Compare commits

..
43 Commits
Author SHA1 Message Date
ergosteur fe6ebdd75b tooling: scheduled profiles/full-sweep list posts+reels by browser (chrome-sync per profile/tab, early stop for weekly); GDL_LISTING=api fallback; reels-tab sweep note in MANUAL-RUN 2026-10-23 00:49:45 -04:00
ergosteur 1b67765d8b tooling: instagram-chrome-scrape.py (was reels-scrape.py) gains --tab posts with early stop; chrome-sync.sh + reels/posts-sync.sh wrappers; MANUAL-RUN.md; atomic-deploy note 2026-10-22 23:53:35 -04:00
ergosteur 30c9fbe611 tooling: auto-upgrade gallery-dl + yt-dlp before every run and weekly; document 2026-10-22 empty posts listing 2026-10-22 23:24:15 -04:00
ergosteur 556eab1169 docs: video upgrade pass — findings, decisions, open items (not built) 2026-10-14 21:39:28 -04:00
ergosteur 99719d164b docs: scraping account replaced by erg05teur on 2026-10-14; warm-up rules apply 2026-10-14 21:34:14 -04:00
ergosteur 5c3307685a docs: profiles timer now weekly on the host; Persistent catch-up on restart 2026-10-14 21:25:57 -04:00
ergosteur 832bbdb391 docs: 2026-10-14 session-death incident, guards, resolution findings
Document the new exit codes (76/77), the wrapper/unit fix, recovery steps, the
verbose output, and the archive-resolution check (images match a logged-in
fetch; 1080p VP9 exists for newer videos; iOS plays VP9+Opus webm but not
VP9+AAC mp4). Correct the stale "timers are disabled" line, and flag that the
repo's weekly profiles timer was never installed on the host.
2026-10-14 21:02:58 -04:00
ergosteur 4a2bd7aa19 feat: guard against a dead Instagram session, show progress, fix missing pings
- check-session.py: preflight in gdl-cron.sh. No unexpired sessionid in the
  Chrome profile gdl reads -> abort before any request, ping /76 (red check).
- stories-streak.py: postflight. Every fetched stories source empty for 3
  consecutive days -> exit 77, for a session Instagram stopped honouring while
  the cookie is still present. The 2026-10-07 session death went unnoticed for
  a week because every run still exited 0.
- gdl-sync.py: [n/N] source headers, a 60s heartbeat with new-file counts, and
  per-source timing, so a healthy paced run no longer looks hung.
- gdl-sync@.service now runs run-%i.sh instead of gdl-cron.sh: HC_URL is set in
  the wrappers, so scheduled runs had never pinged Healthchecks.
- Tests for both guards (offline) and the progress helpers.
2026-10-14 21:02:58 -04:00
ergosteurandClaude Sonnet 5 fcfb8e5fd3 chore: update profiles cadence from monthly to weekly, randomized day
Matches the instagram-archiver-posts healthchecks.io check's Simple
Schedule (period + grace, day-agnostic). The disabled systemd timer
now spreads across the whole week (OnCalendar=weekly + 6d randomized
delay) rather than pinning to a fixed day, same "don't look like a
machine" reasoning as the stories timer's 45m jitter -- just applied
to the day instead of the minute.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-09-14 18:26:07 -04:00
ergosteurandClaude Sonnet 5 9b2c33766f feat: add healthchecks.io pings to run-stories.sh and run-profiles.sh
Start/finish pings wired through gdl-cron.sh via HC_URL, finish ping
carries the run's log as its body (visible in the dashboard, no SSH
needed to diagnose a failure). Exit code is the trailing URL segment
by convention, so the existing "stories: 0 to sync" -> exit 75 remap
already reaches the dashboard as a failure too.

Also fixes the actual bug that would have made this useless: the
routine chown-permission rsync warning (the archive share is never
owned by the ssh user) made gdl-sync.py return 1 on every clean run,
not just failing ones -- harmless when a human reads the log, but it
would have pinged every successful run as a failure. publish() now
only masks that one specific, verified-benign error shape; a real
transfer failure sharing the same exit code (23) still fails loudly.
6 new tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-09-14 14:01:17 -04:00
ergosteurandClaude Sonnet 5 8275416b6e chore: migrate HeeJin's archive from 0ct0ber19 to heejin
Her Instagram handle changed and the old one is now permanently dead
(no redirect, "Sorry, this page isn't available"). Verified @heejin
is her before touching anything. Renamed 3,353 files across her
archive directories on the NAS, matching only the exact username
token so real collab posts physically stored there but owned by
someone else were correctly left untouched. Updated both account-link
files to the new URL. Full record in TOOLING.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-09-14 13:20:32 -04:00
ergosteurandClaude Sonnet 5 5148b677ce docs: record that the reels/posts API block is fixed upstream
gallery-dl 1.32.12 (we were on 1.32.9, three releases behind) resolved
the "HTTP redirect to home page" failures on both posts and reels --
its changelog cites exactly this ("posts & reels extractors... content
retrieval failures"). Verified with a full run-profiles.sh across all
six profiles: zero redirect errors, first time since 2026-08-26.

The browser-scrolling workaround (reels-scrape.py/reels-sync.sh/
run-reels-all.sh) is no longer needed for routine use as a result, but
stays in place as a fallback. Updated the quick-reference commands to
drop run-reels-all.sh from the routine list; kept the original
diagnosis section intact as history, with a note at the top pointing
to the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-09-14 02:03:37 -04:00
ergosteurandClaude Sonnet 5 03dc023620 refactor: prefix the manual entry-point scripts with run-
Makes it unambiguous at a glance that these are meant to be run by
hand, not something a timer or another script calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-09-08 07:55:46 -04:00
ergosteurandClaude Sonnet 5 1a3f85b3aa feat: add fixed-mode entry point scripts for the usual run modes
stories.sh / profiles.sh / full-sweep.sh wrap gdl-cron.sh with the
mode already chosen, so it never has to be remembered or typed.
reels-all.sh runs reels-sync.sh once per profile in
artms_account_links.txt (reusing gdl-sync.py's own URL-file parser,
so it can't drift from what profiles.sh tracks), the scripted version
of the six-profile loop run by hand on 2026-09-03.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-09-08 07:15:25 -04:00
ergosteurandClaude Sonnet 5 84dd76e11e feat: capture coauthors and per-item width/height/tagged_users
Two additions to the metadata sidecars, following up on the 2026-09-01
collab/dedup investigation already documented here:

- coauthors added to the post-level JSON's include list -- a direct,
  native signal for collab detection instead of inferring it from the
  filename/directory identity mismatch also documented that day.
- width, height, width_original, height_original and tagged_users get
  their own per-file sidecar (event: "file", one JSON per downloaded
  media item, named "<filename>.json"), since a carousel's items can
  each have different dimensions and tags -- data the existing
  post-level JSON has no way to represent. owner is deliberately left
  out, same reasoning as audio_user's existing exclusion.

Verified against a live re-fetch of an already-archived carousel:
correct per-item data came back, zero media re-downloaded (skip-archive
still applies; only the new sidecars are new files). 3 new tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-09-01 23:21:22 -04:00
ergosteur d92fed1934 docs: document collab naming mismatch, cross-profile dedup gap, and VP9
Three findings from the 2026-09-01 collab-detection/VP9 investigation in
Cosmo-Live-Downloads, all rooted in gallery-dl's own scraping output:

- Collab posts' JSON is filed under the true owner's identity but the
  media file under the scraped account's — plus coauthors is a native,
  uncaptured signal that already excludes the owner for us.
- The shared archive-db dedups by media_id across all profiles, not per
  profile, so a post reachable from two profiles' listings only gets its
  media written into whichever profile's crawl reached it first (510 such
  posts found archive-wide).
- Some video_versions entries are VP9; format selection is codec-blind,
  and Safari's VP9 decode only wires into its WebM demuxer, never MP4.

Also commits the two saved raw API response examples and the missing-
captions CSV generated during that investigation.
2026-09-01 23:05:19 -04:00
ergosteurandClaude Sonnet 5 7638fa4a9a docs: pull true-owner attribution out into its own explicit note
Was only stated as supporting detail inside the dedup-fix narrative --
someone looking specifically for "where does a shared reel end up?"
had to read through the whole bug story to find it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-08-27 17:52:39 -04:00
ergosteurandClaude Sonnet 5 e0566f06ac feat: add reels-sync.sh, and dedupe reels-scrape.py against the whole archive
reels-sync.sh is the single-command version of the two-step pipeline:
scrape a profile's reels tab, then fetch and publish whatever's new,
with the same hand-paced settings gdl-cron.sh uses. Takes a bare
username or a full profile URL. Exits clean without touching
gdl-sync.py at all when a profile has nothing new.

Also fixes a real inefficiency in reels-scrape.py's dedup, found by
running the new script twice in a row: checking only the scraped
profile's own directories missed that a shortcode already existed
under its true owner elsewhere in the archive (reposts/collabs by
other tracked accounts), so 9 already-held reels got re-fetched for no
reason. Shortcodes are globally unique, so dedup now checks every
archived profile's listing -- all local requests to the viewer's own
API, never instagram.com, so this costs nothing on the budget that
actually matters. See TOOLING.md for the full story.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-08-27 14:54:39 -04:00
ergosteurandClaude Sonnet 5 784ba43bbc docs: document reels-scrape.py, and add a global gallery-dl safety net
Two additions:

- A "Reels" section in TOOLING.md with the exact two-step command
  sequence for scraping and fetching reels by hand, since it's a
  separate manual tool, not wired into gdl-cron.sh.
- ~/.config/gallery-dl/config.json on mattellite, so a plain
  `gallery-dl <url>` typed by hand -- outside gdl-sync.py entirely --
  still gets the hand-paced sleep/rate/api settings instead of
  gallery-dl's own faster defaults. Documented in TOOLING.md since the
  file itself isn't tracked (host-local, like artms.db).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-08-27 14:41:49 -04:00
ergosteurandClaude Sonnet 5 2f46123022 feat: scrape reels by scrolling the real page, since the API is blocked
gallery-dl's dedicated reels extractor POSTs to /api/v1/clips/user/,
which now 302-redirects for this account -- confirmed across multiple
profiles, hours apart, with a freshly-warmed session and a correct
X-IG-WWW-Claim header (ruled out as the cause). The reels tab itself
loads fine in a real, already-signed-in browser, so reels-scrape.py
drives that same Chrome via its loopback CDP port, scrolls the reels
tab like a person would, and scrapes /reel/<code>/ links out of the
rendered page instead of calling the blocked endpoint at all.

It only finds shortcodes -- deduped against the archive via the same
--index gdl-sync.py already uses -- and prints new post URLs. Feeding
many of those into gdl-sync.py needed two small additions: a
--post-urls-file so the list doesn't have to become a giant argv, and
inter-item pacing in run_post_urls (each --post-url was its own
subprocess with nothing pacing the gap between them).

Verified end to end against zindoriyam: 26 reels found, 16 already
archived, 10 new ones fetched and published cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-08-27 14:36:09 -04:00
ergosteurandClaude Sonnet 5 eceee6ec02 docs: add worked --help examples to gdl-sync.py
One example per way of selecting what to fetch (urls-file, --profile,
--all, --post-url), plus the routine/stories/full-sweep pacing choices,
so the flags don't have to be reverse-engineered from the arg list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-08-26 20:45:46 -04:00
ergosteurandClaude Sonnet 5 0753395391 feat: add --post-url to fetch an arbitrary single post or reel
Lets an out-of-band link (shared by someone, not one of the tracked
profiles) be pulled in directly by URL, filed under its owner's account
like any other post. Bypasses profile planning, archive-db seeding, and
the --min-interval floor entirely, since it's a single request rather
than a recurring surface to budget against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
2026-08-26 20:43:38 -04:00
ergosteurandClaude Opus 5 aa062849cb docs: add a quick reference at the top of TOOLING.md
Two commands to get new content onto the site, what the two alarming-but-fine
messages mean, and the two that mean stop. Everything else in this file is
reference material for when something is wrong; this is the part needed when
nothing is.

Says plainly that profiles covers everything but is not a substitute for
stories, because a story posted and expired between two profiles runs is gone
regardless of how thorough the later run is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-22 15:41:28 -04:00
ergosteurandClaude Opus 5 b2eae177b6 docs: document the manual procedures properly, pacing included
The hand-run example had no pacing flags, so following it ran at gdl-sync.py's
defaults -- 6-10s and 1M -- which since yesterday is roughly half the caution
the wrapper applies. A documented manual procedure that is less careful than
the automation is backwards, and that is what this file said until now.

Adds the parts that were only ever in a transcript:

- how to detach a long run, and why `pkill -f <pattern>` over ssh kills your
  own shell instead of the sync (the pattern matches the ssh command line --
  this happened twice yesterday)
- that nothing reaches the archive until a run finishes, so killing one midway
  is safe
- a "when a run fails" runbook in cost order: read the log, check the cookies,
  then look at the browser. The first two are free.

The two results that look like failures and are not: rsync exit 23 is the
chown to rslsync failing because the ssh user is not root, and "No results"
just means a profile has no active story.

The cookie check is the one worth having written down -- exporting them and
looking at the shape of sessionid separates "Chrome decryption broke locally"
from "the account is in trouble" without spending a single request, and it is
what ruled out the wrong explanation yesterday.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-22 11:34:23 -04:00
ergosteurandClaude Opus 5 7c7ce70ff9 refactor: rename the modes to say what they actually do
`full` was the misleading one: it is the abort-LIMITED run, the one that
deliberately stops enumerating a profile as soon as it reaches content already
held. Calling it "full" invited exactly the wrong assumption about coverage.
And `sweep` gave no hint that it was the exhaustive one.

    full   -> profiles     every surface, --abort 50, ~40-60 requests
    sweep  -> full-sweep   every surface, no abort,   ~420 requests

The old names now exit 2 with a pointer to the new one rather than a bare
"unknown mode", since muscle memory and any stray crontab will still use them.

full-sweep's description now says what it costs. At ~420 requests it is the
same order as the run that preceded the 2026-08-21 scraping warning, spent to
catch a handful of retroactively edited posts, so the docs suggest running it
by hand when you mean to rather than leaving it on a timer. Its cadence was
never actually agreed.

Units renamed to match and re-verified with systemd-analyze; the old ones are
removed from the host. All three timers remain disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-22 11:31:45 -04:00
ergosteurandClaude Opus 5 9c574e02eb fix: make the scheduled runs as careful as the manual ones, and honest about failing
All three modes now use the pacing the 2026-08-22 runs used by hand after the
scraping warning -- 12-20s between requests, 5-10s between downloads, 500K --
against gdl-sync.py's defaults of 6-10 / 3-6 / 1M. Those values produced 0 400s
and 0 429s across 18 sources. An archive sync has no deadline; being slow is
free and being restricted is not.

Two things that made "scheduled" not mean much:

`stories` now uses --min-interval 8, not 20. At 20h the daily timer silently
did nothing whenever a manual run had happened the previous afternoon, which is
exactly what happened on 2026-08-21. The floor is there to stop an aborted
restart re-enumerating profiles -- minutes to hours -- and a stories fetch is
one request per profile, so 8h permits about twelve requests a day instead of
six. A run that skips every source now exits 75 and says so.

And the wrapper never reported failure at all. It ran the sync inside
`{ ...; exit $status; } | tee`, and the left side of a pipeline is a subshell,
so that exit set the subshell's status while the script returned tee's --
always 0. Every claim about cron mailing on failure was wrong. Fixed with
bash PIPESTATUS, hence the shebang change.

Verified with a stub sync across five cases: skipped-and-clean exits 75 with a
warning, a real failure propagates its own code, a normal run stays quiet, and
`full` skipping everything is not treated as an anomaly. Testing also caught
that the log name was only second-granular, so runs in the same second shared a
file and the skip check saw the previous run's output; the check now reads a
per-run temp file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-22 11:25:43 -04:00
ergosteurandClaude Opus 5 3a7dc749c7 docs: bring the outstanding list back in line with reality
Four claims went stale over two days and would each have misled someone
reading this cold:

- "the first unattended run has not happened yet" -- it has, and it did the
  wrong thing: skipped every source on the 20h floor and reported success.
- "no daily stories run is scheduled" -- one exists, but is disabled after the
  scraping warning, which is a materially different situation. Stories now
  depend on someone remembering, and every day nobody runs it is a day gone.
- "--abort 50 is opt-in and nothing uses it yet" -- gdl-cron.sh full passes it
  and both manual runs used it. sweep deliberately does not, which is the
  point of sweep.
- the diff baseline in "Verifying a run" was pre-2026-08-22 and would have
  made a correct run look like it had lost files.

Adds the gap that matters most for re-enabling: the scheduled modes still use
the default 6-10s pacing while the hand runs after the warning used 12-20s, so
turning the timers back on as they stand would make the automation less
careful than the humans were.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-22 11:15:15 -04:00
ergosteurandClaude Opus 5 627cedd3a4 docs: correct the incident record — the stories were caught, not lost
The pause section predicted official_artms and 0ct0ber19 stories would expire
uncollected. They did not: both surfaces were fetched by hand the next day at
roughly double the configured caution, with 0 400s and 0 429s. 16 story media
and 26 posts/reels media, 85 files into the archive.

That confirms the 400s were the challenge state rather than a block -- once
the interstitial was dismissed, the same endpoints served normally.

It does not retire the warning, and the section says so. Two hand-paced runs
are not evidence the old cadence was safe. It also flags the gap that matters
for whenever the timers go back on: the scheduled "full" mode still runs at
the default 6-10s pacing, so the automation would be less careful than the
manual runs that followed a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-22 08:51:22 -04:00
ergosteurandClaude Opus 5 99fb24ad21 docs: record the scraping warning and stop the automation
Instagram flagged the account on 2026-08-21 -- not a suspension, an
interstitial at /accounts/scraping_warning/ saying it suspects automated
behaviour. Dismissed; account healthy. All three timers are disabled.

The sequence is worth keeping because each step misled differently. The daily
timer fired and exited 0 having fetched nothing: yesterday's manual run was
19h earlier, under the 20h floor, so a silent no-op on the one surface that
cannot be backfilled reported as success. Chrome was separately OOM-killed, so
no browser was running to notice. The recovery run then drew 400s from the
stories endpoint on all six profiles.

Cookies were exported and checked before assuming a block -- sessionid
well-formed and unexpired -- which cost nothing and ruled out the local
explanation. Only then did the browser reveal the interstitial.

Records the four things to fix before re-enabling: the floor defeats the daily
job, a skipped stories run must be loud rather than exit 0, the daily cadence
is itself the most machine-like pattern we run, and chrome-devtools.service
has Restart=no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-21 23:29:08 -04:00
ergosteurandClaude Opus 5 db0b97dcec docs: add a runbook for verifying a run, and record why today happened
The first unattended run is tomorrow morning and nobody will be watching it,
so this writes down how to tell whether it worked -- entirely from the
journal, local logs and our own viewer's API, without spending a single
Instagram request to check.

Includes the per-profile file counts as of today so tomorrow's numbers have
something to diff against, and says plainly that a stories run adding zero
files is a normal result rather than a failure: profiles often have no active
story, and someone checking for the first time would reasonably read 0 as
broken.

Repeats the one rule that matters more than any tuning: a CDN 429 ends the
session. The warning order last time was 429 -> 400 on the highlights endpoint
-> suspension, so the runbook gives the disable command rather than leaving it
to judgement in the moment.

Also records what changed today and why, since none of it is recoverable from
the diffs: the run, the --abort measurement, the public/private split, and the
automation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 20:06:15 -04:00
ergosteurandClaude Opus 5 652747e0f1 feat: schedule the sync with systemd timers, and let the fetch host publish
mattellite's key is now in the NAS's authorized_keys for agentapi, so
gdl-sync.py publishes straight to the archive. That was the last manual step:
the 2026-08-20 run published locally and rsynced by hand with sshpass, which
no scheduled job could do. Verified end to end -- key auth, then a dry-run
rsync from the fetch host into the archive root.

Three timers, installed and enabled: stories daily, full monthly, sweep
quarterly. A templated gdl-sync@.service takes the mode as its instance name.

Settings that are load-bearing rather than decoration:

  RandomizedDelaySec=45m  a job firing at exactly 09:00 daily is obviously a
                          machine; list-timers now shows 09:36, not 09:00
  Persistent=true         catch up a run the host slept through -- cron just
                          skips, and a skipped stories run is content gone
  TimeoutStartSec=infinity  a sweep runs for hours at this pacing; the 90s
                          default would kill it mid-fetch

Two things caught while installing, both documented: month names are invalid
in OnCalendar's date field (Jan,Apr,Jul,Oct-07 is rejected -- use numerics,
and check with systemd-analyze calendar), and `systemctl --user` over
non-interactive ssh needs XDG_RUNTIME_DIR or it cannot find the bus.

Smoke-tested by starting the stories service: it spent zero Instagram requests
because the 20h floor skipped all six sources, which is exactly what a job
firing twice should do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 20:03:53 -04:00
ergosteurandClaude Opus 5 ef99389cf4 feat: add an unattended wrapper, and document running this without an agent
gdl-sync.py always needed a fistful of paths on the command line, and the
2026-08-20 run published to a local directory and then rsynced to the NAS by
hand -- fine interactively, impossible from cron. gdl-cron.sh takes one
argument (stories|full|sweep) and does the whole thing.

Staging is wiped every run deliberately: what we already hold is decided by
the skip-archive, never by what happens to be sitting in staging, so starting
empty is correct and keeps the publish to just the new files.

Documents the three cadences and what each is for -- notably that only `sweep`
notices carousels edited after we archived them, and that only `stories` loses
content if it is skipped, since stories cannot be backfilled.

Cron notes the two things that would silently break it: cron runs /bin/sh so
$RANDOM does not exist (use shuf), and `%` in a crontab line means newline.
systemd is offered as the better option because RandomizedDelaySec and
Persistent=true are built in -- a host asleep at 09:00 silently skips a cron
job, and a skipped stories run is content gone for good.

The publish step is still blocked on mattellite having no key on the NAS; that
is recorded under Outstanding as the one thing between this and unattended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 19:59:57 -04:00
ergosteurandClaude Opus 5 ff0d0f9f78 docs: record what is still outstanding, and unstale the scanner section
Nothing here is broken — these are decisions not made and cleanups not done,
written down before the session's context is lost.

The two that can actually cost something: no daily stories run is scheduled,
and stories are the one surface that cannot be backfilled; and the `seeded`
flags in the state file were reconstructed by hand from a log rather than
derived from the archive DB, so losing artms.db without also clearing the
state file would leave those sources permanently unseeded and re-download
everything.

Also corrects "Scanner work (not done yet)", which shipped in 53b1f80 —
the three .json shapes are told apart structurally in gallery-dl-sidecar.ts
and isReel comes from the sidecar's type. That file lives on main: it parses
archives at display time and is viewer code, not tooling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 16:48:17 -04:00
ergosteurandClaude Opus 5 f53c47a5ef docs: move the branch warning into a TOOLING.md that main does not have
Putting it at the top of README.md worked, but it diverged a shared file: a
probe showed main editing the adjacent line conflicts on every merge. The
warning survived the conflict, so nothing was ever silently lost, but a file
that only exists on this branch has no such cost at all.

TOOLING.md carries the warning plus what CLAUDE.md would have said if it could
— the commands, the remote policy, and the two guards, which are local and
unversioned and so are absent from every fresh clone.

Shared files are byte-identical to main again: CLAUDE.md, README.md and
package.json. The divergence is now only files main has never had.

Trade-off worth knowing: gitea renders README.md on the branch page and does
not render TOOLING.md, so this warning is one click less visible than it was.
The pre-push hook, not the documentation, remains the guard that actually
stops a mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 16:36:22 -04:00
ergosteurandClaude Opus 5 a54249e79d docs: warn at the top of the README that this branch stays off GitHub
This branch is the only place the fetching tooling still exists, and it names
the fetch host's public IP, the cookie's browser profile, the NAS path and the
accounts being archived. main's history was rewritten, the GitHub repo deleted
and recreated, and 22 ghcr images pruned to get that material out of public
view — one push of this branch undoes all of it.

Deliberately at the top of the README rather than in a separate file: gitea
renders README.md on the branch page, which is where someone actually looks
before pushing. That does diverge from main, but only by an addition, which a
merge cannot silently drop — unlike the CLAUDE.md/package.json case, where
main was DELETING lines this branch kept and every merge re-applied the
deletion. Worst case here is a trivial conflict if main edits the same region,
and the block says to keep both.

Also records that the pre-push hook is local and unversioned, since a fresh
clone is the realistic way this goes wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 16:33:49 -04:00
ergosteur 84c573b3ed merge main into tooling: 1.8.1, comment stripping 2026-08-20 15:38:28 -04:00
ergosteurandClaude Opus 5 26d2d3e379 chore: release 1.8.1
Docker Build and Publish / build-and-push (push) Failing after 11s
First build from the redacted history, and the first that does not ship
source comments in the server bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 15:36:17 -04:00
ergosteurandClaude Opus 5 61c2b62141 fix: stop shipping source comments in the server bundle
`tsc` keeps comments by default, so the doc comment in archive-grouping.ts
describing the sidecar layout was emitted into dist-server and copied into the
runtime image. Every published container image on ghcr carries it — verified by
pulling the dist-server layer of :latest and grepping it:

    app/src/lib/archive-grouping.js:10:  *   <user>  -> posts (base)

That comment names real archived accounts, which is exactly what main was
redacted to remove, so the redaction was incomplete while the build kept
re-emitting them. The frontend was never affected: Vite strips comments, and
the 432K dist layer greps clean.

--removeComments takes dist-server from 0 comment lines. docs/ was never at
risk; the multi-stage build copies only dist/ and dist-server/ into runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 15:36:00 -04:00
ergosteurandClaude Opus 5 6b8410da09 docs: restore the real account names, which belong only on this branch
main's history was redacted on 2026-08-20: every archived account name was
replaced throughout all 65 commits, because main is published to GitHub and
the list of accounts identifies the archive as surely as a hostname does. The
viewer's test fixtures now use synthetic names chosen to preserve exactly what
each case tests — a leading digit, an underscore, a dotted username, a
bracketed or dotted highlight title, and the trailing U+2800 that case 12
exists for.

The redaction swept these three docs too, since they were in the same history.
This puts the real names back, on the branch that is not published. The
scripts needed nothing: they are generic and never named an account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 15:03:16 -04:00
ergosteurandClaude Opus 5 de8145447a chore: keep CLAUDE.md and package.json identical to main
A superset branch only stays cheap if the files it shares with main are
untouched. Both diverged: package.json carried a jd2 script and CLAUDE.md
described the tooling, so every merge from main would have conflicted on them
forever, and main's removal commit would have kept trying to delete the
tooling entries here.

The tooling files themselves do not exist on main, so they can hold whatever
they like. The jd2 invocation moves into docs/jdownloader.md as a direct npx
call instead of an npm script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 15:00:16 -04:00
ergosteurandClaude Opus 5 96ea0cc1d0 feat: keep the archive-fetching tooling on a branch of its own
The scripts and docs for fetching from Instagram now live here rather than on
main, which is the branch published to GitHub. They carry things that do not
belong in a public repo: the fetch host's public IP, the browser profile path
the cookie is read from, the NAS archive path, and the list of accounts being
archived.

This branch is a superset of main — the viewer plus the tooling — so it can
take main's changes by merging, and the npm script and CLAUDE.md entries that
reference the tooling live here where the files actually exist.

Restored with the sync work from the 2026-08-20 run already in place: the
--abort flag, the corrected yt-dlp install advice, and the measurements behind
both.

Note that main's history was rewritten to strip these paths, so the tooling's
own per-file history does not exist on this branch. It is preserved on gitea
as pre-rewrite-20260820 and pre-rewrite-tooling-20260820.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 14:52:34 -04:00
ergosteurandClaude Opus 5 882296b1c0 chore: move the archive-fetching tooling out of this branch
The fetching scripts and their docs now live on the `tooling` branch, which
is not published to GitHub. This removes the two references that would
otherwise dangle here: the `jd2` npm script and the CLAUDE.md bullet
describing it.

The viewer's own gallery-dl support is untouched and stays here —
src/lib/gallery-dl-sidecar.ts and friends parse sidecars at display time and
are app code, not tooling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 14:52:03 -04:00
ergosteurandClaude Opus 5 4f8b0021c6 chore: stop tracking compiled Python bytecode
Two .pyc files under scripts/__pycache__ were committed at some point and have
been churning ever since — merely importing gdl-sync.py to check a config
rewrites them and dirties the tree, which is how they surfaced.

.gitignore had no Python entries at all, only Node ones. The files stay on
disk; this just untracks them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXfdJu7QhSJLr47K7koTDF
2026-08-20 14:38:41 -04:00
36 changed files with 5784 additions and 9 deletions
+2
View File
@@ -9,3 +9,5 @@ coverage/
!.env.example !.env.example
_sample-archives _sample-archives
_gemini-plans _gemini-plans
__pycache__/
*.pyc
-3
View File
@@ -15,9 +15,6 @@ InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram d
- `npm run lint` — type-check only (`tsc --noEmit`) - `npm run lint` — type-check only (`tsc --noEmit`)
- `npm test` / `npm run test:watch` — vitest - `npm test` / `npm run test:watch` — vitest
- `npx vitest run src/lib/archive-patterns.test.ts` — a single test file - `npx vitest run src/lib/archive-patterns.test.ts` — a single test file
- `npm run jd2 -- --archives <dir> --dry-run` — generate JDownloader `.crawljob`
files for every profile on disk (see `scripts/jd2-sync.ts` and
`docs/jdownloader.md`)
Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not. Local development usually needs both `npm run dev` and `npm run server`. Local-folder mode works without the backend; server-mode archives do not.
+109
View File
@@ -0,0 +1,109 @@
# Manual run — scrape, archive, push to the NAS
**`tooling` branch only. Never push this to GitHub.** Like `TOOLING.md`, it names the fetch host,
the NAS hosts and their paths.
How to do by hand everything the timers normally do, plus the second NAS hop that feeds
`cosmo_web`. `TOOLING.md` has the details behind each step.
## Where things go
1. **mattellite → ana-nas.** Every scrape script ends by rsyncing its new files to
`ana-nas:/volume1/rslsync/sync/Instagram-archive/archives/` (10.20.28.200).
<https://instaarchive.ergosteur.com> reads straight from there. **There is no separate step
for this hop.**
2. **ana-nas → DH2300.** The `archiver-instagram` container on `DH2300-D7CA` pulls that archive,
makes thumbnails, remuxes video, normalizes into `cosmo_web`'s data and translates captions.
It runs weekly (Wednesday 04:00). Step 6 below runs it by hand.
## 1. Pre-flight (mattellite)
```sh
ssh matt@mattellite
~/gdl/check-session.py # want: "session ok ... expires <date>"
curl -s http://localhost:9222/json/version | head -2 # Chrome DevTools port is up
~/gdl/gdl-upgrade.sh # gallery-dl + yt-dlp to latest
```
If the session check fails, log in to Instagram in the VNC Chrome (display `:1`), **reload the
tab** (a dead session can still look logged in) and re-check.
## 2. Stories — first; they expire in 24 h
```sh
~/gdl/run-stories.sh
```
About 2 minutes. `rsync ... (code 23)` / `failed to set permissions` is expected: the script
says "files landed" when that is the only problem. `No results` for a profile just means it has
no active story.
## 3. Posts and reels
**While gallery-dl's listing is broken** ([gh#9829](https://github.com/mikf/gallery-dl/issues/9829),
from 2026-10-22), use the browser scraper, one profile at a time:
```sh
for p in heejin kimxxlip; do ~/gdl/posts-sync.sh $p; done
for p in withaseul cher_ryppo zindoriyam; do ~/gdl/posts-sync.sh $p; done
~/gdl/posts-sync.sh official_artms
```
- `posts-sync.sh` scrolls `/<user>/` in the signed-in Chrome, stops once it reaches posts the
archive already holds, and fetches only the new ones as single posts. Each profile takes a few
minutes, mostly the 12–20 s gap between fetches.
- It prints `no new posts for <user>` when a profile is caught up.
- Logs: `~/gdl/logs/posts-<user>-*.log`.
- **Then sweep the Reels tab too:** `~/gdl/run-reels-all.sh` (~15 min). Most reels never
appear in the main grid. On 2026-10-23 only 3 of heejin's 114 reels did, so `posts-sync.sh`
alone misses them. `reels-sync.sh <user>` does a single profile.
**Or the whole thing in one command**, the same run the weekly timer does: gallery-dl for
stories and highlights, then the browser for every profile's posts grid and Reels tab, each
stopping once it reaches archived content:
```sh
~/gdl/run-profiles.sh
```
Once a gallery-dl release fixes the listing, `GDL_LISTING=api ~/gdl/run-profiles.sh` does posts
and reels through gallery-dl again (~5 min).
For long runs, detach:
`setsid nohup ~/gdl/run-profiles.sh >/dev/null 2>&1 &`, then
`tail -f ~/gdl/logs/$(ls -1t ~/gdl/logs | head -1)`.
**Stop at once** on a `429` from `scontent-*.cdninstagram.com` or a `400 Bad Request` on
`/api/v1/feed/reels_media/`. See "When a run fails" in `TOOLING.md`.
## 4. Highlights
These only come through `run-profiles.sh`. Expect them to fail with the same
`Requested user could not be found` until gh#9829 is fixed in a release, so skip them until then.
## 5. Check it landed (any machine)
```sh
for u in heejin kimxxlip withaseul cher_ryppo zindoriyam official_artms; do
n=$(curl -s "https://instaarchive.ergosteur.com/api/archives/$u/files" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d if isinstance(d,list) else d["files"]))')
printf '%-16s %s\n' "$u" "$n"
done
```
Or browse <https://instaarchive.ergosteur.com>. New files appear without a redeploy.
## 6. Push to `cosmo_web` (the DH2300 hop)
This runs the same command the container's cron line runs (from `docker/archiver-instagram-entrypoint.sh`
in Cosmo-Live-Downloads):
```sh
ssh matt@DH2300-D7CA
docker exec cosmo-web-archiver-instagram-1 sh -c \
'cd /work && . /app/instagram-archiver.env && python3 scripts/instagram_archive_run.py'
docker logs --tail 50 cosmo-web-archiver-instagram-1
```
It pings that container's Healthchecks check, same as a scheduled run. The pipeline itself
(mirror, thumbnail, remux, normalize, collaborators, translate) is described in the docstring of
`scripts/instagram_archive_run.py` in Cosmo-Live-Downloads.
+1036
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
https://www.instagram.com/heejin/
https://www.instagram.com/kimxxlip/
https://www.instagram.com/withaseul/
https://www.instagram.com/cher_ryppo/
https://www.instagram.com/zindoriyam/
https://www.instagram.com/official_artms/
+221
View File
@@ -0,0 +1,221 @@
[
[
2,
{
"category": "instagram",
"coauthors": [
{
"full_name": "\ucd5c\ub9ac \ucd5c\uc608\ub9bc Choerry",
"id": "57668363710",
"username": "cher_ryppo"
},
{
"full_name": "\uc9c4\uc194 \ud835\udc09\ud835\udc08\ud835\udc0d\ud835\udc12\ud835\udc0e\ud835\udc14\ud835\udc0b",
"id": "55465048543",
"username": "zindoriyam"
},
{
"full_name": "Official ARTMS",
"id": "58524253183",
"username": "official_artms"
},
{
"full_name": "HEEJIN",
"id": "57723176039",
"username": "0ct0ber19"
},
{
"full_name": "\uae40\ub9bd KIMLIP",
"id": "57506288936",
"username": "kimxxlip"
}
],
"count": 1,
"date": "2026-08-18 07:18:54",
"description": "#\uc81c\uc791\uc9c0\uc6d0 ARTMS(\uc544\ub974\ud14c\ubbf8\uc2a4) \u2018Blue Blood Night\u2019 \ub9ac\uc2a4\ub2dd \ud30c\ud2f0 \ud83c\udf19\n\n\uc131\uc218\ub3d9\uc5d0\uc11c \ud3bc\uccd0\uc9c4 ARTMS\uc640 300\uba85 \ud32c\ub4e4\uc758 \uc5ed\ub300\uae09 \ubc24!\n\nARTMS\uac00 \uc57d 300\uba85\uc758 \ud32c\ub4e4\uacfc \ud568\uaed8 \ud074\ub7fd \ud615\ud0dc\uc758\n\ub3c5\ud2b9\ud558\uace0 \ubabd\ud658\uc801\uc778 \uacf5\uac04\uc5d0\uc11c \uc624\ud504\ub77c\uc778 \ud32c \uc774\ubca4\ud2b8\n\u2018Blue Blood Night\u2019 \ub9ac\uc2a4\ub2dd \ud30c\ud2f0\ub97c \uac1c\ucd5c\ud588\uc2b5\ub2c8\ub2e4 \u2728\n\n\ud558\uc774 \ub370\ud328\ub274\ub3c4 \uc678\uce58\uace0 ARTMS \uc0ac\ub791\ud574\uc694\ub3c4 \uc678\ucce4\ub358\n\ub728\uac70\uc6e0\ub358 \ud604\uc7a5\uc5d0 \ub370\ud328\ub274\uac00 \ub2e4\ub140\uc654\uc2b5\ub2c8\ub2e4 \ud83d\ude0e\n\n\ud83d\udccc\uc544\ub974\ud14c\ubbf8\uc2a4 (ARTMS) \n@official_artms \n\n\u25aa\ufe0f2024\ub144\uc5d0 \ub370\ubdd4\ud55c \ubaa8\ub4dc\ud558\uc6b0\uc2a4(MODHAUS) \uc18c\uc18d\uc758 5\uc778\uc870 \uac78\uadf8\ub8f9 \n\u25aa\ufe0f\uba64\ubc84: \ud76c\uc9c4, \ud558\uc2ac, \uae40\ub9bd, \uc9c4\uc194, \ucd5c\ub9ac \n\u25aa\ufe0f\uc774\ub2ec\uc758 \uc18c\ub140 \ucd9c\uc2e0 \uba64\ubc84\ub4e4\uc774 \ub73b\uc744 \ubaa8\uc544 \uacb0\uc131\ud55c \ud504\ub85c\uc81d\ud2b8\uc774\uc790 \uc815\uc2dd \uac78\uadf8\ub8f9 \n\u25aa\ufe0f\uccab \uc815\uaddc \uc568\ubc94\uacfc \ud0c0\uc774\ud2c0\uace1 \u2018Virtual Angel\u2019 \ub4f1\uc744 \uc120\ubcf4\uc784\n\n#ARTMS #\uc544\ub974\ud14c\ubbf8\uc2a4 #BlueBloodNight\n\n\ud83c\udfa5 @dailyfashion_news",
"fullname": "\u270d\ud83c\udffb\ud83e\udd13\ud328\uc158 \ub274\uc2a4 \uc694\uc57d \u2b50\ufe0f \ub370\ud328\ub274\ud83d\ude0e\ud83d\udc85\ud83c\udffb",
"liked": false,
"likes": 7219,
"owner_id": "38980901318",
"pinned": [],
"post_date": "2026-08-18 07:18:54",
"post_id": "3966279735525149864",
"post_shortcode": "DcLDme7zoyo",
"post_url": "https://www.instagram.com/reel/DcLDme7zoyo/",
"subcategory": "reel",
"tags": [
"#ARTMS",
"#BlueBloodNight",
"#\uc544\ub974\ud14c\ubbf8\uc2a4",
"#\uc81c\uc791\uc9c0\uc6d0"
],
"type": "reel",
"username": "dailyfashion_news"
}
],
[
3,
"ytdl:https://www.instagram.com/reel/DcLDme7zoyo/1.mp4",
{
"category": "instagram",
"coauthors": [
{
"full_name": "\ucd5c\ub9ac \ucd5c\uc608\ub9bc Choerry",
"id": "57668363710",
"username": "cher_ryppo"
},
{
"full_name": "\uc9c4\uc194 \ud835\udc09\ud835\udc08\ud835\udc0d\ud835\udc12\ud835\udc0e\ud835\udc14\ud835\udc0b",
"id": "55465048543",
"username": "zindoriyam"
},
{
"full_name": "Official ARTMS",
"id": "58524253183",
"username": "official_artms"
},
{
"full_name": "HEEJIN",
"id": "57723176039",
"username": "0ct0ber19"
},
{
"full_name": "\uae40\ub9bd KIMLIP",
"id": "57506288936",
"username": "kimxxlip"
}
],
"count": 1,
"date": "2026-08-18 07:18:54",
"description": "#\uc81c\uc791\uc9c0\uc6d0 ARTMS(\uc544\ub974\ud14c\ubbf8\uc2a4) \u2018Blue Blood Night\u2019 \ub9ac\uc2a4\ub2dd \ud30c\ud2f0 \ud83c\udf19\n\n\uc131\uc218\ub3d9\uc5d0\uc11c \ud3bc\uccd0\uc9c4 ARTMS\uc640 300\uba85 \ud32c\ub4e4\uc758 \uc5ed\ub300\uae09 \ubc24!\n\nARTMS\uac00 \uc57d 300\uba85\uc758 \ud32c\ub4e4\uacfc \ud568\uaed8 \ud074\ub7fd \ud615\ud0dc\uc758\n\ub3c5\ud2b9\ud558\uace0 \ubabd\ud658\uc801\uc778 \uacf5\uac04\uc5d0\uc11c \uc624\ud504\ub77c\uc778 \ud32c \uc774\ubca4\ud2b8\n\u2018Blue Blood Night\u2019 \ub9ac\uc2a4\ub2dd \ud30c\ud2f0\ub97c \uac1c\ucd5c\ud588\uc2b5\ub2c8\ub2e4 \u2728\n\n\ud558\uc774 \ub370\ud328\ub274\ub3c4 \uc678\uce58\uace0 ARTMS \uc0ac\ub791\ud574\uc694\ub3c4 \uc678\ucce4\ub358\n\ub728\uac70\uc6e0\ub358 \ud604\uc7a5\uc5d0 \ub370\ud328\ub274\uac00 \ub2e4\ub140\uc654\uc2b5\ub2c8\ub2e4 \ud83d\ude0e\n\n\ud83d\udccc\uc544\ub974\ud14c\ubbf8\uc2a4 (ARTMS) \n@official_artms \n\n\u25aa\ufe0f2024\ub144\uc5d0 \ub370\ubdd4\ud55c \ubaa8\ub4dc\ud558\uc6b0\uc2a4(MODHAUS) \uc18c\uc18d\uc758 5\uc778\uc870 \uac78\uadf8\ub8f9 \n\u25aa\ufe0f\uba64\ubc84: \ud76c\uc9c4, \ud558\uc2ac, \uae40\ub9bd, \uc9c4\uc194, \ucd5c\ub9ac \n\u25aa\ufe0f\uc774\ub2ec\uc758 \uc18c\ub140 \ucd9c\uc2e0 \uba64\ubc84\ub4e4\uc774 \ub73b\uc744 \ubaa8\uc544 \uacb0\uc131\ud55c \ud504\ub85c\uc81d\ud2b8\uc774\uc790 \uc815\uc2dd \uac78\uadf8\ub8f9 \n\u25aa\ufe0f\uccab \uc815\uaddc \uc568\ubc94\uacfc \ud0c0\uc774\ud2c0\uace1 \u2018Virtual Angel\u2019 \ub4f1\uc744 \uc120\ubcf4\uc784\n\n#ARTMS #\uc544\ub974\ud14c\ubbf8\uc2a4 #BlueBloodNight\n\n\ud83c\udfa5 @dailyfashion_news",
"display_url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.82787-15/777982919_18124593284301319_6754878398807305029_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=107&ig_cache_key=Mzk2NjI3OTczNTUyNTE0OTg2NDE4MTI0NTkzMjgxMzAxMzE5.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjEzMjAuc2RyLnZpZGVvX2RlZmF1bHRfY292ZXJfZnJhbWUuQzMifQ%3D%3D&_nc_ohc=VTiIkvHCoBMQ7kNvwFnD08V&_nc_oc=Adp2K1e-pF18avq4GpMbXRgiZv_T6ySFEqTFdXPRtemyWnFeG8p6cgvxaXxZtyDDw-U&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=instagram.fyto3-1.fna&_nc_gid=-Kd8mdFfght8nFvwkJmRPA&_nc_ss=7a22e&oh=00_AQIKaJEGeU5iyrLIjnfXozkpbabHpemHWlDoSEEx11SMFQ&oe=6A9D5761",
"extension": "mp4",
"filename": "AQNbHAasCyJwuQWdYi7vWw3w6iCiMdBAXZQ58cnOKhanFZgw_IAhVC_f380GVo23sNeeCD559rVhmUSr0bmruolOjAgXMVwrmoRu88A",
"fullname": "\u270d\ud83c\udffb\ud83e\udd13\ud328\uc158 \ub274\uc2a4 \uc694\uc57d \u2b50\ufe0f \ub370\ud328\ub274\ud83d\ude0e\ud83d\udc85\ud83c\udffb",
"height": 1920,
"height_original": 1920,
"liked": false,
"likes": 7219,
"media_id": "3966279735525149864",
"num": 1,
"owner": {
"account_badges": [],
"account_type": 3,
"can_see_quiet_post_attribution": true,
"eligible_for_text_app_activation_badge": false,
"fan_club_info": {
"autosave_to_exclusive_highlight": null,
"connected_member_count": null,
"fan_club_id": null,
"fan_club_name": null,
"fan_consideration_page_revamp_eligiblity": null,
"has_created_ssc": null,
"has_enough_subscribers_for_ssc": null,
"is_fan_club_gifting_eligible": null,
"is_fan_club_referral_eligible": null,
"is_free_trial_eligible": null,
"largest_public_bc_id": null,
"should_show_playlists_in_profile_tab": null,
"subscriber_count": null
},
"fbid_v2": "17841439039552612",
"feed_post_reshare_disabled": false,
"friendship_status": {
"followed_by": false,
"following": false,
"is_bestie": false,
"is_feed_favorite": false,
"is_muting_reel": false,
"is_private": false,
"is_restricted": false
},
"full_name": "\u270d\ud83c\udffb\ud83e\udd13\ud328\uc158 \ub274\uc2a4 \uc694\uc57d \u2b50\ufe0f \ub370\ud328\ub274\ud83d\ude0e\ud83d\udc85\ud83c\udffb",
"has_anonymous_profile_picture": false,
"hd_profile_pic_url_info": {
"height": 1080,
"url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.2885-19/283399158_1201757910664534_1998021056617019217_n.jpg?efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2gE8DUM4skVKFnTxJ_6S8QN3RWaHsgKT9sigiWMRE8sqvl9r488ADsJFU6ErFHln2eg&_nc_ohc=LjOAkQaNPtMQ7kNvwHhtSrv&_nc_gid=-Kd8mdFfght8nFvwkJmRPA&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQLdX6t5krhp725dzvNOvQbGOFpVdvwgZWqDkFvv1AeQPA&oe=6A9D6555&_nc_sid=fc8dfb",
"width": 1080
},
"hd_profile_pic_versions": [
{
"height": 320,
"url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.2885-19/283399158_1201757910664534_1998021056617019217_n.jpg?stp=dst-jpg_s320x320_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2gE8DUM4skVKFnTxJ_6S8QN3RWaHsgKT9sigiWMRE8sqvl9r488ADsJFU6ErFHln2eg&_nc_ohc=LjOAkQaNPtMQ7kNvwHhtSrv&_nc_gid=-Kd8mdFfght8nFvwkJmRPA&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQKux7g7JpZsP_Q7z6rX3bFlcdVCUCwwDAQfmRRWbB6ePQ&oe=6A9D6555&_nc_sid=fc8dfb",
"width": 320
},
{
"height": 640,
"url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.2885-19/283399158_1201757910664534_1998021056617019217_n.jpg?stp=dst-jpg_s640x640_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2gE8DUM4skVKFnTxJ_6S8QN3RWaHsgKT9sigiWMRE8sqvl9r488ADsJFU6ErFHln2eg&_nc_ohc=LjOAkQaNPtMQ7kNvwHhtSrv&_nc_gid=-Kd8mdFfght8nFvwkJmRPA&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQJJz0fXTbLsfMqkiG7KHQtZbaHwHAU8nS1eTzB0sQqRSA&oe=6A9D6555&_nc_sid=fc8dfb",
"width": 640
}
],
"id": "38980901318",
"is_active_on_text_post_app": true,
"is_embeds_disabled": false,
"is_favorite": false,
"is_private": false,
"is_ring_creator": false,
"is_unpublished": false,
"is_verified": true,
"latest_reel_media": 1788306389,
"pk": "38980901318",
"pk_id": "38980901318",
"profile_pic_id": "2844231369895585868_38980901318",
"profile_pic_url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.2885-19/283399158_1201757910664534_1998021056617019217_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2gE8DUM4skVKFnTxJ_6S8QN3RWaHsgKT9sigiWMRE8sqvl9r488ADsJFU6ErFHln2eg&_nc_ohc=LjOAkQaNPtMQ7kNvwHhtSrv&_nc_gid=-Kd8mdFfght8nFvwkJmRPA&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQI28hrlvg9XAWGFkTjCyv96HLuMEZb5q6AlNbUUqw2WVQ&oe=6A9D6555&_nc_sid=fc8dfb",
"show_account_transparency_details": true,
"show_ring_award": false,
"strong_id__": "38980901318",
"text_post_app_is_private": false,
"third_party_downloads_enabled": 2,
"transparency_product_enabled": false,
"user_activation_info": {},
"username": "dailyfashion_news"
},
"owner_id": "38980901318",
"pinned": [],
"post_date": "2026-08-18 07:18:54",
"post_id": "3966279735525149864",
"post_shortcode": "DcLDme7zoyo",
"post_url": "https://www.instagram.com/reel/DcLDme7zoyo/",
"shortcode": "DcLDme7zoyo",
"subcategory": "reel",
"tagged_users": [
{
"full_name": "\uc9c4\uc194 \ud835\udc09\ud835\udc08\ud835\udc0d\ud835\udc12\ud835\udc0e\ud835\udc14\ud835\udc0b",
"id": "55465048543",
"username": "zindoriyam"
},
{
"full_name": "\uae40\ub9bd KIMLIP",
"id": "57506288936",
"username": "kimxxlip"
},
{
"full_name": "\ucd5c\ub9ac \ucd5c\uc608\ub9bc Choerry",
"id": "57668363710",
"username": "cher_ryppo"
},
{
"full_name": "HEEJIN",
"id": "57723176039",
"username": "0ct0ber19"
},
{
"full_name": "Official ARTMS",
"id": "58524253183",
"username": "official_artms"
}
],
"tags": [
"#ARTMS",
"#BlueBloodNight",
"#\uc544\ub974\ud14c\ubbf8\uc2a4",
"#\uc81c\uc791\uc9c0\uc6d0"
],
"type": "reel",
"username": "dailyfashion_news",
"video_url": "https://instagram.fyto3-1.fna.fbcdn.net/o1/v/t2/f2/m86/AQNbHAasCyJwuQWdYi7vWw3w6iCiMdBAXZQ58cnOKhanFZgw_IAhVC_f380GVo23sNeeCD559rVhmUSr0bmruolOjAgXMVwrmoRu88A.mp4?_nc_cat=105&_nc_oc=AdomTMuitkVGoe5U3qTaa9WVmrQNaUmC-hpxQMgtcKNDrT2Htj3cu6iPiBDfh9M0Hyk&_nc_sid=5e9851&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_ohc=O3edipmWdLQQ7kNvwFP9r4s&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5JTlNUQUdSQU0uQ0xJUFMuQzMuNzIwLmRhc2hfYmFzZWxpbmVfMV92MSIsInhwdl9hc3NldF9pZCI6MTgxMTkyNTgxMzEyMzA0OSwiYXNzZXRfYWdlX2RheXMiOjE0LCJ2aV91c2VjYXNlX2lkIjoxMDA5OSwiZHVyYXRpb25fcyI6NTQsInVybGdlbl9zb3VyY2UiOiJ3d3cifQ%3D%3D&ccb=17-1&vs=69de96aad46e550e&_nc_vs=HBksFQIYUmlnX3hwdl9yZWVsc19wZXJtYW5lbnRfc3JfcHJvZC83RTRFMjc3MDhFMjVCMzUxNjA5MURDQUM2QTQ3MTlBQV92aWRlb19kYXNoaW5pdC5tcDQVAALIARIAFQIYUWlnX3hwdl9wbGFjZW1lbnRfcGVybWFuZW50X3YyL0I3NDdFMUM4NTRCODk2NDRENDM2NTIwQzVBODBCOTk5X2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACbS35qn-_u3BhUCKAJDMywXQEtRBiTdLxsYEmRhc2hfYmFzZWxpbmVfMV92MREAdf4HZeadAQA&_nc_gid=-Kd8mdFfght8nFvwkJmRPA&_nc_zt=28&_nc_ss=7a22e&oh=00_AQLoXUFyQixJHvIcp3UE-6sEsjKuOlmhiPWmQphL7U7o5g&oe=6A996A42",
"width": 1080,
"width_original": 1080
}
]
]
+181
View File
@@ -0,0 +1,181 @@
[
[
2,
{
"audio_artist": null,
"audio_duration": 15.0,
"audio_timestamps": null,
"audio_title": "Original audio",
"audio_user": {
"full_name": "\uc2e0\ubaa8\ucc0c \ud835\udde0\ud835\uddfc\ud835\uddf0\ud835\uddf5\ud835\uddf6 \ud83c\udf58",
"id": "61396137942",
"is_private": false,
"is_verified": false,
"pk": "61396137942",
"pk_id": "61396137942",
"profile_pic_id": "3506506412954824214_61396137942",
"profile_pic_url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.2885-19/468037362_1017459960091671_5806837525855946557_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby42MDAuYzIifQ&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=103&_nc_oc=Q6cZ2gEBkafhMKkLe-YbSz6FVQiMLEw4juIKWvM8PNmZ2nTcMpNyjdl0kV_-fWna0tytdLA&_nc_ohc=sPpx_bESe4cQ7kNvwFvI70J&_nc_gid=RGZW24yVvqRHKLuQvnRkKQ&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQIS_52Qhu7n6Fo7XNtQaIKXGEhkwhgz-C9vpWo4yb6dnw&oe=6A9D5107&_nc_sid=fc8dfb",
"strong_id__": "61396137942",
"username": "mochi.053"
},
"category": "instagram",
"coauthors": [
{
"full_name": "HEEJIN",
"id": "57723176039",
"username": "0ct0ber19"
}
],
"count": 2,
"date": "2026-08-22 02:48:05",
"description": "\u3060\u3044\u3058\u3087\u270c\ufe0f\u3067\u3057\u3087",
"fullname": "\uce04",
"liked": true,
"likes": 178797,
"owner_id": "54301371254",
"pinned": [],
"post_date": "2026-08-22 02:48:05",
"post_id": "3969041070976751617",
"post_shortcode": "DcU3dM-gVgB",
"post_url": "https://www.instagram.com/p/DcU3dM-gVgB/",
"subcategory": "post",
"type": "post",
"username": "chuuo3o"
}
],
[
3,
"ytdl:https://www.instagram.com/p/DcU3dM-gVgB/1.mp4",
{
"audio_artist": null,
"audio_duration": 15.0,
"audio_timestamps": null,
"audio_title": "Original audio",
"audio_user": {
"full_name": "\uc2e0\ubaa8\ucc0c \ud835\udde0\ud835\uddfc\ud835\uddf0\ud835\uddf5\ud835\uddf6 \ud83c\udf58",
"id": "61396137942",
"is_private": false,
"is_verified": false,
"pk": "61396137942",
"pk_id": "61396137942",
"profile_pic_id": "3506506412954824214_61396137942",
"profile_pic_url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.2885-19/468037362_1017459960091671_5806837525855946557_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby42MDAuYzIifQ&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=103&_nc_oc=Q6cZ2gEBkafhMKkLe-YbSz6FVQiMLEw4juIKWvM8PNmZ2nTcMpNyjdl0kV_-fWna0tytdLA&_nc_ohc=sPpx_bESe4cQ7kNvwFvI70J&_nc_gid=RGZW24yVvqRHKLuQvnRkKQ&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQIS_52Qhu7n6Fo7XNtQaIKXGEhkwhgz-C9vpWo4yb6dnw&oe=6A9D5107&_nc_sid=fc8dfb",
"strong_id__": "61396137942",
"username": "mochi.053"
},
"category": "instagram",
"coauthors": [
{
"full_name": "HEEJIN",
"id": "57723176039",
"username": "0ct0ber19"
}
],
"count": 2,
"date": "2026-08-22 02:48:05",
"description": "\u3060\u3044\u3058\u3087\u270c\ufe0f\u3067\u3057\u3087",
"display_url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.82787-15/783856831_18043412798811255_1724949997749570795_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=108&ig_cache_key=Mzk2OTA0MTA3MDk3Njc1MTYxNzE4MDQzNDEyNzkyODExMjU1.3-ccb7-5&ccb=7-5&_nc_sid=58cdad&efg=eyJ2ZW5jb2RlX3RhZyI6IkNMSVBTLnhwaWRzLjcyMC5zZHIudmlkZW9fZGVmYXVsdF9jb3Zlcl9mcmFtZS5DMyJ9&_nc_ohc=JWzEND3gUxAQ7kNvwGDGCLf&_nc_oc=AdpwpFkh8Wj84hyF4zWcsaNB5zUnYJh2bJijNe9AxrOR1WKa08Uy35khb5BpFnG9XhM&_nc_ad=z-m&_nc_cid=0&_nc_zt=23&_nc_ht=instagram.fyto3-1.fna&_nc_gid=RGZW24yVvqRHKLuQvnRkKQ&_nc_ss=7a22e&oh=00_AQLQlu6EItD2G6QrlOmQSas2IHDOpucLIhxVWcCPFE1BOw&oe=6A9D4B4F",
"extension": "mp4",
"filename": "AQN8nhc_L3hdDSQVVRF2OXm2gKwDpsdh_jw81ZnA9Uh4nqOwukKke8QXrGCe1jrMtOXzPT5Bkuipu8RShT651VV7q6EVbjvxC-fxgUE",
"fullname": "\uce04",
"height": 1920,
"height_original": 1920,
"liked": true,
"likes": 178797,
"media_id": "3969041070976751617",
"num": 1,
"owner": {
"account_badges": [],
"account_type": 3,
"can_see_quiet_post_attribution": true,
"eligible_for_text_app_activation_badge": false,
"fan_club_info": {
"autosave_to_exclusive_highlight": null,
"connected_member_count": null,
"fan_club_id": null,
"fan_club_name": null,
"fan_consideration_page_revamp_eligiblity": null,
"has_created_ssc": null,
"has_enough_subscribers_for_ssc": null,
"is_fan_club_gifting_eligible": null,
"is_fan_club_referral_eligible": null,
"is_free_trial_eligible": null,
"largest_public_bc_id": null,
"should_show_playlists_in_profile_tab": null,
"subscriber_count": null
},
"fbid_v2": "17841454337627671",
"feed_post_reshare_disabled": false,
"friendship_status": {
"followed_by": false,
"following": true,
"is_bestie": false,
"is_feed_favorite": false,
"is_muting_reel": false,
"is_private": false,
"is_restricted": false
},
"full_name": "\uce04",
"has_anonymous_profile_picture": false,
"hd_profile_pic_url_info": {
"height": 1080,
"url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.82787-19/714930835_18032224961811255_6816644959905468206_n.jpg?efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2gEBkafhMKkLe-YbSz6FVQiMLEw4juIKWvM8PNmZ2nTcMpNyjdl0kV_-fWna0tytdLA&_nc_ohc=hpoiFipmAqsQ7kNvwE3y5Ow&_nc_gid=RGZW24yVvqRHKLuQvnRkKQ&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQJiPho8ymPf1bC2B5dAlKCbdXW8d4KK-9qqsVrcOytVGQ&oe=6A9D4CB4&_nc_sid=fc8dfb",
"width": 1080
},
"hd_profile_pic_versions": [
{
"height": 320,
"url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.82787-19/714930835_18032224961811255_6816644959905468206_n.jpg?stp=dst-jpg_s320x320_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2gEBkafhMKkLe-YbSz6FVQiMLEw4juIKWvM8PNmZ2nTcMpNyjdl0kV_-fWna0tytdLA&_nc_ohc=hpoiFipmAqsQ7kNvwE3y5Ow&_nc_gid=RGZW24yVvqRHKLuQvnRkKQ&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQIn43F6SprbhopBuJxmswE2wd4C39mDZoL9Fbqs8Ih-cw&oe=6A9D4CB4&_nc_sid=fc8dfb",
"width": 320
},
{
"height": 640,
"url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.82787-19/714930835_18032224961811255_6816644959905468206_n.jpg?stp=dst-jpg_s640x640_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2gEBkafhMKkLe-YbSz6FVQiMLEw4juIKWvM8PNmZ2nTcMpNyjdl0kV_-fWna0tytdLA&_nc_ohc=hpoiFipmAqsQ7kNvwE3y5Ow&_nc_gid=RGZW24yVvqRHKLuQvnRkKQ&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQJF1vYqhGCDkV-v6WbrTb_PoVC4GA3QCbEGJU0y0F96BA&oe=6A9D4CB4&_nc_sid=fc8dfb",
"width": 640
}
],
"id": "54301371254",
"is_active_on_text_post_app": false,
"is_embeds_disabled": false,
"is_favorite": false,
"is_private": false,
"is_ring_creator": false,
"is_unpublished": false,
"is_verified": true,
"latest_reel_media": 0,
"pk": "54301371254",
"pk_id": "54301371254",
"profile_pic_id": "3912796961274695044_54301371254",
"profile_pic_url": "https://instagram.fyto3-1.fna.fbcdn.net/v/t51.82787-19/714930835_18032224961811255_6816644959905468206_n.jpg?stp=dst-jpg_s150x150_tt6&efg=eyJ2ZW5jb2RlX3RhZyI6InByb2ZpbGVfcGljLmRqYW5nby4xMDgwLmMyIn0&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_cat=1&_nc_oc=Q6cZ2gEBkafhMKkLe-YbSz6FVQiMLEw4juIKWvM8PNmZ2nTcMpNyjdl0kV_-fWna0tytdLA&_nc_ohc=hpoiFipmAqsQ7kNvwE3y5Ow&_nc_gid=RGZW24yVvqRHKLuQvnRkKQ&edm=ALQROFkBAAAA&ccb=7-5&oh=00_AQJu85hrHiCGK3enXnfDQExy_ZbgzCV_AGYJ4yOy7mi_QQ&oe=6A9D4CB4&_nc_sid=fc8dfb",
"show_account_transparency_details": true,
"show_ring_award": false,
"strong_id__": "54301371254",
"text_post_app_is_private": true,
"third_party_downloads_enabled": 1,
"transparency_product_enabled": false,
"user_activation_info": {},
"username": "chuuo3o"
},
"owner_id": "54301371254",
"pinned": [],
"post_date": "2026-08-22 02:48:05",
"post_id": "3969041070976751617",
"post_shortcode": "DcU3dM-gVgB",
"post_url": "https://www.instagram.com/p/DcU3dM-gVgB/",
"shortcode": "DcU3dM-gVgB",
"subcategory": "post",
"tagged_users": [
{
"full_name": "HEEJIN",
"id": "57723176039",
"username": "0ct0ber19"
}
],
"type": "post",
"username": "chuuo3o",
"video_url": "https://instagram.fyto3-1.fna.fbcdn.net/o1/v/t2/f2/m86/AQN8nhc_L3hdDSQVVRF2OXm2gKwDpsdh_jw81ZnA9Uh4nqOwukKke8QXrGCe1jrMtOXzPT5Bkuipu8RShT651VV7q6EVbjvxC-fxgUE.mp4?_nc_cat=102&_nc_oc=AdqR865Bo8jW90mx5U9WR94Q0Eh1YI3ycT_ZhhW71XQNFHxUR3aBwhQEgcQ9Z2Hnrzs&_nc_sid=5e9851&_nc_ht=instagram.fyto3-1.fna.fbcdn.net&_nc_ohc=2F0lG8gquXwQ7kNvwGIIkUT&efg=eyJ2ZW5jb2RlX3RhZyI6Inhwdl9wcm9ncmVzc2l2ZS5JTlNUQUdSQU0uQ0xJUFMuQzMuNzIwLmRhc2hfYmFzZWxpbmVfMV92MSIsInhwdl9hc3NldF9pZCI6MTYxMDAzNjE0Mzk3NzkxMywiYXNzZXRfYWdlX2RheXMiOjExLCJ2aV91c2VjYXNlX2lkIjoxMDA5OSwiZHVyYXRpb25fcyI6MTUsInVybGdlbl9zb3VyY2UiOiJ3d3cifQ%3D%3D&ccb=17-1&vs=93986893873e15f7&_nc_vs=HBksFQIYUmlnX3hwdl9yZWVsc19wZXJtYW5lbnRfc3JfcHJvZC80ODQ5NDdDRjM0NzcyNkI2RkE4RDZDRkFGQjQzQTlCQl92aWRlb19kYXNoaW5pdC5tcDQVAALIARIAFQIYUWlnX3hwdl9wbGFjZW1lbnRfcGVybWFuZW50X3YyLzkwNDc3OEIwRTY2MDYxNEY5RjA4MUMxQzNDMDVBMkFDX2F1ZGlvX2Rhc2hpbml0Lm1wNBUCAsgBEgAoABgAGwKIB3VzZV9vaWwBMRJwcm9ncmVzc2l2ZV9yZWNpcGUBMRUAACby1v-GuJTcBRUCKAJDMywXQC6qfvnbItEYEmRhc2hfYmFzZWxpbmVfMV92MREAdf4HZeadAQA&_nc_gid=RGZW24yVvqRHKLuQvnRkKQ&_nc_zt=28&_nc_ss=7a22e&oh=00_AQIrUM_ZU8b3LTxlgefSs0x6vjvCX9BV4piQfWrs74dXIg&oe=6A99835F",
"width": 1080,
"width_original": 1080
}
]
]
+732
View File
@@ -0,0 +1,732 @@
# gallery-dl — a CLI replacement for JDownloader2
Status: **in production.** All six ARTMS profiles are synced with
`scripts/gdl-sync.py`; JD2 is no longer used for them.
Everything below was measured against the live site and the real archive on
2026-08-16 and 2026-08-20, not inferred from documentation.
## Why gallery-dl and not a hand-rolled script
The hard parts of fetching Instagram are pagination, cookie handling, CDN URL
expiry and resumption. gallery-dl already has all of them, plus extractors that
map 1:1 onto our sidecar directory layout (`posts`, `reels`, `stories`,
`highlights`). Rolling our own would mean reimplementing the ban-sensitive part
by hand.
## The account was suspended on 2026-08-17 — read this first
> [!NOTE]
> **The scraping account was replaced on 2026-10-14.** It is now **`erg05teur`**,
> which is also signed in on the owner's phone (a separate session; the cookie
> gdl uses lives only in the dedicated Chrome on `mattellite`). Everything below
> about the suspension is the **previous** account's history and the reasons for
> every rule; none of it has stopped applying, and the new account has no track
> record, so treat it as a replacement account (see "For a replacement account"
> below). The new session cookie was created 2026-10-14 ~10:07 EDT.
>
> What this account has been asked since then, honestly counted, because the
> warm-up advice below was not followed to the letter: ~13 single-post / format
> probes (resolution check, 2026-10-14), plus ~80 listing requests from an
> **unplanned** reels catch-up run (installing the weekly profiles timer
> triggered it via `Persistent=true`; stopped partway, `heejin / reels`
> completed, `kimxxlip / reels` was abandoned ~7 min in). Scheduled runs were
> deliberately left on their normal timers by the owner.
The account used for all of the below was suspended the same day this tooling
was built, for "activity that doesn't follow our Community Standards on spam".
The fetching was not the expensive part. **Verification was.**
**It was restored, and synced normally again on 2026-08-20** — a full run
across all six profiles with 0 failures and 0 CDN 429s. That is not evidence
the limits were imagined; it is one data point on a restored account that has
been treated carefully since. Everything below still applies, and the budget is
still per session rather than per command.
What was actually spent against `instagram.com` in a few hours, from one
session and one IP:
| activity | rough requests | downloaded |
|---|---:|---|
| enumerating a profile grid by scrolling it in an automated browser | ~18 pages | nothing |
| the same profile again, after a bug in the scraping selector | ~18 pages | nothing |
| a Reels tab enumerated the same way | ~9 pages | nothing |
| full `-j` metadata dumps of one profile, twice | ~16 pages | nothing |
| `--simulate` runs over the same profile, three times | ~24 pages | nothing |
| single-post `/p/<code>/` fetches while testing filename formats | ~8 | a handful |
| an aborted sync that re-ran every listing pass before dying | ~40 pages | ~270 MB |
| the real sync, 24 sources across 6 profiles | ~150 pages | 2.2 GB |
The two rows that actually mattered to the archive are the last one and part of
the second-to-last. **Everything above them produced no files at all**, and
together they were a comparable number of requests.
The warnings arrived in this order and were each rationalised:
1. `429 Too Many Requests` from `scontent-*.cdninstagram.com`, losing two
videos. Treated as a pacing problem — pacing was lowered and the run
continued.
2. `400 Bad Request` from `/api/v1/highlights/<id>/highlights_tray/`, on an
endpoint that had worked hours earlier. Correctly read as a possible block;
requests stopped.
3. Suspension.
**Treat the first CDN 429 as a stop signal for the session, not a tuning
parameter.** It is the tolerant surface complaining; if that surface is
complaining, the rate-limited one has been unhappy for a while.
### Rules that follow from this
- **Count verification requests against the same budget as fetching.** A
`--simulate`, a `-j` dump and a browser scroll all hit `instagram.com` and
download nothing. Being read-only does not make them free; it makes them
invisible, which is worse.
- **Never enumerate the live site with an automated browser.** Scrolling a
214-post grid is ~18 paginated GraphQL loads at machine speed with no dwell
time between them. It is the most obviously non-human thing in this whole
document, and it was done here twice on one profile.
- **Verify against the archive, not against Instagram.** Every naming, dating
and classification question answered in this file could have been answered
from files already on disk plus a single listing pass.
- **`probe_live` is not cached, so every restart re-enumerates everything.**
The aborted run cost a full duplicate set of listing passes for five
profiles. Cache probe output to disk before running anything twice.
- **Budget per session, not per command.** Nothing in the tooling knows what
the last command spent.
### For a replacement account
(Applies to `erg05teur`, 2026-10-14 onward — see the note at the top of this
section for what it has already been asked.)
- Let it exist and be used normally for a while before pointing any tool at it.
- Keep the cookie on one machine and one public IP, as before.
- Start with a single small profile and stop for the day afterwards.
- Prefer Instagram's own "Download a copy" export where possible: it is
first-party, costs no scraping requests, and carries the metadata this whole
document works around not having.
## The safety model — read this before changing any option
The ban vector is **requests to `instagram.com`**, not bandwidth. See
`docs/jdownloader.md` for the history; Instaloader got this account banned by
asking `instagram.com` a question *per post*.
gallery-dl has two API backends and the difference is exactly that vector:
```python
if self.config("api") == "graphql":
self.api = InstagramGraphqlAPI(self) # per-post api.media() for every
else: # video and every carousel
self.api = InstagramRestAPI(self) # <- default, listing-only
```
The REST backend paginates at `count: 30` (feed) / `page_size: 50` (clips), and
those responses already carry `carousel_media`, `image_versions2`,
`video_versions` and `product_type`. **No per-post request.** A 300-post
profile costs roughly 10 requests to `instagram.com`.
Rules, in order of importance:
1. **`"api": "rest"` always.** Never `graphql`. This is the whole ballgame.
2. **Never enable `metadata`-style options that trigger extra calls.** If a
field is not already in the listing response, it is not worth a request.
3. **Pace it.** `"sleep-request": [4.0, 7.0]` — a randomised gap, not a fixed
one. Also `"sleep": [1.0, 3.0]` between downloads.
4. **Cap the download rate** (`downloader.http.rate`) so the CDN side looks like
a person, not a mirror.
5. **Run from the same public IP as the browser the cookie came from.** At time
of writing that is `mattellite` (`66.23.52.196`); the dev workstation is a
*different* public IP and using the cookie from there is precisely what
session-hijack detection looks for.
6. **No programmatic login, ever.** gallery-dl's username/password path is
disabled upstream anyway; use `--cookies-from-browser`.
Do not add proxy rotation, fingerprint spoofing or account rotation. Throttling
and request-avoidance are welcome; evasion is not.
### Cookies
The logged-in Chrome on `mattellite` runs with a non-default profile:
```
--user-data-dir=/home/matt/.config/google-chrome-devtools
```
so the cookie flag is:
```
--cookies-from-browser "chrome:/home/matt/.config/google-chrome-devtools"
```
Plain `--cookies-from-browser chrome` fails with "Unable to find chrome cookies
database" because it looks in `~/.config/google-chrome/`.
Anonymous access is **not** a viable fallback: it serves lower-resolution media,
caps profile pagination at 12 posts, and returns `AuthRequired` for stories and
highlights.
(Observed 2026-10-07, after the session died: with no `sessionid` gallery-dl did
not fall back to anonymous downloads at all — most sources failed with
`HTTP redirect to login page` / `HTTP redirect to home page`, and stories came
back as "No results". The lower-resolution claim above was **not** re-measured
then; what *was* measured is that archived image sizes match a logged-in fetch.)
**The session can die silently, and nothing about the browser window shows it** —
an open Instagram tab keeps rendering its logged-in UI until its next failed
request. The only reliable signal is the cookie DB: `scripts/check-session.py`
looks for an unexpired `sessionid` in this same profile, and `gdl-cron.sh` runs
it before every scheduled run (exit 76 = refuse to scrape). See `TOOLING.md`,
"2026-10-14".
## Output format
The viewer's parser is the contract, not JD2's exact bytes. `EXPORT_RE` in
`src/lib/archive-patterns.ts` accepts all of these, and normalises the index
with `parseInt`, so **JD2 and gallery-dl naming interoperate**:
```
"… - CrORBIcJJbM.mp4" -> postId=CrORBIcJJbM index=1
"… - CrORBIcJJbM - 1.mp4" -> postId=CrORBIcJJbM index=1
"… - C53YPQzp7Wj - 09.jpg" -> postId=C53YPQzp7Wj index=9
```
That means zero-padding and the presence/absence of ` - N` on single-media posts
are cosmetic. Don't spend effort forcing them.
### Directory layout
| kind | directory | note |
|---|---|---|
| posts | `<user>` | |
| reels | `<user> - reels` | |
| stories | `story - <user>` | |
| highlights | `story highlights - <user> - <title>` | |
**Force the directory with `-D`; never use `{username}` for it.** A profile's
reels tab returns *collab reels owned by other accounts* — `/0ct0ber19/reels/`
served 6 reels owned by `official_artms` and 1 by `chuuo3o`. With
`{username}` those would scatter into `official_artms - reels/`. JD2 got this
right and the archive proves it: `chuuo3o` and `official_artms` filenames sit
inside `0ct0ber19 - reels/`.
So: **owner in the filename, crawl scope in the directory.**
### Filenames
```jsonc
"filename": {
"sidecar_shortcode and count >= 10":
"{date:Olocal/%Y-%m-%d}_{username} - {post_shortcode} - {num:02}.{extension}",
"sidecar_shortcode":
"{date:Olocal/%Y-%m-%d}_{username} - {post_shortcode} - {num}.{extension}",
"":
"{date:Olocal/%Y-%m-%d}_{username} - {post_shortcode}.{extension}"
}
```
`sidecar_shortcode` is set only when the post is a carousel, so it is the
carousel discriminator. Conditions are evaluated in order, first match wins
(`path.py:265`).
Stories and highlights use the per-item `{shortcode}`, not `{post_shortcode}`
(which is the *reel's* id, shared by every item in it):
```
"{date:Olocal/%Y-%m-%d}_{username} - {shortcode}.{extension}"
```
`{date}` on a story/highlight file is the **per-item** `taken_at`
(`instagram.py:337` prefers `item["taken_at"]`), verified on a 154-item
highlight whose items carried distinct times while `post_date` stayed pinned to
the reel. Highlights therefore gain real dates — today they fall back to
directory mtime.
### The timezone is not UTC
JD2 stamped filenames in **desktop local time (US Eastern)**. Measured across
212 comparable posts:
| model | mismatches |
|---|---:|
| UTC | 19 |
| UTC−5 (EST) | 10 |
| UTC−4 (EDT) | **0** |
| America/New_York (DST-aware) | **0** |
`{date:Olocal/%Y-%m-%d}` uses the machine's local zone with per-timestamp DST
awareness, which reproduces it — `mattellite` is `America/Toronto`, the same
offsets. Note the **trailing `/` must be omitted**: `Olocal/%Y-%m-%d/` puts the
separator into the strftime format and it sanitises to an underscore, giving
`2026-08-15__0ct0ber19`.
If the sync ever moves to a host in another timezone, set an explicit
`{date:O-4/…}` or the dates will silently shift for ~9% of posts.
### Caption sidecars
JD2 writes one `.txt` per post, named without the index, containing the caption
with **no trailing newline**, and writes nothing when the caption is empty
(measured: 197 of 217 posts, 86 of 86 reels, 0 of 10 stories, 0 of 16
highlights). gallery-dl reproduces this exactly with the default
`"empty": false`:
```jsonc
{ "name": "metadata", "event": "post", "mode": "custom",
"content-format": "{description}", "extension": "txt",
"filename": "{date:Olocal/%Y-%m-%d}_{username} - {post_shortcode}.txt" }
```
`"event": "post"` is what makes it one file per post rather than per media file.
### Metadata sidecar (new — JD2 had no equivalent)
```jsonc
{ "name": "metadata", "event": "post", "mode": "json",
"filename": "{date:Olocal/%Y-%m-%d}_{username} - {post_shortcode}.json",
"include": ["post_shortcode","post_id","type","date","post_date","username",
"fullname","owner_id","description","count","likes","post_url",
"sidecar_shortcode"] }
```
Use **`include`**, not `fields` — `fields` is for `mode: custom` and silently
does nothing here, leaving `audio_user` blobs (including another user's profile
picture URL) in the output.
The payoff is `type`, which is Instagram's own classification:
```json
{ "post_shortcode": "DbdG9L9jU4m", "type": "post", "count": 2 } // feed video
{ "post_shortcode": "Db-lNCoib9m", "type": "reel", "count": 1 } // real reel
```
This is the `product_type: "clips"` signal, delivered free in the listing
response. It is the authoritative answer to "is this a reel", and would let the
viewer retire the lone-video heuristic in `src/lib/post-tabs.ts` — see
"Scanner work" below.
**`type` is only populated by listing extractors.** Extracting a single
`/p/<shortcode>/` URL leaves it `null`. Sync always uses listing URLs, so this
only matters when testing by hand.
### Collab posts: the JSON and the media disagree about whose post it is
Verified directly against two saved raw API responses (`docs/example-api-
response-DcU3dM-gVgB.json`, a 2-way collab, and `docs/example-api-response-
DcLDme7zoyo.json`, a 5-way collab owned by an external account), 2026-09-01.
For an Instagram Collab, `username`/`fullname`/`owner_id` in the listing
response are always the **original poster's**, never the scraped account's —
even when the scraped account is one of the collaborators, not the owner. The
metadata `.json` sidecar (built from `include`, above) is filed under that
same original-poster identity, since its filename template uses
`{username}`. But the **media file** for that same post is written into
whichever profile's own crawl directory triggered the download, and its
filename's `{username}` slot took the *scraped* account's name, not the true
owner's. Net effect: one physical post produces a JSON named for the real
owner and a media file (or files, for a carousel) named for whoever we were
crawling — two different identities for one post, in the same directory.
Downstream (`cosmo_normalize_instagram.py` in `Cosmo-Live-Downloads`) had to
stop matching media to its JSON by username and match on shortcode/`{num}`
only, and separately built a cross-profile `collab_with` pass so every
participant's page shows the post, not just the one whose directory JD2/
gallery-dl happened to land the JSON in. See that repo's `NOTES.md`,
2026-09-01 entries, for the full fix.
**`coauthors` is a native, richer signal for this, and is now captured.**
The raw API response carries a `coauthors` array (`{"full_name", "id",
"username"}` per collaborator) that **excludes the post's own owner** —
confirmed on both saved examples, including one where the owner
(`dailyfashion_news`) is an external account with no ARTMS members in her
own name, present only via `coauthors`. Added to the post-level metadata
JSON's `include` list on 2026-09-01: a direct field instead of inferring a
collab from filename/directory identity mismatches, and it never needs the
"is this the owner" branch `coauthors` already excludes for us.
**Per-carousel-item fields got their own sidecar, added the same day.**
`width`, `height`, `width_original`, `height_original` and `tagged_users`
live on the per-FILE kwdict, not the per-post one the metadata JSON above
reads — a carousel's items can each have different dimensions and tags,
which one post-level JSON can't represent. `gdl-sync.py`'s `media_pp` is a
second `metadata` postprocessor, `event: "file"` (gallery-dl's default when
omitted), so it runs once per downloaded file and writes `<filename>.json`
alongside it — e.g. `... - 01.jpg.json` next to `... - 01.jpg`, never
colliding with the post-level `....json`, which has no per-item number.
`owner` — a full user object (profile pic URLs, privacy flags) for whoever
posted that specific item — is deliberately left out, the same reasoning as
`audio_user` above. Verified against the same two saved examples: correct
per-item `width`/`height` and `tagged_users` came back on a live re-fetch of
an already-archived carousel, with zero media re-downloaded (the existing
skip-archive still applies; only the new sidecars are new files).
### The shared archive-db dedups media across profiles too, not just within one
The skip-archive DB (see "Incremental sync" above) keys purely on
`instagram_<media_id>`, with no per-profile scoping. When the *same* media_id
is reachable from more than one profile's listing — a Collab post, or a
repost — whichever profile's crawl reaches it **first** downloads the file;
every other profile that later lists the same media_id sees it as
already-in-the-archive-db and skips the download, even though that file has
never actually landed in *that profile's own* directory tree. The metadata
`.json`/`.txt` sidecars are written regardless (they aren't gated by the
download-archive), so the symptom is a JSON with zero matching media files
in its own directory — 510 such posts were found across the real archive on
2026-09-01, entirely from this mechanism, not from anything actually missing
from Instagram.
This isn't fixable on the gallery-dl side without per-profile archive DBs
(which would defeat the point of skip-archive — re-downloading anything a
sibling profile already fetched). The fix lives downstream instead: `cosmo_
normalize_instagram.py` builds one archive-wide `shortcode → path` index once
and falls back to it when a post's own directory has no matching media. Worth
knowing before assuming a JSON-with-no-media post reflects a real scrape gap.
## Cadence, and the budget that enforces it
**Monthly for everything, daily for stories only.** Stories expire in 24h and
cannot be backfilled, so they are the one surface where missing a day means
losing the content permanently. Everything else can wait — the skip-archive
means an infrequent full sync costs barely more than a frequent one, because it
only fetches what is new.
```
# weekly, everything
gdl-sync.py --index <viewer-url> --staging ~/gdl/staging \
--publish <user>@<nas>:<archives> --archive-db ~/gdl/artms.db \
--urls-file artms_account_links.txt --execute
# daily, stories only -- one request per profile
gdl-sync.py ... --only stories --execute
```
A stories-only run is one source per profile and **never seeds**, because a
story cannot be in the archive before it is fetched; probing would double the
cost of the cheapest surface for no benefit. Six profiles is a handful of
requests.
When scheduling it, **randomise the minute and avoid the hour boundary**. A job
that fires at exactly 09:00 every day is a machine; one that fires somewhere in
a window looks like someone opening the app.
The tool now refuses to repeat itself:
| flag | default | what it prevents |
|---|---|---|
| `--min-interval` | 20h | re-fetching a source touched recently — the aborted-restart case that re-enumerated five profiles |
| `--probe-ttl` | 24h | paying for a listing pass twice within a run cycle |
| `--max-sources` | off | a runaway list touching more than intended |
| `--force` | off | (escape hatch: ignores both guards) |
State lives beside the archive DB as `<db>.state.json`, recording per source
when it was seeded and last fetched. **Seeding is a one-time bootstrap**: after
the first successful sync the archive DB records everything gallery-dl has
seen, so the source is never probed again. That is the single biggest saving
here — a second full sync costs roughly half what the first did.
## Incremental sync — why the fetch host needs no copy of the archive
gallery-dl can skip already-held media two ways, and the difference decides
whether the fetcher needs the archive mounted:
- **By file existence** (default). Needs the destination to already contain the
files, so it only works if the archive is mounted where gallery-dl writes.
- **By skip-archive** (`--download-archive`). A sqlite DB of ids. Needs nothing
on disk.
We use the second, so the fetch host can write to **local disk and rsync
afterwards**. That avoids writing tens of thousands of small files over CIFS,
and keeps a mid-sync failure from leaving partial files on the live Resilio
share.
The key is `archive_prefix + archive_fmt`, which for this extractor is the
literal `instagram` plus the per-media numeric pk (`instagram.py:25`,
`job.py:713-719`). Verified: a 3-image carousel produced
```
instagram3079387627521318672
instagram3079387627521429433
instagram3079387627529716672
```
and a second run skipped every media file, rewriting only the idempotent
`.txt`/`.json` sidecars.
**Seeding.** `media_id` is not in our filenames, so the DB cannot be built from
names alone — but one listing pass (the pass we make anyway) maps every live
item to its `media_id`, and the archive's *file listing* says which we already
hold. No extra Instagram requests, and no archive content — a listing is
enough, which `GET /api/archives/:name/files` already serves.
Measured on `0ct0ber19`: 2275 live media items, 2248 seeded from the existing
listing, **27 left to download** — precisely the media of the two posts added
since the last crawl.
The one trap, which silently seeds almost nothing if you get it backwards:
| surface | filed under | why |
|---|---|---|
| posts, reels | `post_shortcode` | carousel children each have their own `shortcode`, which never appears in a filename |
| stories, highlights | `shortcode` (per item) | `post_shortcode` is the containing reel's id, shared by every item |
`live_key()` encodes this. Matching on the wrong field seeded 5 of 2275.
### The skip-archive saves the CDN, not `instagram.com`
Worth being exact about, because the two costs land on different surfaces and
only one of them bans accounts:
| what | which surface | scales with |
|---|---|---|
| downloading media | `scontent-*.cdninstagram.com` | how much is **new** |
| enumerating the profile to find it | `instagram.com` | how **big** the profile is |
The skip-archive suppresses the first. It does nothing about the second, so a
2275-post profile costs ~76 pages of pagination every run, forever, whether it
has three new posts or none. Seeding (above) saved a *second* full pass, not
the first.
Measured on the 2026-08-20 run, from sidecar write times in staging — free,
since the run was paying for the listing anyway:
```
1787248852 2026-08-19 … DcOeoVxkthi new, +0s
1787248944 2026-08-18 … DcLpfoJCZtp new, +92s
1787249058 2026-08-17 … DcIlGbxCUk0 new, +114s
1787249162 2026-07-24 … DbKr1TxlPSX ┐ all one second: nothing
1787249162 2026-08-15 … DcD-FdBCYGm ┘ downloaded, sidecars only
```
Three posts took ~100s each; the remaining 2272 were enumeration with nothing
to show for it.
**Pinned posts do not break early abort.** Test case 16 previously claimed
`0ct0ber19` returns its 3 pinned posts out of date order — that is true of the
*web grid*, but the REST `/posts/` listing came back strictly
reverse-chronological, newest first, no hoisting. That matters because
front-loaded old posts are the one thing that would make `skip: abort:N`
dangerous: it would trip on them and abort before reaching anything new.
So `skip: abort:N` is viable, and cuts ~420 requests per run to ~40-60:
| surface | live items | pages | with `abort:50` |
|---|---:|---:|---:|
| posts, 6 profiles | 11,248 | ~377 | ~12 |
| reels, 6 profiles | 1,080 | ~24 | ~8 |
| stories + highlights | — | ~20 | ~20 |
N counts consecutive skipped **files**, not posts, so it must clear the largest
already-held carousel — `DcD-FdBCYGm` alone is 22 media. 50 is comfortable; 5
would not be.
**The tradeoff is edited carousels.** Test case 15 is a post that gained items
after we archived it, and only a full enumeration finds those. Suggested
policy: `abort:50` for routine runs, a full sweep occasionally.
Measured the same day, resuming a stopped run with `--abort 50`:
| source | live items | enumerated |
|---|---:|---:|
| `cher_ryppo` posts | 2,151 | **7** |
| `cher_ryppo` reels | 92 | 53 |
One page instead of 72, and every new post was still caught. The 7 is roughly
3 new posts plus 4 already-held carousels making up the 50 skipped files.
Reels need 53 because they are single-media, so 50 consecutive skips really is
50 reels — another reminder that N counts files, and that the same N behaves
very differently on a carousel-heavy surface than on a reels tab.
## Publishing
The fetch host stages to local disk and rsyncs afterwards. `rsync
--ignore-existing` is not an optimisation but the safety property: the archive
deliberately outlives Instagram, so publishing must only ever **add**. No
`--delete`, and nothing already present is overwritten — including sidecars,
which are rewritten every run and would otherwise churn the synced share.
Publishing happens once at the end of a run, so a profile that fails midway
never reaches the archive half-written.
## Status
In use for all six ARTMS profiles.
`withaseul` first — 322 files added (74 media, 241 `.json`, 7 `.txt`), nothing
overwritten or deleted. Of the 74 new media, **zero** duplicated media already
held under a different name, which is the check that says JD2 and gallery-dl
naming really do converge.
**2026-08-20**, the first full incremental sync, four days after the previous
one. 184 new media, 299 files published, 0 failures and **0 CDN 429s**:
| profile | posts | reels | stories | files added |
|---|---:|---:|---:|---:|
| 0ct0ber19 | 58 | 2 | 4 | +77 |
| official_artms | 12 | — | 2 | +85 |
| cher_ryppo | 41 | 1 | 8 | +63 |
| zindoriyam | 23 | — | 4 | +35 |
| kimxxlip | 16 | — | 2 | +23 |
| withaseul | 10 | — | — | +16 |
The 20 story items are the part that could not have been recovered later.
Two things made it cheap, and both are worth keeping:
- The archive DB was already seeded from the previous run, so `--min-interval`
and the recorded `seeded` state meant **no probe passes at all**. A state
file has to exist for this; if one is missing after a manual run, write it
rather than letting the tool re-seed 24 sources.
- `--abort 50` (see above) cut the remaining listing cost by roughly 85%.
The run was deliberately **stopped and resumed** halfway to pick up `--abort`.
That is safe precisely because of the state file: the 12 finished sources were
already marked `fetched`, so the 20h floor skipped them and only the remaining
12 re-ran. Stopping a run is cheap now; it was not before.
Published files land owned by the SSH user rather than `rslsync`. The viewer
reads them fine (world-readable), but Resilio does not own what it syncs; worth
a `chown` if that ever matters. This also makes **`rsync` exit 23**
("some files/attrs were not transferred") the *normal* outcome of a publish —
it is the failed `chown`, not lost data. Confirm by re-running the same rsync
with `--dry-run`: an empty file list means everything arrived.
The profiles to fetch live in `artms_account_links.txt` at the archive root,
passed with `--urls-file`.
## Verified run
`withaseul`, all four surfaces, staged locally and published to a scratch
directory before the live publish above:
```
==> withaseul / posts seeded 915 of 984 live items
==> withaseul / reels seeded 28 of 34 live items
==> withaseul / stories no results (none active)
==> withaseul / highlights no results
```
Output landed correctly, including the collab-reel case — `withaseul - reels`
contains 53 files owned by `withaseul`, 10 by `cher_ryppo`, 3 by `0ct0ber19`
and 2 by `official_artms`, all with the owner in the filename and the crawl
scope as the directory.
### The CDN rate-limits, and the first run tripped it
At `rate: 3M` with `sleep: [1.0, 3.0]`, `scontent-*.cdninstagram.com` returned
**`429 Too Many Requests`** and two videos were lost (gallery-dl retried, then
gave up with exit 4). This is the *tolerant* surface complaining, which is a
clear signal the pacing was too aggressive.
Defaults are now:
| option | value |
|---|---|
| `--rate` | `1M` |
| `--sleep-request` | 6–10 s |
| `--sleep` | 3–6 s |
| `sleep-429` | 120 s |
| `retries` (extractor and downloader) | 8 |
Re-running with those recovered both videos and produced **0 failures and 0
429s**. Do not raise them for speed; an archive sync has no deadline.
### yt-dlp is worth installing
Without it, gallery-dl logs `Cannot import yt-dlp or youtube-dl` and falls back
to a progressive URL for DASH videos. The fallback mostly works but is what the
429s hit hardest.
**`pipx install yt-dlp` does not work** — it was the advice here until
2026-08-20, and it is wrong. It gives yt-dlp its own venv, so the binary lands
on `PATH` while gallery-dl, in a *different* venv, still cannot `import yt_dlp`.
The symptom is that everything looks installed and the log keeps saying
`Cannot import yt-dlp`. gallery-dl needs it importable, not runnable:
```sh
pipx inject gallery-dl yt-dlp
```
Verify by asking gallery-dl's own interpreter, not the shell:
```sh
/home/matt/.local/share/pipx/venvs/gallery-dl/bin/python -c 'import yt_dlp'
```
## Known quirks
- **`count` is not the emitted file count.** For 135 of 214 posts it was exactly
one higher than the number of files written. This makes the `count >= 10`
padding condition mis-pad a handful of 9-item posts (10 of 214 measured). Since
the parser normalises the index, this is cosmetic — but it means a re-fetch
over an existing JD2 tree writes `- 01.jpg` beside an existing `- 1.jpg`.
- **Carousels get edited.** Two posts had a different media count live than on
disk. Padding width follows the count *at download time*, so a grown carousel
produces mixed widths — the archive already contains one such post from JD2.
- **Highlights already have two naming styles on disk**, and every undated file
has a dated twin. The scanner dedupes by index so they render once; it is
wasted disk, not a display bug.
- **Some `video_versions` entries are VP9, and format selection is
codec-blind.** The extractor picks `max(video_versions, key=lambda x:
(x["width"], x["height"], x["type"]))` — resolution only, no codec check
(`instagram.py`). Instagram appears to have started serving VP9-encoded
highest-resolution variants for some posts around when the new gdl-based
workflow started (2026-08); 87 such files were found archive-wide on
2026-09-01. VP9-in-MP4 plays fine everywhere gallery-dl was tested from
except **Safari/WebKit**, which wires VP9 decode only into its WebM
demuxer, never its MP4/ISOBMFF path — confirmed via WebKit bug trackers,
not guessed. Fetching a lower-resolution non-VP9 variant instead was
considered and rejected (quality loss); the fix is downstream, a one-time
`-c:v copy -c:a libopus` remux to `.webm` (`cosmo_remux_instagram_vp9.py`
in `Cosmo-Live-Downloads`) that keeps VP9 losslessly and only re-encodes
audio (WebM disallows AAC). Confirmed live via gallery-dl/yt-dlp that
Instagram never offers a native WebM option to request instead — this has
to be done locally, there's no source-side fix.
## Scanner work — done
Shipped in `53b1f80` ("read gallery-dl sidecars for reel type and post dates").
`useArchiveScanner` tells the three `.json` shapes apart **structurally**, not
by filename, in `src/lib/gallery-dl-sidecar.ts`:
1. Instagram export manifests (`posts_1.json`) — top-level `media` array.
2. Instaloader `.json.xz` — GraphQL node under `node` / `__typename`.
3. gallery-dl `.json` — flat, `post_shortcode` + `type`, none of the above.
`post.isReel` now comes from the sidecar's `type`, which is Instagram's own
classification, and beats every fallback in `post-tabs.ts`. Post dates are
ranked rather than last-write-wins (`src/lib/post-dates.ts`): sidecar beats
filename beats mtime.
Note those files live on **`main`** — they parse the archive at display time
and are viewer code, not fetching tooling.
## Test cases
Real subjects, all present in the archive today. See
`scripts/gdl-sync.py --selftest` for the harness.
| # | case | shortcode | expected |
|---|---|---|---|
| 1 | single image | `CwcXnQhOqFG` | one `.jpg`, no index |
| 2 | single feed video | `DbdG9L9jU4m` | one `.mp4`, `type: post` |
| 3 | carousel, images only | `Cq8LrxSJAJE` | `- 1 … - 3` |
| 4 | carousel, image + video | `CtohvHxLnWO` | `- 1.jpg … - 4.mp4`, **no `.txt`** |
| 5 | carousel of exactly 9 | `Cv2Hb_brx_N` | 1-digit index |
| 6 | carousel of 10+ | `CzM8Uf6B6H_` | 2-digit index `- 01 … - 10` |
| 7 | reel shown on the posts grid | `C8FHM6EJl15` | in `<user>`, `type: reel` |
| 8 | reel on the reels tab | `Db-lNCoib9m` | in `<user> - reels`, `type: reel` |
| 9 | collab reel (other owner) | `DYcZOb0h6Sv` | dir `0ct0ber19 - reels`, filename `chuuo3o` |
| 10 | story | live only | `story - <user>`, per-item shortcode + date |
| 11 | story highlight | `C-IImhvpFuk` | `story highlights - <user> - <title>` |
| 12 | highlight, unicode title | `Drawheeing⠀` | trailing U+2800 preserved in dirname |
| 13 | empty caption | `CrdsY5CrSsO` | media written, `.txt` absent |
| 14 | deleted post | `C0TgI7sphfZ` | on disk, absent live — must not be removed |
| 15 | edited carousel | `C7zG7-jJMlq` | 18 on disk, 8 live — must not be removed |
| 16 | pinned posts | `0ct0ber19` | REST listing is strictly reverse-chronological; see below |
| 17 | profile avatar | `0ct0ber19.jpg` | base dir, undated |
Cases 14–16 are reconciliation, not naming: **a sync must never delete**, since
the archive deliberately outlives Instagram.
Not covered, decide before relying on them: the `/reposts/` tab (`0ct0ber19`
has one) and `/tagged/`. Neither is fetched today.
+184
View File
@@ -0,0 +1,184 @@
slug,username,archive_dir,shortcode,type,date,post_url,json_file
choerry,cher_ryppo,cher_ryppo,C5SBC--Syik,post,2024-04-03 01:50:25,https://www.instagram.com/p/C5SBC--Syik/,2024-04-02_cher_ryppo - C5SBC--Syik.json
choerry,cher_ryppo,cher_ryppo,C811ib_ys7D,post,2024-06-30 13:16:41,https://www.instagram.com/p/C811ib_ys7D/,2024-06-30_cher_ryppo - C811ib_ys7D.json
choerry,cher_ryppo,cher_ryppo,C9JTMgnSCHF,post,2024-07-08 02:41:25,https://www.instagram.com/p/C9JTMgnSCHF/,2024-07-07_cher_ryppo - C9JTMgnSCHF.json
choerry,cher_ryppo,cher_ryppo,C-K6kxZy6qq,post,2024-08-02 14:16:09,https://www.instagram.com/p/C-K6kxZy6qq/,2024-08-02_cher_ryppo - C-K6kxZy6qq.json
choerry,cher_ryppo,cher_ryppo,DBXI0zVs_Iv,post,2024-10-20 21:45:44,https://www.instagram.com/p/DBXI0zVs_Iv/,2024-10-20_cher_ryppo - DBXI0zVs_Iv.json
choerry,cher_ryppo,cher_ryppo,DRRDiezjdyV,post,2025-11-20 05:26:24,https://www.instagram.com/p/DRRDiezjdyV/,2025-11-20_cher_ryppo - DRRDiezjdyV.json
haseul,withaseul,withaseul,Cqps3ZWBlav,post,2023-04-05 10:44:56,https://www.instagram.com/p/Cqps3ZWBlav/,2023-04-05_withaseul - Cqps3ZWBlav.json
haseul,withaseul,withaseul,CqsjfUzSelq,post,2023-04-06 13:20:43,https://www.instagram.com/p/CqsjfUzSelq/,2023-04-06_withaseul - CqsjfUzSelq.json
haseul,withaseul,withaseul,CqxiTfjhNJ2,post,2023-04-08 11:46:34,https://www.instagram.com/p/CqxiTfjhNJ2/,2023-04-08_withaseul - CqxiTfjhNJ2.json
haseul,withaseul,withaseul,Cq4qymBhftL,post,2023-04-11 06:15:24,https://www.instagram.com/p/Cq4qymBhftL/,2023-04-11_withaseul - Cq4qymBhftL.json
haseul,withaseul,withaseul,CrnhJtrhoZi,post,2023-04-29 10:55:29,https://www.instagram.com/p/CrnhJtrhoZi/,2023-04-29_withaseul - CrnhJtrhoZi.json
haseul,withaseul,withaseul,Cr7fZZyB4Sp,post,2023-05-07 05:04:58,https://www.instagram.com/p/Cr7fZZyB4Sp/,2023-05-07_withaseul - Cr7fZZyB4Sp.json
haseul,withaseul,withaseul,Cr8QeaFhFsq,post,2023-05-07 12:13:49,https://www.instagram.com/p/Cr8QeaFhFsq/,2023-05-07_withaseul - Cr8QeaFhFsq.json
haseul,withaseul,withaseul,CsBIA4HBUOX,post,2023-05-09 09:36:05,https://www.instagram.com/p/CsBIA4HBUOX/,2023-05-09_withaseul - CsBIA4HBUOX.json
haseul,withaseul,withaseul,CsgbxFDhNmj,post,2023-05-21 13:25:08,https://www.instagram.com/p/CsgbxFDhNmj/,2023-05-21_withaseul - CsgbxFDhNmj.json
haseul,withaseul,withaseul,Csn9aO4B1r-,post,2023-05-24 11:33:48,https://www.instagram.com/p/Csn9aO4B1r-/,2023-05-24_withaseul - Csn9aO4B1r-.json
haseul,withaseul,withaseul,CsvY2jrBjMc,post,2023-05-27 08:48:17,https://www.instagram.com/p/CsvY2jrBjMc/,2023-05-27_withaseul - CsvY2jrBjMc.json
haseul,withaseul,withaseul,CtEHmRDPesc,post,2023-06-04 10:01:34,https://www.instagram.com/p/CtEHmRDPesc/,2023-06-04_withaseul - CtEHmRDPesc.json
haseul,withaseul,withaseul,CtRmUjsBUoc,post,2023-06-09 15:40:09,https://www.instagram.com/p/CtRmUjsBUoc/,2023-06-09_withaseul - CtRmUjsBUoc.json
haseul,withaseul,withaseul,Cuzar_3LqAA,post,2023-07-17 15:24:04,https://www.instagram.com/p/Cuzar_3LqAA/,2023-07-17_withaseul - Cuzar_3LqAA.json
haseul,withaseul,withaseul,CvHe1VMr7C-,post,2023-07-25 10:25:06,https://www.instagram.com/p/CvHe1VMr7C-/,2023-07-25_withaseul - CvHe1VMr7C-.json
haseul,withaseul,withaseul,CvhRFq7rnHc,post,2023-08-04 10:45:15,https://www.instagram.com/p/CvhRFq7rnHc/,2023-08-04_withaseul - CvhRFq7rnHc.json
haseul,withaseul,withaseul,CwIr3qEBPgF,post,2023-08-19 18:09:39,https://www.instagram.com/p/CwIr3qEBPgF/,2023-08-19_withaseul - CwIr3qEBPgF.json
haseul,withaseul,withaseul,CwzPlMThh5m,post,2023-09-05 06:49:48,https://www.instagram.com/p/CwzPlMThh5m/,2023-09-05_withaseul - CwzPlMThh5m.json
haseul,withaseul,withaseul,Cw9s3LNBhsq,post,2023-09-09 08:18:04,https://www.instagram.com/p/Cw9s3LNBhsq/,2023-09-09_withaseul - Cw9s3LNBhsq.json
haseul,withaseul,withaseul,CxIma4nBlaP,post,2023-09-13 13:53:26,https://www.instagram.com/p/CxIma4nBlaP/,2023-09-13_withaseul - CxIma4nBlaP.json
haseul,withaseul,withaseul,CxLcZ2jBME5,post,2023-09-14 16:23:38,https://www.instagram.com/p/CxLcZ2jBME5/,2023-09-14_withaseul - CxLcZ2jBME5.json
haseul,withaseul,withaseul,CxN3g95hVDH,post,2023-09-15 14:59:00,https://www.instagram.com/p/CxN3g95hVDH/,2023-09-15_withaseul - CxN3g95hVDH.json
haseul,withaseul,withaseul,CxTGBb_BAYw,post,2023-09-17 15:41:59,https://www.instagram.com/p/CxTGBb_BAYw/,2023-09-17_withaseul - CxTGBb_BAYw.json
haseul,withaseul,withaseul,CxnTyJIhUc0,post,2023-09-25 12:07:02,https://www.instagram.com/p/CxnTyJIhUc0/,2023-09-25_withaseul - CxnTyJIhUc0.json
haseul,withaseul,withaseul,CxsPZAur6Xv,post,2023-09-27 10:04:51,https://www.instagram.com/p/CxsPZAur6Xv/,2023-09-27_withaseul - CxsPZAur6Xv.json
haseul,withaseul,withaseul,Cx7mOFqhU7R,post,2023-10-03 09:12:57,https://www.instagram.com/p/Cx7mOFqhU7R/,2023-10-03_withaseul - Cx7mOFqhU7R.json
haseul,withaseul,withaseul,CyqV0lfruX-,post,2023-10-21 12:53:58,https://www.instagram.com/p/CyqV0lfruX-/,2023-10-21_withaseul - CyqV0lfruX-.json
haseul,withaseul,withaseul,Cy7RByYh2BH,post,2023-10-28 02:39:10,https://www.instagram.com/p/Cy7RByYh2BH/,2023-10-27_withaseul - Cy7RByYh2BH.json
haseul,withaseul,withaseul,Cz7nefxBJT0,post,2023-11-22 02:26:43,https://www.instagram.com/p/Cz7nefxBJT0/,2023-11-21_withaseul - Cz7nefxBJT0.json
haseul,withaseul,withaseul,C0YxsROhG4W,post,2023-12-03 10:13:57,https://www.instagram.com/p/C0YxsROhG4W/,2023-12-03_withaseul - C0YxsROhG4W.json
haseul,withaseul,withaseul,C0zOk2JBXTY,post,2023-12-13 16:46:36,https://www.instagram.com/p/C0zOk2JBXTY/,2023-12-13_withaseul - C0zOk2JBXTY.json
haseul,withaseul,withaseul,C1q_9TzBrdt,post,2024-01-04 08:36:20,https://www.instagram.com/p/C1q_9TzBrdt/,2024-01-04_withaseul - C1q_9TzBrdt.json
haseul,withaseul,withaseul,C1t-aeHBCef,post,2024-01-05 12:20:34,https://www.instagram.com/p/C1t-aeHBCef/,2024-01-05_withaseul - C1t-aeHBCef.json
haseul,withaseul,withaseul,C1yrgmMhsmz,post,2024-01-07 08:11:35,https://www.instagram.com/p/C1yrgmMhsmz/,2024-01-07_withaseul - C1yrgmMhsmz.json
haseul,withaseul,withaseul,C1yrxqIhGBZ,post,2024-01-07 08:13:54,https://www.instagram.com/p/C1yrxqIhGBZ/,2024-01-07_withaseul - C1yrxqIhGBZ.json
haseul,withaseul,withaseul,C10p70xh-6x,post,2024-01-08 02:36:18,https://www.instagram.com/p/C10p70xh-6x/,2024-01-07_withaseul - C10p70xh-6x.json
haseul,withaseul,withaseul,C2APujnBSF-,post,2024-01-12 14:38:11,https://www.instagram.com/p/C2APujnBSF-/,2024-01-12_withaseul - C2APujnBSF-.json
haseul,withaseul,withaseul,C3enxknhzeF,post,2024-02-18 06:16:55,https://www.instagram.com/p/C3enxknhzeF/,2024-02-18_withaseul - C3enxknhzeF.json
haseul,withaseul,withaseul,C4f8UQIBZAe,post,2024-03-14 15:07:03,https://www.instagram.com/p/C4f8UQIBZAe/,2024-03-14_withaseul - C4f8UQIBZAe.json
haseul,withaseul,withaseul,C4qIrJTh3po,post,2024-03-18 14:07:26,https://www.instagram.com/p/C4qIrJTh3po/,2024-03-18_withaseul - C4qIrJTh3po.json
haseul,withaseul,withaseul,C4rjDNhhJu8,post,2024-03-19 03:17:09,https://www.instagram.com/p/C4rjDNhhJu8/,2024-03-18_withaseul - C4rjDNhhJu8.json
haseul,withaseul,withaseul,C4xSiGGhP40,post,2024-03-21 08:48:16,https://www.instagram.com/p/C4xSiGGhP40/,2024-03-21_withaseul - C4xSiGGhP40.json
haseul,withaseul,withaseul,C5GBz4FB-kE,post,2024-03-29 10:06:12,https://www.instagram.com/p/C5GBz4FB-kE/,2024-03-29_withaseul - C5GBz4FB-kE.json
haseul,withaseul,withaseul,C5GJmlABYug,post,2024-03-29 11:14:17,https://www.instagram.com/p/C5GJmlABYug/,2024-03-29_withaseul - C5GJmlABYug.json
haseul,withaseul,withaseul,C5LgFFtB8ri,post,2024-03-31 13:06:54,https://www.instagram.com/p/C5LgFFtB8ri/,2024-03-31_withaseul - C5LgFFtB8ri.json
haseul,withaseul,withaseul,C5VZGPDB-Qo,post,2024-04-04 09:18:17,https://www.instagram.com/p/C5VZGPDB-Qo/,2024-04-04_withaseul - C5VZGPDB-Qo.json
haseul,withaseul,withaseul - reels,C5bWrT3BaGy,post,2024-04-06 16:52:50,https://www.instagram.com/p/C5bWrT3BaGy/,2024-04-06_withaseul - C5bWrT3BaGy.json
haseul,withaseul,withaseul,C6LAH8vBs_a,post,2024-04-25 04:59:04,https://www.instagram.com/p/C6LAH8vBs_a/,2024-04-25_withaseul - C6LAH8vBs_a.json
haseul,withaseul,withaseul,C6ypIzVBmiL,post,2024-05-10 14:27:49,https://www.instagram.com/p/C6ypIzVBmiL/,2024-05-10_withaseul - C6ypIzVBmiL.json
haseul,withaseul,withaseul,C68cTpzhUzQ,post,2024-05-14 09:48:07,https://www.instagram.com/p/C68cTpzhUzQ/,2024-05-14_withaseul - C68cTpzhUzQ.json
haseul,withaseul,withaseul,C68oiiahwZd,post,2024-05-14 11:35:00,https://www.instagram.com/p/C68oiiahwZd/,2024-05-14_withaseul - C68oiiahwZd.json
haseul,withaseul,withaseul,C7Oh6mYB7oF,post,2024-05-21 10:23:27,https://www.instagram.com/p/C7Oh6mYB7oF/,2024-05-21_withaseul - C7Oh6mYB7oF.json
haseul,withaseul,withaseul - reels,C7RnCv6BZT3,post,2024-05-22 15:06:48,https://www.instagram.com/p/C7RnCv6BZT3/,2024-05-22_withaseul - C7RnCv6BZT3.json
haseul,withaseul,withaseul,C7R7owoBd4n,post,2024-05-22 18:05:56,https://www.instagram.com/p/C7R7owoBd4n/,2024-05-22_withaseul - C7R7owoBd4n.json
haseul,withaseul,withaseul,C7mPzLRhCRz,post,2024-05-30 15:26:55,https://www.instagram.com/p/C7mPzLRhCRz/,2024-05-30_withaseul - C7mPzLRhCRz.json
haseul,withaseul,withaseul,C7tNIlPh7NK,post,2024-06-02 08:18:19,https://www.instagram.com/p/C7tNIlPh7NK/,2024-06-02_withaseul - C7tNIlPh7NK.json
haseul,withaseul,withaseul,C70yRTmBgng,post,2024-06-05 06:57:30,https://www.instagram.com/p/C70yRTmBgng/,2024-06-05_withaseul - C70yRTmBgng.json
haseul,withaseul,withaseul,C71aPMWBfqs,post,2024-06-05 12:46:44,https://www.instagram.com/p/C71aPMWBfqs/,2024-06-05_withaseul - C71aPMWBfqs.json
haseul,withaseul,withaseul,C8NBuqRhCJv,post,2024-06-14 16:54:21,https://www.instagram.com/p/C8NBuqRhCJv/,2024-06-14_withaseul - C8NBuqRhCJv.json
haseul,withaseul,withaseul,C8PPpUKhyLf,post,2024-06-15 13:34:26,https://www.instagram.com/p/C8PPpUKhyLf/,2024-06-15_withaseul - C8PPpUKhyLf.json
haseul,withaseul,withaseul,C8UjYG8B74s,post,2024-06-17 15:03:03,https://www.instagram.com/p/C8UjYG8B74s/,2024-06-17_withaseul - C8UjYG8B74s.json
haseul,withaseul,withaseul,C87PlKyBTU6,post,2024-07-02 15:40:27,https://www.instagram.com/p/C87PlKyBTU6/,2024-07-02_withaseul - C87PlKyBTU6.json
haseul,withaseul,withaseul,C9FqomJhky4,post,2024-07-06 16:49:16,https://www.instagram.com/p/C9FqomJhky4/,2024-07-06_withaseul - C9FqomJhky4.json
haseul,withaseul,withaseul,C9ICMZ5BUfr,post,2024-07-07 14:53:36,https://www.instagram.com/p/C9ICMZ5BUfr/,2024-07-07_withaseul - C9ICMZ5BUfr.json
haseul,withaseul,withaseul,C9V8bjJBPdk,post,2024-07-13 00:32:37,https://www.instagram.com/p/C9V8bjJBPdk/,2024-07-12_withaseul - C9V8bjJBPdk.json
haseul,withaseul,withaseul,C9xK8b1SZAY,post,2024-07-23 14:18:56,https://www.instagram.com/p/C9xK8b1SZAY/,2024-07-23_withaseul - C9xK8b1SZAY.json
haseul,withaseul,withaseul,C-DLjJ6BE0y,post,2024-07-30 14:10:33,https://www.instagram.com/p/C-DLjJ6BE0y/,2024-07-30_withaseul - C-DLjJ6BE0y.json
haseul,withaseul,withaseul,C-Ncp1MyhiD,post,2024-08-03 13:52:25,https://www.instagram.com/p/C-Ncp1MyhiD/,2024-08-03_withaseul - C-Ncp1MyhiD.json
haseul,withaseul,withaseul,C_A4QkApuLO,post,2024-08-23 13:14:54,https://www.instagram.com/p/C_A4QkApuLO/,2024-08-23_withaseul - C_A4QkApuLO.json
haseul,withaseul,withaseul,C_CcGblt8X9,post,2024-08-24 03:47:20,https://www.instagram.com/p/C_CcGblt8X9/,2024-08-23_withaseul - C_CcGblt8X9.json
haseul,withaseul,withaseul,C_ZCyXPylSu,post,2024-09-01 22:28:40,https://www.instagram.com/p/C_ZCyXPylSu/,2024-09-01_withaseul - C_ZCyXPylSu.json
haseul,withaseul,withaseul,C_gRp4gPuY3,post,2024-09-04 17:53:16,https://www.instagram.com/p/C_gRp4gPuY3/,2024-09-04_withaseul - C_gRp4gPuY3.json
haseul,withaseul,withaseul,C_7XyvJMmm7,post,2024-09-15 06:26:24,https://www.instagram.com/p/C_7XyvJMmm7/,2024-09-15_withaseul - C_7XyvJMmm7.json
haseul,withaseul,withaseul,DACAVOGyOEc,post,2024-09-17 20:16:04,https://www.instagram.com/p/DACAVOGyOEc/,2024-09-17_withaseul - DACAVOGyOEc.json
haseul,withaseul,withaseul,DAqwdZIBjY5,post,2024-10-03 16:06:14,https://www.instagram.com/p/DAqwdZIBjY5/,2024-10-03_withaseul - DAqwdZIBjY5.json
haseul,withaseul,withaseul,DAv8DxvyGbr,post,2024-10-05 16:23:48,https://www.instagram.com/p/DAv8DxvyGbr/,2024-10-05_withaseul - DAv8DxvyGbr.json
haseul,withaseul,withaseul,DBhdsL1NIvC,post,2024-10-24 22:00:28,https://www.instagram.com/p/DBhdsL1NIvC/,2024-10-24_withaseul - DBhdsL1NIvC.json
haseul,withaseul,withaseul,DBrP-pYSDEn,post,2024-10-28 17:13:03,https://www.instagram.com/p/DBrP-pYSDEn/,2024-10-28_withaseul - DBrP-pYSDEn.json
haseul,withaseul,withaseul,DBu5zCHBgrT,post,2024-10-30 03:16:12,https://www.instagram.com/p/DBu5zCHBgrT/,2024-10-29_withaseul - DBu5zCHBgrT.json
haseul,withaseul,withaseul,DByVUvOBSQy,post,2024-10-31 11:14:27,https://www.instagram.com/p/DByVUvOBSQy/,2024-10-31_withaseul - DByVUvOBSQy.json
haseul,withaseul,withaseul,DB5re5DhW8a,post,2024-11-03 07:42:45,https://www.instagram.com/p/DB5re5DhW8a/,2024-11-03_withaseul - DB5re5DhW8a.json
haseul,withaseul,withaseul,DCBsfwGhFYd,post,2024-11-06 10:25:32,https://www.instagram.com/p/DCBsfwGhFYd/,2024-11-06_withaseul - DCBsfwGhFYd.json
haseul,withaseul,withaseul,DCObB0SR64m,post,2024-11-11 09:03:02,https://www.instagram.com/p/DCObB0SR64m/,2024-11-11_withaseul - DCObB0SR64m.json
haseul,withaseul,withaseul,DCRhOPphLWU,post,2024-11-12 13:54:52,https://www.instagram.com/p/DCRhOPphLWU/,2024-11-12_withaseul - DCRhOPphLWU.json
haseul,withaseul,withaseul,DCbgyVghiT-,post,2024-11-16 11:03:28,https://www.instagram.com/p/DCbgyVghiT-/,2024-11-16_withaseul - DCbgyVghiT-.json
haseul,withaseul,withaseul,DCe4qpXhgBV,post,2024-11-17 18:29:51,https://www.instagram.com/p/DCe4qpXhgBV/,2024-11-17_withaseul - DCe4qpXhgBV.json
haseul,withaseul,withaseul,DC_4KddBmo_,post,2024-11-30 14:00:24,https://www.instagram.com/p/DC_4KddBmo_/,2024-11-30_withaseul - DC_4KddBmo_.json
haseul,withaseul,withaseul,DDRNi9Bhicn,post,2024-12-07 07:34:20,https://www.instagram.com/p/DDRNi9Bhicn/,2024-12-07_withaseul - DDRNi9Bhicn.json
haseul,withaseul,withaseul,DDW2JdFBUMs,post,2024-12-09 12:05:19,https://www.instagram.com/p/DDW2JdFBUMs/,2024-12-09_withaseul - DDW2JdFBUMs.json
haseul,withaseul,withaseul,DDjWETEBWw2,post,2024-12-14 08:35:07,https://www.instagram.com/p/DDjWETEBWw2/,2024-12-14_withaseul - DDjWETEBWw2.json
haseul,withaseul,withaseul,DDpkLTyhUdt,post,2024-12-16 18:33:51,https://www.instagram.com/p/DDpkLTyhUdt/,2024-12-16_withaseul - DDpkLTyhUdt.json
haseul,withaseul,withaseul,DE1NVFihF2g,post,2025-01-15 03:36:30,https://www.instagram.com/p/DE1NVFihF2g/,2025-01-14_withaseul - DE1NVFihF2g.json
haseul,withaseul,withaseul,DE5GO2-S5PB,post,2025-01-16 15:51:26,https://www.instagram.com/p/DE5GO2-S5PB/,2025-01-16_withaseul - DE5GO2-S5PB.json
haseul,withaseul,withaseul - reels,DE5H7LcB5aX,post,2025-01-16 16:07:31,https://www.instagram.com/p/DE5H7LcB5aX/,2025-01-16_withaseul - DE5H7LcB5aX.json
haseul,withaseul,withaseul,DFFtju9hTdv,post,2025-01-21 13:25:58,https://www.instagram.com/p/DFFtju9hTdv/,2025-01-21_withaseul - DFFtju9hTdv.json
haseul,withaseul,withaseul,DFsLxphhwqE,post,2025-02-05 12:01:09,https://www.instagram.com/p/DFsLxphhwqE/,2025-02-05_withaseul - DFsLxphhwqE.json
haseul,withaseul,withaseul,DHvlOgAxE_j,post,2025-03-28 12:44:03,https://www.instagram.com/p/DHvlOgAxE_j/,2025-03-28_withaseul - DHvlOgAxE_j.json
haseul,withaseul,withaseul,DH96B0QtxfT,post,2025-04-03 02:15:11,https://www.instagram.com/p/DH96B0QtxfT/,2025-04-02_withaseul - DH96B0QtxfT.json
haseul,withaseul,withaseul,DIL6wNXRGDC,post,2025-04-08 12:50:53,https://www.instagram.com/p/DIL6wNXRGDC/,2025-04-08_withaseul - DIL6wNXRGDC.json
haseul,withaseul,withaseul,DIP1mift6QV,post,2025-04-10 01:22:50,https://www.instagram.com/p/DIP1mift6QV/,2025-04-09_withaseul - DIP1mift6QV.json
haseul,withaseul,withaseul,DIfvDIKxwuT,post,2025-04-16 05:33:25,https://www.instagram.com/p/DIfvDIKxwuT/,2025-04-16_withaseul - DIfvDIKxwuT.json
haseul,withaseul,withaseul,DIm-MshvCJy,post,2025-04-19 01:00:03,https://www.instagram.com/p/DIm-MshvCJy/,2025-04-18_withaseul - DIm-MshvCJy.json
haseul,withaseul,withaseul,DIuQyfphWnR,post,2025-04-21 20:57:37,https://www.instagram.com/p/DIuQyfphWnR/,2025-04-21_withaseul - DIuQyfphWnR.json
haseul,withaseul,withaseul,DI3hOzrBLs-,post,2025-04-25 11:14:27,https://www.instagram.com/p/DI3hOzrBLs-/,2025-04-25_withaseul - DI3hOzrBLs-.json
haseul,withaseul,withaseul,DK0lklBhrH6,post,2025-06-13 00:57:27,https://www.instagram.com/p/DK0lklBhrH6/,2025-06-12_withaseul - DK0lklBhrH6.json
haseul,withaseul,withaseul,DK1EdUZBJtr,post,2025-06-13 05:27:20,https://www.instagram.com/p/DK1EdUZBJtr/,2025-06-13_withaseul - DK1EdUZBJtr.json
haseul,withaseul,withaseul,DLJiMzrhM83,post,2025-06-21 04:12:02,https://www.instagram.com/p/DLJiMzrhM83/,2025-06-21_withaseul - DLJiMzrhM83.json
haseul,withaseul,withaseul,DLL1iePBcwm,post,2025-06-22 01:39:30,https://www.instagram.com/p/DLL1iePBcwm/,2025-06-21_withaseul - DLL1iePBcwm.json
haseul,withaseul,withaseul,DLM-sUIhK5P,post,2025-06-22 12:18:44,https://www.instagram.com/p/DLM-sUIhK5P/,2025-06-22_withaseul - DLM-sUIhK5P.json
haseul,withaseul,withaseul,DLU66yCvWZh,post,2025-06-25 14:19:41,https://www.instagram.com/p/DLU66yCvWZh/,2025-06-25_withaseul - DLU66yCvWZh.json
haseul,withaseul,withaseul,DLxAZg5hpnW,post,2025-07-06 12:06:18,https://www.instagram.com/p/DLxAZg5hpnW/,2025-07-06_withaseul - DLxAZg5hpnW.json
haseul,withaseul,withaseul,DMFfDqABJgM,post,2025-07-14 10:59:00,https://www.instagram.com/p/DMFfDqABJgM/,2025-07-14_withaseul - DMFfDqABJgM.json
haseul,withaseul,withaseul,DM0NH9yhkzT,post,2025-08-01 14:26:37,https://www.instagram.com/p/DM0NH9yhkzT/,2025-08-01_withaseul - DM0NH9yhkzT.json
haseul,withaseul,withaseul,DM9_Ol7SzmK,post,2025-08-05 09:37:35,https://www.instagram.com/p/DM9_Ol7SzmK/,2025-08-05_withaseul - DM9_Ol7SzmK.json
haseul,withaseul,withaseul,DNnih4pBd_M,post,2025-08-21 12:54:55,https://www.instagram.com/p/DNnih4pBd_M/,2025-08-21_withaseul - DNnih4pBd_M.json
haseul,withaseul,withaseul,DOIxptrgXgv,post,2025-09-03 10:42:00,https://www.instagram.com/p/DOIxptrgXgv/,2025-09-03_withaseul - DOIxptrgXgv.json
haseul,withaseul,withaseul,DOa8a_ugc2Z,post,2025-09-10 12:02:26,https://www.instagram.com/p/DOa8a_ugc2Z/,2025-09-10_withaseul - DOa8a_ugc2Z.json
haseul,withaseul,withaseul,DPMLxsmgUJJ,post,2025-09-29 14:59:24,https://www.instagram.com/p/DPMLxsmgUJJ/,2025-09-29_withaseul - DPMLxsmgUJJ.json
haseul,withaseul,withaseul,DPRez1QgUxE,post,2025-10-01 16:21:55,https://www.instagram.com/p/DPRez1QgUxE/,2025-10-01_withaseul - DPRez1QgUxE.json
haseul,withaseul,withaseul,DQg9WOOgaKD,post,2025-11-01 13:08:45,https://www.instagram.com/p/DQg9WOOgaKD/,2025-11-01_withaseul - DQg9WOOgaKD.json
haseul,withaseul,withaseul,DQ_JrMdDciC,post,2025-11-13 06:33:42,https://www.instagram.com/p/DQ_JrMdDciC/,2025-11-13_withaseul - DQ_JrMdDciC.json
haseul,withaseul,withaseul,DROhLjLjVqY,post,2025-11-19 05:47:42,https://www.instagram.com/p/DROhLjLjVqY/,2025-11-19_withaseul - DROhLjLjVqY.json
haseul,withaseul,withaseul,DRwiTEDDRHR,post,2025-12-02 10:51:38,https://www.instagram.com/p/DRwiTEDDRHR/,2025-12-02_withaseul - DRwiTEDDRHR.json
haseul,withaseul,withaseul,DTw-4d9gRUx,post,2026-01-21 08:04:12,https://www.instagram.com/p/DTw-4d9gRUx/,2026-01-21_withaseul - DTw-4d9gRUx.json
haseul,withaseul,withaseul,DT0rHDCjPpE,post,2026-01-22 18:28:24,https://www.instagram.com/p/DT0rHDCjPpE/,2026-01-22_withaseul - DT0rHDCjPpE.json
haseul,withaseul,withaseul,DT6HqajDJfF,post,2026-01-24 21:14:05,https://www.instagram.com/p/DT6HqajDJfF/,2026-01-24_withaseul - DT6HqajDJfF.json
haseul,withaseul,withaseul,DU0oU25gWso,post,2026-02-16 14:19:54,https://www.instagram.com/p/DU0oU25gWso/,2026-02-16_withaseul - DU0oU25gWso.json
haseul,withaseul,withaseul,DWZPNAKAZcl,post,2026-03-27 16:19:14,https://www.instagram.com/p/DWZPNAKAZcl/,2026-03-27_withaseul - DWZPNAKAZcl.json
haseul,withaseul,withaseul,DW3yBm2gctK,post,2026-04-08 13:00:00,https://www.instagram.com/p/DW3yBm2gctK/,2026-04-08_withaseul - DW3yBm2gctK.json
haseul,withaseul,withaseul,DZj6uVWhV8M,post,2026-06-14 09:26:10,https://www.instagram.com/p/DZj6uVWhV8M/,2026-06-14_withaseul - DZj6uVWhV8M.json
haseul,withaseul,withaseul,DaZm_xLAZeL,post,2026-07-05 05:52:43,https://www.instagram.com/p/DaZm_xLAZeL/,2026-07-05_withaseul - DaZm_xLAZeL.json
haseul,withaseul,withaseul,Dbnq4k9gb5U,post,2026-08-04 13:27:27,https://www.instagram.com/p/Dbnq4k9gb5U/,2026-08-04_withaseul - Dbnq4k9gb5U.json
heejin,0ct0ber19,0ct0ber19,CrdsY5CrSsO,post,2023-04-25 15:21:16,https://www.instagram.com/p/CrdsY5CrSsO/,2023-04-25_0ct0ber19 - CrdsY5CrSsO.json
heejin,0ct0ber19,0ct0ber19,CtohvHxLnWO,post,2023-06-18 13:22:37,https://www.instagram.com/p/CtohvHxLnWO/,2023-06-18_0ct0ber19 - CtohvHxLnWO.json
heejin,0ct0ber19,0ct0ber19,CuEOMWppp5S,post,2023-06-29 07:30:35,https://www.instagram.com/p/CuEOMWppp5S/,2023-06-29_0ct0ber19 - CuEOMWppp5S.json
heejin,0ct0ber19,0ct0ber19,CwC0Y-prGoB,post,2023-08-17 11:28:40,https://www.instagram.com/p/CwC0Y-prGoB/,2023-08-17_0ct0ber19 - CwC0Y-prGoB.json
heejin,0ct0ber19,0ct0ber19,CxsKPWgpzU4,post,2023-09-27 09:19:51,https://www.instagram.com/p/CxsKPWgpzU4/,2023-09-27_0ct0ber19 - CxsKPWgpzU4.json
heejin,0ct0ber19,0ct0ber19,CzJf78xryub,post,2023-11-02 15:18:48,https://www.instagram.com/p/CzJf78xryub/,2023-11-02_0ct0ber19 - CzJf78xryub.json
heejin,0ct0ber19,0ct0ber19,CzOVVzFLTnD,post,2023-11-04 12:22:25,https://www.instagram.com/p/CzOVVzFLTnD/,2023-11-04_0ct0ber19 - CzOVVzFLTnD.json
heejin,0ct0ber19,0ct0ber19,C14VKVmpdtC,post,2024-01-09 12:51:44,https://www.instagram.com/p/C14VKVmpdtC/,2024-01-09_0ct0ber19 - C14VKVmpdtC.json
heejin,0ct0ber19,0ct0ber19,C2FnIelpue9,post,2024-01-14 16:38:54,https://www.instagram.com/p/C2FnIelpue9/,2024-01-14_0ct0ber19 - C2FnIelpue9.json
heejin,0ct0ber19,0ct0ber19,C5SDY_UJIn6,post,2024-04-03 02:10:53,https://www.instagram.com/p/C5SDY_UJIn6/,2024-04-02_0ct0ber19 - C5SDY_UJIn6.json
heejin,0ct0ber19,0ct0ber19,C5WB6zwp_rX,post,2024-04-04 15:15:00,https://www.instagram.com/p/C5WB6zwp_rX/,2024-04-04_0ct0ber19 - C5WB6zwp_rX.json
heejin,0ct0ber19,0ct0ber19,C5dJ0xXpJpX,post,2024-04-07 09:38:46,https://www.instagram.com/p/C5dJ0xXpJpX/,2024-04-07_0ct0ber19 - C5dJ0xXpJpX.json
heejin,0ct0ber19,0ct0ber19,C5oJdT-pQBI,post,2024-04-11 16:07:12,https://www.instagram.com/p/C5oJdT-pQBI/,2024-04-11_0ct0ber19 - C5oJdT-pQBI.json
heejin,0ct0ber19,0ct0ber19,C53YPQzp7Wj,post,2024-04-17 14:04:58,https://www.instagram.com/p/C53YPQzp7Wj/,2024-04-17_0ct0ber19 - C53YPQzp7Wj.json
heejin,0ct0ber19,0ct0ber19,C5--hdUJKZH,post,2024-04-20 12:54:11,https://www.instagram.com/p/C5--hdUJKZH/,2024-04-20_0ct0ber19 - C5--hdUJKZH.json
heejin,0ct0ber19,0ct0ber19,C71iAmbpekA,post,2024-06-05 13:54:39,https://www.instagram.com/p/C71iAmbpekA/,2024-06-05_0ct0ber19 - C71iAmbpekA.json
heejin,0ct0ber19,0ct0ber19,C8M8TxnSOKI,post,2024-06-14 16:07:00,https://www.instagram.com/p/C8M8TxnSOKI/,2024-06-14_0ct0ber19 - C8M8TxnSOKI.json
heejin,0ct0ber19,0ct0ber19,C8hxpuYyWGv,post,2024-06-22 18:17:55,https://www.instagram.com/p/C8hxpuYyWGv/,2024-06-22_0ct0ber19 - C8hxpuYyWGv.json
heejin,0ct0ber19,0ct0ber19,C-AbtWSJbHB,post,2024-07-29 12:34:02,https://www.instagram.com/p/C-AbtWSJbHB/,2024-07-29_0ct0ber19 - C-AbtWSJbHB.json
heejin,0ct0ber19,0ct0ber19,C_WxFtuvqXK,post,2024-09-01 01:15:32,https://www.instagram.com/p/C_WxFtuvqXK/,2024-08-31_0ct0ber19 - C_WxFtuvqXK.json
heejin,0ct0ber19,0ct0ber19,DcgTbz-iTAH,post,2026-08-26 13:19:15,https://www.instagram.com/p/DcgTbz-iTAH/,2026-08-26_0ct0ber19 - DcgTbz-iTAH.json
jinsoul,zindoriyam,zindoriyam,CndmboMBHeB,post,2023-01-16 04:23:38,https://www.instagram.com/p/CndmboMBHeB/,2023-01-15_zindoriyam - CndmboMBHeB.json
jinsoul,zindoriyam,zindoriyam,Ctb5AnZxCwh,post,2023-06-13 15:35:51,https://www.instagram.com/p/Ctb5AnZxCwh/,2023-06-13_zindoriyam - Ctb5AnZxCwh.json
jinsoul,zindoriyam,zindoriyam,Cu6ZRz8hj_T,post,2023-07-20 08:26:26,https://www.instagram.com/p/Cu6ZRz8hj_T/,2023-07-20_zindoriyam - Cu6ZRz8hj_T.json
jinsoul,zindoriyam,zindoriyam,Cwrydt6OXmo,post,2023-09-02 09:20:42,https://www.instagram.com/p/Cwrydt6OXmo/,2023-09-02_zindoriyam - Cwrydt6OXmo.json
jinsoul,zindoriyam,zindoriyam,Cxsj3_IhwNv,post,2023-09-27 13:03:51,https://www.instagram.com/p/Cxsj3_IhwNv/,2023-09-27_zindoriyam - Cxsj3_IhwNv.json
jinsoul,zindoriyam,zindoriyam,Cza0C4gBkb5,post,2023-11-09 08:41:36,https://www.instagram.com/p/Cza0C4gBkb5/,2023-11-09_zindoriyam - Cza0C4gBkb5.json
jinsoul,zindoriyam,zindoriyam,C5LqGJUBTIK,post,2024-03-31 14:34:25,https://www.instagram.com/p/C5LqGJUBTIK/,2024-03-31_zindoriyam - C5LqGJUBTIK.json
jinsoul,zindoriyam,zindoriyam,C5QZg9BBc12,post,2024-04-02 10:45:44,https://www.instagram.com/p/C5QZg9BBc12/,2024-04-02_zindoriyam - C5QZg9BBc12.json
jinsoul,zindoriyam,zindoriyam,C8NAliShdHm,post,2024-06-14 16:44:22,https://www.instagram.com/p/C8NAliShdHm/,2024-06-14_zindoriyam - C8NAliShdHm.json
jinsoul,zindoriyam,zindoriyam,C9-Ot__hyGY,post,2024-07-28 16:02:02,https://www.instagram.com/p/C9-Ot__hyGY/,2024-07-28_zindoriyam - C9-Ot__hyGY.json
jinsoul,zindoriyam,zindoriyam,DANQU24um-N,post,2024-09-22 05:07:29,https://www.instagram.com/p/DANQU24um-N/,2024-09-22_zindoriyam - DANQU24um-N.json
jinsoul,zindoriyam,zindoriyam,DHz88e2sS_J,post,2025-03-30 05:28:16,https://www.instagram.com/p/DHz88e2sS_J/,2025-03-30_zindoriyam - DHz88e2sS_J.json
jinsoul,zindoriyam,zindoriyam,DJXAVrANWfp,post,2025-05-07 16:42:44,https://www.instagram.com/p/DJXAVrANWfp/,2025-05-07_zindoriyam - DJXAVrANWfp.json
jinsoul,zindoriyam,zindoriyam,DLhdKkZJ2_5,post,2025-06-30 11:09:49,https://www.instagram.com/p/DLhdKkZJ2_5/,2025-06-30_zindoriyam - DLhdKkZJ2_5.json
jinsoul,zindoriyam,zindoriyam,DMiZJbaJZXv,post,2025-07-25 16:25:21,https://www.instagram.com/p/DMiZJbaJZXv/,2025-07-25_zindoriyam - DMiZJbaJZXv.json
jinsoul,zindoriyam,zindoriyam,DNJCd9jpT-J,post,2025-08-09 16:37:33,https://www.instagram.com/p/DNJCd9jpT-J/,2025-08-09_zindoriyam - DNJCd9jpT-J.json
jinsoul,zindoriyam,zindoriyam,DRl8jAxFnwE,post,2025-11-28 08:09:22,https://www.instagram.com/p/DRl8jAxFnwE/,2025-11-28_zindoriyam - DRl8jAxFnwE.json
jinsoul,zindoriyam,zindoriyam,DTQR5cDk6kY,post,2026-01-08 15:15:25,https://www.instagram.com/p/DTQR5cDk6kY/,2026-01-08_zindoriyam - DTQR5cDk6kY.json
jinsoul,zindoriyam,zindoriyam,DVv1oRSEyIy,post,2026-03-11 14:26:54,https://www.instagram.com/p/DVv1oRSEyIy/,2026-03-11_zindoriyam - DVv1oRSEyIy.json
jinsoul,zindoriyam,zindoriyam,DW_6oxSkz8u,post,2026-04-11 16:49:54,https://www.instagram.com/p/DW_6oxSkz8u/,2026-04-11_zindoriyam - DW_6oxSkz8u.json
jinsoul,zindoriyam,zindoriyam,DcRLAVJEwlG,post,2026-08-20 16:16:59,https://www.instagram.com/p/DcRLAVJEwlG/,2026-08-20_zindoriyam - DcRLAVJEwlG.json
kimlip,kimxxlip,kimxxlip,CndmBanNyrR,post,2023-01-16 04:20:04,https://www.instagram.com/p/CndmBanNyrR/,2023-01-15_kimxxlip - CndmBanNyrR.json
kimlip,kimxxlip,kimxxlip,CwXG1L3vQFH,post,2023-08-25 08:34:37,https://www.instagram.com/p/CwXG1L3vQFH/,2023-08-25_kimxxlip - CwXG1L3vQFH.json
kimlip,kimxxlip,kimxxlip,CxsL_FQvHna,post,2023-09-27 09:35:06,https://www.instagram.com/p/CxsL_FQvHna/,2023-09-27_kimxxlip - CxsL_FQvHna.json
1 slug username archive_dir shortcode type date post_url json_file
2 choerry cher_ryppo cher_ryppo C5SBC--Syik post 2024-04-03 01:50:25 https://www.instagram.com/p/C5SBC--Syik/ 2024-04-02_cher_ryppo - C5SBC--Syik.json
3 choerry cher_ryppo cher_ryppo C811ib_ys7D post 2024-06-30 13:16:41 https://www.instagram.com/p/C811ib_ys7D/ 2024-06-30_cher_ryppo - C811ib_ys7D.json
4 choerry cher_ryppo cher_ryppo C9JTMgnSCHF post 2024-07-08 02:41:25 https://www.instagram.com/p/C9JTMgnSCHF/ 2024-07-07_cher_ryppo - C9JTMgnSCHF.json
5 choerry cher_ryppo cher_ryppo C-K6kxZy6qq post 2024-08-02 14:16:09 https://www.instagram.com/p/C-K6kxZy6qq/ 2024-08-02_cher_ryppo - C-K6kxZy6qq.json
6 choerry cher_ryppo cher_ryppo DBXI0zVs_Iv post 2024-10-20 21:45:44 https://www.instagram.com/p/DBXI0zVs_Iv/ 2024-10-20_cher_ryppo - DBXI0zVs_Iv.json
7 choerry cher_ryppo cher_ryppo DRRDiezjdyV post 2025-11-20 05:26:24 https://www.instagram.com/p/DRRDiezjdyV/ 2025-11-20_cher_ryppo - DRRDiezjdyV.json
8 haseul withaseul withaseul Cqps3ZWBlav post 2023-04-05 10:44:56 https://www.instagram.com/p/Cqps3ZWBlav/ 2023-04-05_withaseul - Cqps3ZWBlav.json
9 haseul withaseul withaseul CqsjfUzSelq post 2023-04-06 13:20:43 https://www.instagram.com/p/CqsjfUzSelq/ 2023-04-06_withaseul - CqsjfUzSelq.json
10 haseul withaseul withaseul CqxiTfjhNJ2 post 2023-04-08 11:46:34 https://www.instagram.com/p/CqxiTfjhNJ2/ 2023-04-08_withaseul - CqxiTfjhNJ2.json
11 haseul withaseul withaseul Cq4qymBhftL post 2023-04-11 06:15:24 https://www.instagram.com/p/Cq4qymBhftL/ 2023-04-11_withaseul - Cq4qymBhftL.json
12 haseul withaseul withaseul CrnhJtrhoZi post 2023-04-29 10:55:29 https://www.instagram.com/p/CrnhJtrhoZi/ 2023-04-29_withaseul - CrnhJtrhoZi.json
13 haseul withaseul withaseul Cr7fZZyB4Sp post 2023-05-07 05:04:58 https://www.instagram.com/p/Cr7fZZyB4Sp/ 2023-05-07_withaseul - Cr7fZZyB4Sp.json
14 haseul withaseul withaseul Cr8QeaFhFsq post 2023-05-07 12:13:49 https://www.instagram.com/p/Cr8QeaFhFsq/ 2023-05-07_withaseul - Cr8QeaFhFsq.json
15 haseul withaseul withaseul CsBIA4HBUOX post 2023-05-09 09:36:05 https://www.instagram.com/p/CsBIA4HBUOX/ 2023-05-09_withaseul - CsBIA4HBUOX.json
16 haseul withaseul withaseul CsgbxFDhNmj post 2023-05-21 13:25:08 https://www.instagram.com/p/CsgbxFDhNmj/ 2023-05-21_withaseul - CsgbxFDhNmj.json
17 haseul withaseul withaseul Csn9aO4B1r- post 2023-05-24 11:33:48 https://www.instagram.com/p/Csn9aO4B1r-/ 2023-05-24_withaseul - Csn9aO4B1r-.json
18 haseul withaseul withaseul CsvY2jrBjMc post 2023-05-27 08:48:17 https://www.instagram.com/p/CsvY2jrBjMc/ 2023-05-27_withaseul - CsvY2jrBjMc.json
19 haseul withaseul withaseul CtEHmRDPesc post 2023-06-04 10:01:34 https://www.instagram.com/p/CtEHmRDPesc/ 2023-06-04_withaseul - CtEHmRDPesc.json
20 haseul withaseul withaseul CtRmUjsBUoc post 2023-06-09 15:40:09 https://www.instagram.com/p/CtRmUjsBUoc/ 2023-06-09_withaseul - CtRmUjsBUoc.json
21 haseul withaseul withaseul Cuzar_3LqAA post 2023-07-17 15:24:04 https://www.instagram.com/p/Cuzar_3LqAA/ 2023-07-17_withaseul - Cuzar_3LqAA.json
22 haseul withaseul withaseul CvHe1VMr7C- post 2023-07-25 10:25:06 https://www.instagram.com/p/CvHe1VMr7C-/ 2023-07-25_withaseul - CvHe1VMr7C-.json
23 haseul withaseul withaseul CvhRFq7rnHc post 2023-08-04 10:45:15 https://www.instagram.com/p/CvhRFq7rnHc/ 2023-08-04_withaseul - CvhRFq7rnHc.json
24 haseul withaseul withaseul CwIr3qEBPgF post 2023-08-19 18:09:39 https://www.instagram.com/p/CwIr3qEBPgF/ 2023-08-19_withaseul - CwIr3qEBPgF.json
25 haseul withaseul withaseul CwzPlMThh5m post 2023-09-05 06:49:48 https://www.instagram.com/p/CwzPlMThh5m/ 2023-09-05_withaseul - CwzPlMThh5m.json
26 haseul withaseul withaseul Cw9s3LNBhsq post 2023-09-09 08:18:04 https://www.instagram.com/p/Cw9s3LNBhsq/ 2023-09-09_withaseul - Cw9s3LNBhsq.json
27 haseul withaseul withaseul CxIma4nBlaP post 2023-09-13 13:53:26 https://www.instagram.com/p/CxIma4nBlaP/ 2023-09-13_withaseul - CxIma4nBlaP.json
28 haseul withaseul withaseul CxLcZ2jBME5 post 2023-09-14 16:23:38 https://www.instagram.com/p/CxLcZ2jBME5/ 2023-09-14_withaseul - CxLcZ2jBME5.json
29 haseul withaseul withaseul CxN3g95hVDH post 2023-09-15 14:59:00 https://www.instagram.com/p/CxN3g95hVDH/ 2023-09-15_withaseul - CxN3g95hVDH.json
30 haseul withaseul withaseul CxTGBb_BAYw post 2023-09-17 15:41:59 https://www.instagram.com/p/CxTGBb_BAYw/ 2023-09-17_withaseul - CxTGBb_BAYw.json
31 haseul withaseul withaseul CxnTyJIhUc0 post 2023-09-25 12:07:02 https://www.instagram.com/p/CxnTyJIhUc0/ 2023-09-25_withaseul - CxnTyJIhUc0.json
32 haseul withaseul withaseul CxsPZAur6Xv post 2023-09-27 10:04:51 https://www.instagram.com/p/CxsPZAur6Xv/ 2023-09-27_withaseul - CxsPZAur6Xv.json
33 haseul withaseul withaseul Cx7mOFqhU7R post 2023-10-03 09:12:57 https://www.instagram.com/p/Cx7mOFqhU7R/ 2023-10-03_withaseul - Cx7mOFqhU7R.json
34 haseul withaseul withaseul CyqV0lfruX- post 2023-10-21 12:53:58 https://www.instagram.com/p/CyqV0lfruX-/ 2023-10-21_withaseul - CyqV0lfruX-.json
35 haseul withaseul withaseul Cy7RByYh2BH post 2023-10-28 02:39:10 https://www.instagram.com/p/Cy7RByYh2BH/ 2023-10-27_withaseul - Cy7RByYh2BH.json
36 haseul withaseul withaseul Cz7nefxBJT0 post 2023-11-22 02:26:43 https://www.instagram.com/p/Cz7nefxBJT0/ 2023-11-21_withaseul - Cz7nefxBJT0.json
37 haseul withaseul withaseul C0YxsROhG4W post 2023-12-03 10:13:57 https://www.instagram.com/p/C0YxsROhG4W/ 2023-12-03_withaseul - C0YxsROhG4W.json
38 haseul withaseul withaseul C0zOk2JBXTY post 2023-12-13 16:46:36 https://www.instagram.com/p/C0zOk2JBXTY/ 2023-12-13_withaseul - C0zOk2JBXTY.json
39 haseul withaseul withaseul C1q_9TzBrdt post 2024-01-04 08:36:20 https://www.instagram.com/p/C1q_9TzBrdt/ 2024-01-04_withaseul - C1q_9TzBrdt.json
40 haseul withaseul withaseul C1t-aeHBCef post 2024-01-05 12:20:34 https://www.instagram.com/p/C1t-aeHBCef/ 2024-01-05_withaseul - C1t-aeHBCef.json
41 haseul withaseul withaseul C1yrgmMhsmz post 2024-01-07 08:11:35 https://www.instagram.com/p/C1yrgmMhsmz/ 2024-01-07_withaseul - C1yrgmMhsmz.json
42 haseul withaseul withaseul C1yrxqIhGBZ post 2024-01-07 08:13:54 https://www.instagram.com/p/C1yrxqIhGBZ/ 2024-01-07_withaseul - C1yrxqIhGBZ.json
43 haseul withaseul withaseul C10p70xh-6x post 2024-01-08 02:36:18 https://www.instagram.com/p/C10p70xh-6x/ 2024-01-07_withaseul - C10p70xh-6x.json
44 haseul withaseul withaseul C2APujnBSF- post 2024-01-12 14:38:11 https://www.instagram.com/p/C2APujnBSF-/ 2024-01-12_withaseul - C2APujnBSF-.json
45 haseul withaseul withaseul C3enxknhzeF post 2024-02-18 06:16:55 https://www.instagram.com/p/C3enxknhzeF/ 2024-02-18_withaseul - C3enxknhzeF.json
46 haseul withaseul withaseul C4f8UQIBZAe post 2024-03-14 15:07:03 https://www.instagram.com/p/C4f8UQIBZAe/ 2024-03-14_withaseul - C4f8UQIBZAe.json
47 haseul withaseul withaseul C4qIrJTh3po post 2024-03-18 14:07:26 https://www.instagram.com/p/C4qIrJTh3po/ 2024-03-18_withaseul - C4qIrJTh3po.json
48 haseul withaseul withaseul C4rjDNhhJu8 post 2024-03-19 03:17:09 https://www.instagram.com/p/C4rjDNhhJu8/ 2024-03-18_withaseul - C4rjDNhhJu8.json
49 haseul withaseul withaseul C4xSiGGhP40 post 2024-03-21 08:48:16 https://www.instagram.com/p/C4xSiGGhP40/ 2024-03-21_withaseul - C4xSiGGhP40.json
50 haseul withaseul withaseul C5GBz4FB-kE post 2024-03-29 10:06:12 https://www.instagram.com/p/C5GBz4FB-kE/ 2024-03-29_withaseul - C5GBz4FB-kE.json
51 haseul withaseul withaseul C5GJmlABYug post 2024-03-29 11:14:17 https://www.instagram.com/p/C5GJmlABYug/ 2024-03-29_withaseul - C5GJmlABYug.json
52 haseul withaseul withaseul C5LgFFtB8ri post 2024-03-31 13:06:54 https://www.instagram.com/p/C5LgFFtB8ri/ 2024-03-31_withaseul - C5LgFFtB8ri.json
53 haseul withaseul withaseul C5VZGPDB-Qo post 2024-04-04 09:18:17 https://www.instagram.com/p/C5VZGPDB-Qo/ 2024-04-04_withaseul - C5VZGPDB-Qo.json
54 haseul withaseul withaseul - reels C5bWrT3BaGy post 2024-04-06 16:52:50 https://www.instagram.com/p/C5bWrT3BaGy/ 2024-04-06_withaseul - C5bWrT3BaGy.json
55 haseul withaseul withaseul C6LAH8vBs_a post 2024-04-25 04:59:04 https://www.instagram.com/p/C6LAH8vBs_a/ 2024-04-25_withaseul - C6LAH8vBs_a.json
56 haseul withaseul withaseul C6ypIzVBmiL post 2024-05-10 14:27:49 https://www.instagram.com/p/C6ypIzVBmiL/ 2024-05-10_withaseul - C6ypIzVBmiL.json
57 haseul withaseul withaseul C68cTpzhUzQ post 2024-05-14 09:48:07 https://www.instagram.com/p/C68cTpzhUzQ/ 2024-05-14_withaseul - C68cTpzhUzQ.json
58 haseul withaseul withaseul C68oiiahwZd post 2024-05-14 11:35:00 https://www.instagram.com/p/C68oiiahwZd/ 2024-05-14_withaseul - C68oiiahwZd.json
59 haseul withaseul withaseul C7Oh6mYB7oF post 2024-05-21 10:23:27 https://www.instagram.com/p/C7Oh6mYB7oF/ 2024-05-21_withaseul - C7Oh6mYB7oF.json
60 haseul withaseul withaseul - reels C7RnCv6BZT3 post 2024-05-22 15:06:48 https://www.instagram.com/p/C7RnCv6BZT3/ 2024-05-22_withaseul - C7RnCv6BZT3.json
61 haseul withaseul withaseul C7R7owoBd4n post 2024-05-22 18:05:56 https://www.instagram.com/p/C7R7owoBd4n/ 2024-05-22_withaseul - C7R7owoBd4n.json
62 haseul withaseul withaseul C7mPzLRhCRz post 2024-05-30 15:26:55 https://www.instagram.com/p/C7mPzLRhCRz/ 2024-05-30_withaseul - C7mPzLRhCRz.json
63 haseul withaseul withaseul C7tNIlPh7NK post 2024-06-02 08:18:19 https://www.instagram.com/p/C7tNIlPh7NK/ 2024-06-02_withaseul - C7tNIlPh7NK.json
64 haseul withaseul withaseul C70yRTmBgng post 2024-06-05 06:57:30 https://www.instagram.com/p/C70yRTmBgng/ 2024-06-05_withaseul - C70yRTmBgng.json
65 haseul withaseul withaseul C71aPMWBfqs post 2024-06-05 12:46:44 https://www.instagram.com/p/C71aPMWBfqs/ 2024-06-05_withaseul - C71aPMWBfqs.json
66 haseul withaseul withaseul C8NBuqRhCJv post 2024-06-14 16:54:21 https://www.instagram.com/p/C8NBuqRhCJv/ 2024-06-14_withaseul - C8NBuqRhCJv.json
67 haseul withaseul withaseul C8PPpUKhyLf post 2024-06-15 13:34:26 https://www.instagram.com/p/C8PPpUKhyLf/ 2024-06-15_withaseul - C8PPpUKhyLf.json
68 haseul withaseul withaseul C8UjYG8B74s post 2024-06-17 15:03:03 https://www.instagram.com/p/C8UjYG8B74s/ 2024-06-17_withaseul - C8UjYG8B74s.json
69 haseul withaseul withaseul C87PlKyBTU6 post 2024-07-02 15:40:27 https://www.instagram.com/p/C87PlKyBTU6/ 2024-07-02_withaseul - C87PlKyBTU6.json
70 haseul withaseul withaseul C9FqomJhky4 post 2024-07-06 16:49:16 https://www.instagram.com/p/C9FqomJhky4/ 2024-07-06_withaseul - C9FqomJhky4.json
71 haseul withaseul withaseul C9ICMZ5BUfr post 2024-07-07 14:53:36 https://www.instagram.com/p/C9ICMZ5BUfr/ 2024-07-07_withaseul - C9ICMZ5BUfr.json
72 haseul withaseul withaseul C9V8bjJBPdk post 2024-07-13 00:32:37 https://www.instagram.com/p/C9V8bjJBPdk/ 2024-07-12_withaseul - C9V8bjJBPdk.json
73 haseul withaseul withaseul C9xK8b1SZAY post 2024-07-23 14:18:56 https://www.instagram.com/p/C9xK8b1SZAY/ 2024-07-23_withaseul - C9xK8b1SZAY.json
74 haseul withaseul withaseul C-DLjJ6BE0y post 2024-07-30 14:10:33 https://www.instagram.com/p/C-DLjJ6BE0y/ 2024-07-30_withaseul - C-DLjJ6BE0y.json
75 haseul withaseul withaseul C-Ncp1MyhiD post 2024-08-03 13:52:25 https://www.instagram.com/p/C-Ncp1MyhiD/ 2024-08-03_withaseul - C-Ncp1MyhiD.json
76 haseul withaseul withaseul C_A4QkApuLO post 2024-08-23 13:14:54 https://www.instagram.com/p/C_A4QkApuLO/ 2024-08-23_withaseul - C_A4QkApuLO.json
77 haseul withaseul withaseul C_CcGblt8X9 post 2024-08-24 03:47:20 https://www.instagram.com/p/C_CcGblt8X9/ 2024-08-23_withaseul - C_CcGblt8X9.json
78 haseul withaseul withaseul C_ZCyXPylSu post 2024-09-01 22:28:40 https://www.instagram.com/p/C_ZCyXPylSu/ 2024-09-01_withaseul - C_ZCyXPylSu.json
79 haseul withaseul withaseul C_gRp4gPuY3 post 2024-09-04 17:53:16 https://www.instagram.com/p/C_gRp4gPuY3/ 2024-09-04_withaseul - C_gRp4gPuY3.json
80 haseul withaseul withaseul C_7XyvJMmm7 post 2024-09-15 06:26:24 https://www.instagram.com/p/C_7XyvJMmm7/ 2024-09-15_withaseul - C_7XyvJMmm7.json
81 haseul withaseul withaseul DACAVOGyOEc post 2024-09-17 20:16:04 https://www.instagram.com/p/DACAVOGyOEc/ 2024-09-17_withaseul - DACAVOGyOEc.json
82 haseul withaseul withaseul DAqwdZIBjY5 post 2024-10-03 16:06:14 https://www.instagram.com/p/DAqwdZIBjY5/ 2024-10-03_withaseul - DAqwdZIBjY5.json
83 haseul withaseul withaseul DAv8DxvyGbr post 2024-10-05 16:23:48 https://www.instagram.com/p/DAv8DxvyGbr/ 2024-10-05_withaseul - DAv8DxvyGbr.json
84 haseul withaseul withaseul DBhdsL1NIvC post 2024-10-24 22:00:28 https://www.instagram.com/p/DBhdsL1NIvC/ 2024-10-24_withaseul - DBhdsL1NIvC.json
85 haseul withaseul withaseul DBrP-pYSDEn post 2024-10-28 17:13:03 https://www.instagram.com/p/DBrP-pYSDEn/ 2024-10-28_withaseul - DBrP-pYSDEn.json
86 haseul withaseul withaseul DBu5zCHBgrT post 2024-10-30 03:16:12 https://www.instagram.com/p/DBu5zCHBgrT/ 2024-10-29_withaseul - DBu5zCHBgrT.json
87 haseul withaseul withaseul DByVUvOBSQy post 2024-10-31 11:14:27 https://www.instagram.com/p/DByVUvOBSQy/ 2024-10-31_withaseul - DByVUvOBSQy.json
88 haseul withaseul withaseul DB5re5DhW8a post 2024-11-03 07:42:45 https://www.instagram.com/p/DB5re5DhW8a/ 2024-11-03_withaseul - DB5re5DhW8a.json
89 haseul withaseul withaseul DCBsfwGhFYd post 2024-11-06 10:25:32 https://www.instagram.com/p/DCBsfwGhFYd/ 2024-11-06_withaseul - DCBsfwGhFYd.json
90 haseul withaseul withaseul DCObB0SR64m post 2024-11-11 09:03:02 https://www.instagram.com/p/DCObB0SR64m/ 2024-11-11_withaseul - DCObB0SR64m.json
91 haseul withaseul withaseul DCRhOPphLWU post 2024-11-12 13:54:52 https://www.instagram.com/p/DCRhOPphLWU/ 2024-11-12_withaseul - DCRhOPphLWU.json
92 haseul withaseul withaseul DCbgyVghiT- post 2024-11-16 11:03:28 https://www.instagram.com/p/DCbgyVghiT-/ 2024-11-16_withaseul - DCbgyVghiT-.json
93 haseul withaseul withaseul DCe4qpXhgBV post 2024-11-17 18:29:51 https://www.instagram.com/p/DCe4qpXhgBV/ 2024-11-17_withaseul - DCe4qpXhgBV.json
94 haseul withaseul withaseul DC_4KddBmo_ post 2024-11-30 14:00:24 https://www.instagram.com/p/DC_4KddBmo_/ 2024-11-30_withaseul - DC_4KddBmo_.json
95 haseul withaseul withaseul DDRNi9Bhicn post 2024-12-07 07:34:20 https://www.instagram.com/p/DDRNi9Bhicn/ 2024-12-07_withaseul - DDRNi9Bhicn.json
96 haseul withaseul withaseul DDW2JdFBUMs post 2024-12-09 12:05:19 https://www.instagram.com/p/DDW2JdFBUMs/ 2024-12-09_withaseul - DDW2JdFBUMs.json
97 haseul withaseul withaseul DDjWETEBWw2 post 2024-12-14 08:35:07 https://www.instagram.com/p/DDjWETEBWw2/ 2024-12-14_withaseul - DDjWETEBWw2.json
98 haseul withaseul withaseul DDpkLTyhUdt post 2024-12-16 18:33:51 https://www.instagram.com/p/DDpkLTyhUdt/ 2024-12-16_withaseul - DDpkLTyhUdt.json
99 haseul withaseul withaseul DE1NVFihF2g post 2025-01-15 03:36:30 https://www.instagram.com/p/DE1NVFihF2g/ 2025-01-14_withaseul - DE1NVFihF2g.json
100 haseul withaseul withaseul DE5GO2-S5PB post 2025-01-16 15:51:26 https://www.instagram.com/p/DE5GO2-S5PB/ 2025-01-16_withaseul - DE5GO2-S5PB.json
101 haseul withaseul withaseul - reels DE5H7LcB5aX post 2025-01-16 16:07:31 https://www.instagram.com/p/DE5H7LcB5aX/ 2025-01-16_withaseul - DE5H7LcB5aX.json
102 haseul withaseul withaseul DFFtju9hTdv post 2025-01-21 13:25:58 https://www.instagram.com/p/DFFtju9hTdv/ 2025-01-21_withaseul - DFFtju9hTdv.json
103 haseul withaseul withaseul DFsLxphhwqE post 2025-02-05 12:01:09 https://www.instagram.com/p/DFsLxphhwqE/ 2025-02-05_withaseul - DFsLxphhwqE.json
104 haseul withaseul withaseul DHvlOgAxE_j post 2025-03-28 12:44:03 https://www.instagram.com/p/DHvlOgAxE_j/ 2025-03-28_withaseul - DHvlOgAxE_j.json
105 haseul withaseul withaseul DH96B0QtxfT post 2025-04-03 02:15:11 https://www.instagram.com/p/DH96B0QtxfT/ 2025-04-02_withaseul - DH96B0QtxfT.json
106 haseul withaseul withaseul DIL6wNXRGDC post 2025-04-08 12:50:53 https://www.instagram.com/p/DIL6wNXRGDC/ 2025-04-08_withaseul - DIL6wNXRGDC.json
107 haseul withaseul withaseul DIP1mift6QV post 2025-04-10 01:22:50 https://www.instagram.com/p/DIP1mift6QV/ 2025-04-09_withaseul - DIP1mift6QV.json
108 haseul withaseul withaseul DIfvDIKxwuT post 2025-04-16 05:33:25 https://www.instagram.com/p/DIfvDIKxwuT/ 2025-04-16_withaseul - DIfvDIKxwuT.json
109 haseul withaseul withaseul DIm-MshvCJy post 2025-04-19 01:00:03 https://www.instagram.com/p/DIm-MshvCJy/ 2025-04-18_withaseul - DIm-MshvCJy.json
110 haseul withaseul withaseul DIuQyfphWnR post 2025-04-21 20:57:37 https://www.instagram.com/p/DIuQyfphWnR/ 2025-04-21_withaseul - DIuQyfphWnR.json
111 haseul withaseul withaseul DI3hOzrBLs- post 2025-04-25 11:14:27 https://www.instagram.com/p/DI3hOzrBLs-/ 2025-04-25_withaseul - DI3hOzrBLs-.json
112 haseul withaseul withaseul DK0lklBhrH6 post 2025-06-13 00:57:27 https://www.instagram.com/p/DK0lklBhrH6/ 2025-06-12_withaseul - DK0lklBhrH6.json
113 haseul withaseul withaseul DK1EdUZBJtr post 2025-06-13 05:27:20 https://www.instagram.com/p/DK1EdUZBJtr/ 2025-06-13_withaseul - DK1EdUZBJtr.json
114 haseul withaseul withaseul DLJiMzrhM83 post 2025-06-21 04:12:02 https://www.instagram.com/p/DLJiMzrhM83/ 2025-06-21_withaseul - DLJiMzrhM83.json
115 haseul withaseul withaseul DLL1iePBcwm post 2025-06-22 01:39:30 https://www.instagram.com/p/DLL1iePBcwm/ 2025-06-21_withaseul - DLL1iePBcwm.json
116 haseul withaseul withaseul DLM-sUIhK5P post 2025-06-22 12:18:44 https://www.instagram.com/p/DLM-sUIhK5P/ 2025-06-22_withaseul - DLM-sUIhK5P.json
117 haseul withaseul withaseul DLU66yCvWZh post 2025-06-25 14:19:41 https://www.instagram.com/p/DLU66yCvWZh/ 2025-06-25_withaseul - DLU66yCvWZh.json
118 haseul withaseul withaseul DLxAZg5hpnW post 2025-07-06 12:06:18 https://www.instagram.com/p/DLxAZg5hpnW/ 2025-07-06_withaseul - DLxAZg5hpnW.json
119 haseul withaseul withaseul DMFfDqABJgM post 2025-07-14 10:59:00 https://www.instagram.com/p/DMFfDqABJgM/ 2025-07-14_withaseul - DMFfDqABJgM.json
120 haseul withaseul withaseul DM0NH9yhkzT post 2025-08-01 14:26:37 https://www.instagram.com/p/DM0NH9yhkzT/ 2025-08-01_withaseul - DM0NH9yhkzT.json
121 haseul withaseul withaseul DM9_Ol7SzmK post 2025-08-05 09:37:35 https://www.instagram.com/p/DM9_Ol7SzmK/ 2025-08-05_withaseul - DM9_Ol7SzmK.json
122 haseul withaseul withaseul DNnih4pBd_M post 2025-08-21 12:54:55 https://www.instagram.com/p/DNnih4pBd_M/ 2025-08-21_withaseul - DNnih4pBd_M.json
123 haseul withaseul withaseul DOIxptrgXgv post 2025-09-03 10:42:00 https://www.instagram.com/p/DOIxptrgXgv/ 2025-09-03_withaseul - DOIxptrgXgv.json
124 haseul withaseul withaseul DOa8a_ugc2Z post 2025-09-10 12:02:26 https://www.instagram.com/p/DOa8a_ugc2Z/ 2025-09-10_withaseul - DOa8a_ugc2Z.json
125 haseul withaseul withaseul DPMLxsmgUJJ post 2025-09-29 14:59:24 https://www.instagram.com/p/DPMLxsmgUJJ/ 2025-09-29_withaseul - DPMLxsmgUJJ.json
126 haseul withaseul withaseul DPRez1QgUxE post 2025-10-01 16:21:55 https://www.instagram.com/p/DPRez1QgUxE/ 2025-10-01_withaseul - DPRez1QgUxE.json
127 haseul withaseul withaseul DQg9WOOgaKD post 2025-11-01 13:08:45 https://www.instagram.com/p/DQg9WOOgaKD/ 2025-11-01_withaseul - DQg9WOOgaKD.json
128 haseul withaseul withaseul DQ_JrMdDciC post 2025-11-13 06:33:42 https://www.instagram.com/p/DQ_JrMdDciC/ 2025-11-13_withaseul - DQ_JrMdDciC.json
129 haseul withaseul withaseul DROhLjLjVqY post 2025-11-19 05:47:42 https://www.instagram.com/p/DROhLjLjVqY/ 2025-11-19_withaseul - DROhLjLjVqY.json
130 haseul withaseul withaseul DRwiTEDDRHR post 2025-12-02 10:51:38 https://www.instagram.com/p/DRwiTEDDRHR/ 2025-12-02_withaseul - DRwiTEDDRHR.json
131 haseul withaseul withaseul DTw-4d9gRUx post 2026-01-21 08:04:12 https://www.instagram.com/p/DTw-4d9gRUx/ 2026-01-21_withaseul - DTw-4d9gRUx.json
132 haseul withaseul withaseul DT0rHDCjPpE post 2026-01-22 18:28:24 https://www.instagram.com/p/DT0rHDCjPpE/ 2026-01-22_withaseul - DT0rHDCjPpE.json
133 haseul withaseul withaseul DT6HqajDJfF post 2026-01-24 21:14:05 https://www.instagram.com/p/DT6HqajDJfF/ 2026-01-24_withaseul - DT6HqajDJfF.json
134 haseul withaseul withaseul DU0oU25gWso post 2026-02-16 14:19:54 https://www.instagram.com/p/DU0oU25gWso/ 2026-02-16_withaseul - DU0oU25gWso.json
135 haseul withaseul withaseul DWZPNAKAZcl post 2026-03-27 16:19:14 https://www.instagram.com/p/DWZPNAKAZcl/ 2026-03-27_withaseul - DWZPNAKAZcl.json
136 haseul withaseul withaseul DW3yBm2gctK post 2026-04-08 13:00:00 https://www.instagram.com/p/DW3yBm2gctK/ 2026-04-08_withaseul - DW3yBm2gctK.json
137 haseul withaseul withaseul DZj6uVWhV8M post 2026-06-14 09:26:10 https://www.instagram.com/p/DZj6uVWhV8M/ 2026-06-14_withaseul - DZj6uVWhV8M.json
138 haseul withaseul withaseul DaZm_xLAZeL post 2026-07-05 05:52:43 https://www.instagram.com/p/DaZm_xLAZeL/ 2026-07-05_withaseul - DaZm_xLAZeL.json
139 haseul withaseul withaseul Dbnq4k9gb5U post 2026-08-04 13:27:27 https://www.instagram.com/p/Dbnq4k9gb5U/ 2026-08-04_withaseul - Dbnq4k9gb5U.json
140 heejin 0ct0ber19 0ct0ber19 CrdsY5CrSsO post 2023-04-25 15:21:16 https://www.instagram.com/p/CrdsY5CrSsO/ 2023-04-25_0ct0ber19 - CrdsY5CrSsO.json
141 heejin 0ct0ber19 0ct0ber19 CtohvHxLnWO post 2023-06-18 13:22:37 https://www.instagram.com/p/CtohvHxLnWO/ 2023-06-18_0ct0ber19 - CtohvHxLnWO.json
142 heejin 0ct0ber19 0ct0ber19 CuEOMWppp5S post 2023-06-29 07:30:35 https://www.instagram.com/p/CuEOMWppp5S/ 2023-06-29_0ct0ber19 - CuEOMWppp5S.json
143 heejin 0ct0ber19 0ct0ber19 CwC0Y-prGoB post 2023-08-17 11:28:40 https://www.instagram.com/p/CwC0Y-prGoB/ 2023-08-17_0ct0ber19 - CwC0Y-prGoB.json
144 heejin 0ct0ber19 0ct0ber19 CxsKPWgpzU4 post 2023-09-27 09:19:51 https://www.instagram.com/p/CxsKPWgpzU4/ 2023-09-27_0ct0ber19 - CxsKPWgpzU4.json
145 heejin 0ct0ber19 0ct0ber19 CzJf78xryub post 2023-11-02 15:18:48 https://www.instagram.com/p/CzJf78xryub/ 2023-11-02_0ct0ber19 - CzJf78xryub.json
146 heejin 0ct0ber19 0ct0ber19 CzOVVzFLTnD post 2023-11-04 12:22:25 https://www.instagram.com/p/CzOVVzFLTnD/ 2023-11-04_0ct0ber19 - CzOVVzFLTnD.json
147 heejin 0ct0ber19 0ct0ber19 C14VKVmpdtC post 2024-01-09 12:51:44 https://www.instagram.com/p/C14VKVmpdtC/ 2024-01-09_0ct0ber19 - C14VKVmpdtC.json
148 heejin 0ct0ber19 0ct0ber19 C2FnIelpue9 post 2024-01-14 16:38:54 https://www.instagram.com/p/C2FnIelpue9/ 2024-01-14_0ct0ber19 - C2FnIelpue9.json
149 heejin 0ct0ber19 0ct0ber19 C5SDY_UJIn6 post 2024-04-03 02:10:53 https://www.instagram.com/p/C5SDY_UJIn6/ 2024-04-02_0ct0ber19 - C5SDY_UJIn6.json
150 heejin 0ct0ber19 0ct0ber19 C5WB6zwp_rX post 2024-04-04 15:15:00 https://www.instagram.com/p/C5WB6zwp_rX/ 2024-04-04_0ct0ber19 - C5WB6zwp_rX.json
151 heejin 0ct0ber19 0ct0ber19 C5dJ0xXpJpX post 2024-04-07 09:38:46 https://www.instagram.com/p/C5dJ0xXpJpX/ 2024-04-07_0ct0ber19 - C5dJ0xXpJpX.json
152 heejin 0ct0ber19 0ct0ber19 C5oJdT-pQBI post 2024-04-11 16:07:12 https://www.instagram.com/p/C5oJdT-pQBI/ 2024-04-11_0ct0ber19 - C5oJdT-pQBI.json
153 heejin 0ct0ber19 0ct0ber19 C53YPQzp7Wj post 2024-04-17 14:04:58 https://www.instagram.com/p/C53YPQzp7Wj/ 2024-04-17_0ct0ber19 - C53YPQzp7Wj.json
154 heejin 0ct0ber19 0ct0ber19 C5--hdUJKZH post 2024-04-20 12:54:11 https://www.instagram.com/p/C5--hdUJKZH/ 2024-04-20_0ct0ber19 - C5--hdUJKZH.json
155 heejin 0ct0ber19 0ct0ber19 C71iAmbpekA post 2024-06-05 13:54:39 https://www.instagram.com/p/C71iAmbpekA/ 2024-06-05_0ct0ber19 - C71iAmbpekA.json
156 heejin 0ct0ber19 0ct0ber19 C8M8TxnSOKI post 2024-06-14 16:07:00 https://www.instagram.com/p/C8M8TxnSOKI/ 2024-06-14_0ct0ber19 - C8M8TxnSOKI.json
157 heejin 0ct0ber19 0ct0ber19 C8hxpuYyWGv post 2024-06-22 18:17:55 https://www.instagram.com/p/C8hxpuYyWGv/ 2024-06-22_0ct0ber19 - C8hxpuYyWGv.json
158 heejin 0ct0ber19 0ct0ber19 C-AbtWSJbHB post 2024-07-29 12:34:02 https://www.instagram.com/p/C-AbtWSJbHB/ 2024-07-29_0ct0ber19 - C-AbtWSJbHB.json
159 heejin 0ct0ber19 0ct0ber19 C_WxFtuvqXK post 2024-09-01 01:15:32 https://www.instagram.com/p/C_WxFtuvqXK/ 2024-08-31_0ct0ber19 - C_WxFtuvqXK.json
160 heejin 0ct0ber19 0ct0ber19 DcgTbz-iTAH post 2026-08-26 13:19:15 https://www.instagram.com/p/DcgTbz-iTAH/ 2026-08-26_0ct0ber19 - DcgTbz-iTAH.json
161 jinsoul zindoriyam zindoriyam CndmboMBHeB post 2023-01-16 04:23:38 https://www.instagram.com/p/CndmboMBHeB/ 2023-01-15_zindoriyam - CndmboMBHeB.json
162 jinsoul zindoriyam zindoriyam Ctb5AnZxCwh post 2023-06-13 15:35:51 https://www.instagram.com/p/Ctb5AnZxCwh/ 2023-06-13_zindoriyam - Ctb5AnZxCwh.json
163 jinsoul zindoriyam zindoriyam Cu6ZRz8hj_T post 2023-07-20 08:26:26 https://www.instagram.com/p/Cu6ZRz8hj_T/ 2023-07-20_zindoriyam - Cu6ZRz8hj_T.json
164 jinsoul zindoriyam zindoriyam Cwrydt6OXmo post 2023-09-02 09:20:42 https://www.instagram.com/p/Cwrydt6OXmo/ 2023-09-02_zindoriyam - Cwrydt6OXmo.json
165 jinsoul zindoriyam zindoriyam Cxsj3_IhwNv post 2023-09-27 13:03:51 https://www.instagram.com/p/Cxsj3_IhwNv/ 2023-09-27_zindoriyam - Cxsj3_IhwNv.json
166 jinsoul zindoriyam zindoriyam Cza0C4gBkb5 post 2023-11-09 08:41:36 https://www.instagram.com/p/Cza0C4gBkb5/ 2023-11-09_zindoriyam - Cza0C4gBkb5.json
167 jinsoul zindoriyam zindoriyam C5LqGJUBTIK post 2024-03-31 14:34:25 https://www.instagram.com/p/C5LqGJUBTIK/ 2024-03-31_zindoriyam - C5LqGJUBTIK.json
168 jinsoul zindoriyam zindoriyam C5QZg9BBc12 post 2024-04-02 10:45:44 https://www.instagram.com/p/C5QZg9BBc12/ 2024-04-02_zindoriyam - C5QZg9BBc12.json
169 jinsoul zindoriyam zindoriyam C8NAliShdHm post 2024-06-14 16:44:22 https://www.instagram.com/p/C8NAliShdHm/ 2024-06-14_zindoriyam - C8NAliShdHm.json
170 jinsoul zindoriyam zindoriyam C9-Ot__hyGY post 2024-07-28 16:02:02 https://www.instagram.com/p/C9-Ot__hyGY/ 2024-07-28_zindoriyam - C9-Ot__hyGY.json
171 jinsoul zindoriyam zindoriyam DANQU24um-N post 2024-09-22 05:07:29 https://www.instagram.com/p/DANQU24um-N/ 2024-09-22_zindoriyam - DANQU24um-N.json
172 jinsoul zindoriyam zindoriyam DHz88e2sS_J post 2025-03-30 05:28:16 https://www.instagram.com/p/DHz88e2sS_J/ 2025-03-30_zindoriyam - DHz88e2sS_J.json
173 jinsoul zindoriyam zindoriyam DJXAVrANWfp post 2025-05-07 16:42:44 https://www.instagram.com/p/DJXAVrANWfp/ 2025-05-07_zindoriyam - DJXAVrANWfp.json
174 jinsoul zindoriyam zindoriyam DLhdKkZJ2_5 post 2025-06-30 11:09:49 https://www.instagram.com/p/DLhdKkZJ2_5/ 2025-06-30_zindoriyam - DLhdKkZJ2_5.json
175 jinsoul zindoriyam zindoriyam DMiZJbaJZXv post 2025-07-25 16:25:21 https://www.instagram.com/p/DMiZJbaJZXv/ 2025-07-25_zindoriyam - DMiZJbaJZXv.json
176 jinsoul zindoriyam zindoriyam DNJCd9jpT-J post 2025-08-09 16:37:33 https://www.instagram.com/p/DNJCd9jpT-J/ 2025-08-09_zindoriyam - DNJCd9jpT-J.json
177 jinsoul zindoriyam zindoriyam DRl8jAxFnwE post 2025-11-28 08:09:22 https://www.instagram.com/p/DRl8jAxFnwE/ 2025-11-28_zindoriyam - DRl8jAxFnwE.json
178 jinsoul zindoriyam zindoriyam DTQR5cDk6kY post 2026-01-08 15:15:25 https://www.instagram.com/p/DTQR5cDk6kY/ 2026-01-08_zindoriyam - DTQR5cDk6kY.json
179 jinsoul zindoriyam zindoriyam DVv1oRSEyIy post 2026-03-11 14:26:54 https://www.instagram.com/p/DVv1oRSEyIy/ 2026-03-11_zindoriyam - DVv1oRSEyIy.json
180 jinsoul zindoriyam zindoriyam DW_6oxSkz8u post 2026-04-11 16:49:54 https://www.instagram.com/p/DW_6oxSkz8u/ 2026-04-11_zindoriyam - DW_6oxSkz8u.json
181 jinsoul zindoriyam zindoriyam DcRLAVJEwlG post 2026-08-20 16:16:59 https://www.instagram.com/p/DcRLAVJEwlG/ 2026-08-20_zindoriyam - DcRLAVJEwlG.json
182 kimlip kimxxlip kimxxlip CndmBanNyrR post 2023-01-16 04:20:04 https://www.instagram.com/p/CndmBanNyrR/ 2023-01-15_kimxxlip - CndmBanNyrR.json
183 kimlip kimxxlip kimxxlip CwXG1L3vQFH post 2023-08-25 08:34:37 https://www.instagram.com/p/CwXG1L3vQFH/ 2023-08-25_kimxxlip - CwXG1L3vQFH.json
184 kimlip kimxxlip kimxxlip CxsL_FQvHna post 2023-09-27 09:35:06 https://www.instagram.com/p/CxsL_FQvHna/ 2023-09-27_kimxxlip - CxsL_FQvHna.json
+194
View File
@@ -0,0 +1,194 @@
# JDownloader2 — archive fetching
> **This file lives only on the `tooling` branch.** `main` is published to
> GitHub and deliberately carries none of this — not the host details, not the
> IPs, and not the account names. `main`'s history was redacted on 2026-08-20;
> real names exist only here.
>
> There is no `npm run jd2` script — `package.json` and `CLAUDE.md` are kept
> byte-identical to `main` so that merging `main` into `tooling` never
> conflicts. Run the crawljob generator directly:
>
> ```sh
> npx tsx scripts/jd2-sync.ts --archives <dir> --dry-run
> ```
How content gets into this archive, and why the setup is shaped the way it is.
## Why JDownloader and not Instaloader
There are two surfaces, and they're treated very differently:
| Surface | What hits it | Risk |
|---|---|---|
| `instagram.com` | profile pages, GraphQL/API metadata | Tied to your session, heavily rate-limited. **This is where bans come from.** |
| `scontent*.cdninstagram.com` | the actual media | Signed URLs, CDN-served, tolerant. Mostly a bandwidth question. |
JDownloader does nearly all its work on the CDN. Instaloader's value — the rich
`.json.xz` metadata — comes from asking `instagram.com` a question *per post*.
Concretely, from this archive: `rivvsofficial` has 188 post-metadata files, so
backfilling it cost 188 API requests for one 605-file profile. That's the ban
vector. Downloading the 238 photos was never the problem.
Instaloader got this account banned once. JDownloader with throttling did not.
> **The account was suspended anyway, on 2026-08-17, for "spam".** Not by
> JDownloader, and not by downloading. It was suspended during a day of
> *building and verifying* the gallery-dl replacement — automated browser
> scrolling to enumerate profile grids, repeated `--simulate` and `-j` metadata
> passes, and one aborted sync that re-ran every listing pass before dying.
>
> The framing above is right about which surface is dangerous and wrong about
> what reaches it. **Every read of `instagram.com` counts, including the ones
> that download nothing** — and read-only work is easy not to count precisely
> because it leaves no files behind. See the post-mortem at the top of
> `docs/gallery-dl.md`.
>
> The rule that would have prevented it: *verify against the archive, never
> against the live site*, and treat the first CDN `429` as the end of the
> session rather than a pacing knob.
### What the metadata gap actually costs
Comparing a JDownloader profile against an Instaloader one:
| | JDownloader | Instaloader |
|---|---|---|
| Media | ✅ | ✅ |
| Captions (`.txt`) | ✅ | ✅ |
| Dates (from filenames) | ✅ | ✅ |
| Bio / full name | ❌ | ✅ |
| Follower counts | ❌ | ✅ |
| External URL | ❌ | ✅ |
Captions already work — the viewer reads the `.txt` sidecars. Everything missing
lives in a *single* profile-level record, not the per-post ones. That's why
JDownloader-sourced profiles show "0 followers" and a placeholder bio.
Not worth extra requests. If you ever want it, the zero-request option is a
hand-written `profile.json` sidecar (not implemented yet — ask).
## Settings that matter
**Chunks per download → 1.** The single most important one. JDownloader splits
each file into multiple ranged requests by default; that `Range` pattern looks
nothing like a browser or the app. One chunk = one sequential GET per file.
`jd2-sync` sets `chunks=1` per job, so no global change is needed — but set it
globally too if you ever add links by hand.
**Max simultaneous downloads → 2–3**, connections-per-host low. Concurrency is
what turns "a user" into a statistic.
**Leave reconnect / IP-change features off.** A mid-session IP change on a live
cookie is a *stronger* anomaly signal than the request rate you'd be avoiding.
## The cookie
Exported manually from a real browser session. This is the right approach — no
programmatic login anywhere, which is the thing that actually gets flagged.
- Use it from the **same public IP** as the browser it came from. A cookie used
from a different network is what session-hijack detection looks for.
- When it expires, **re-export from the browser**. Never add a login step to a tool.
- It's a full account credential. Keep it off the NAS share and out of the repo.
## Workflow
Two URLs per profile, because the profile grid misses some reels:
```
https://www.instagram.com/<user>/
https://www.instagram.com/<user>/reels/
```
They overlap slightly — a reel caught by both lands in each directory and shows
up twice in the viewer. That's correct and matches Instagram, which also shows
reels in the profile grid *and* the Reels tab.
## Generating jobs
Instead of pasting URLs and setting output folders by hand:
```bash
npm run jd2 -- --archives /volume1/rslsync/sync/Instagram-archive/archives --dry-run
```
Review, then write it into JDownloader's folder-watch directory:
```bash
npm run jd2 -- --archives /volume1/rslsync/sync/Instagram-archive/archives \
--out ~/.jd2/folderwatch
```
JDownloader runs on the desktop while the archive lives on the NAS, so tell it
the path *it* sees:
```bash
npm run jd2 -- --archives /mnt/nas/Instagram-archive/archives \
--download-base 'Z:\Instagram-archive\archives' \
--out ~/.jd2/folderwatch
```
| Flag | Purpose |
|---|---|
| `--archives <dir>` | Archive root to scan (or `$ARCHIVES_DIR`) |
| `--out <dir>` | JDownloader folder-watch directory |
| `--download-base <dir>` | Root path as JDownloader sees it (Windows paths fine) |
| `--user <name>` | Just this profile (repeatable) |
| `--skip <name>` | Never emit jobs for this directory (repeatable) |
| `--chunks <n>` | Connections per file (default 1) |
| `--auto-start` | Start immediately instead of parking in LinkGrabber |
| `--all-reels` | Emit a reels job even where no reels directory exists |
| `--dry-run` | Print instead of writing |
Defaults are deliberately conservative: `chunks=1`, and links park in the
LinkGrabber for review rather than auto-starting.
Only posts and reels are emitted. Highlight URLs need a numeric id and story
URLs expire, so those stay manual.
Directories that aren't Instagram profiles are skipped by username shape
(letters, digits, dots, underscores, ≤30 chars) — pointing a crawl at those
spends `instagram.com` requests to be told the profile doesn't exist. For names
that *look* like usernames but aren't, use `--skip` or a `.jd2ignore` file in
the archive root, one name per line.
Format reference: `src/org/jdownloader/extensions/folderwatchV2/explain.txt`.
JDownloader develops on SVN — read it via the daily mirror at
<https://github.com/mycodedoesnotcompile2/jdownloader_mirror> (`svn_trunk/`),
not one of the abandoned GitHub copies.
## Expected layout
Everything downloads into `<archives>/`, one directory per source:
```
archives/
0ct0ber19/ posts
0ct0ber19 - reels/ reels
story - 0ct0ber19/ stories
story highlights - 0ct0ber19 - Heestory/ a highlight
```
Non-archive directories (tool output, exports from elsewhere) live *outside*
`archives/` so they never reach the viewer.
The server picks up changes automatically — its index is keyed on directory
mtime, so a new file invalidates only that directory.
## If something goes wrong
**429 / rate limited** — stop for hours, not seconds. Retrying into a limit is
what converts a soft throttle into something worse.
**Cookie stops working** — re-export from the browser. Don't add a login step.
**Files land in the wrong folder** — a Packagizer rule is overriding the job.
Generated jobs set `overwritePackagizerEnabled=TRUE` to prevent this; check that
rules aren't set to run after it.
**Viewer doesn't show new posts** — check the file is in the right directory and
matches the naming pattern (`YYYY-MM-DD_<user> - <shortcode>[ - NN].<ext>`).
The index refreshes on directory mtime, so a genuinely new file is picked up on
the next request.
+85
View File
@@ -0,0 +1,85 @@
# Video upgrade pass — plan and findings (2026-10-14)
**Status: designed and partly measured, NOT built. Nothing in the archive has been changed.**
Written so this can be picked up cold. Account context: the scraping account is the
fresh `erg05teur` (see `docs/gallery-dl.md`, top of "The account was suspended…") — every
request in this plan is spent on a new account, so the budget rules there apply in full.
## Why this exists
The question was whether a logged-out fetch had given us lower-resolution media and whether a
logged-in run could upgrade it. Measured result:
- **Images: no upgrade needed.** Header-scan of 38.9k archived images, plus live metadata for 8
posts across 2023–2026 (including the oddly small ones): archived sizes match exactly what a
logged-in fetch offers (1440×1800, 3277×4096, 2976×3720; 400×400 / 742×928 items are small at
the source, sitting next to 1440 px siblings in the same carousel).
- **Logged-out gdl does not download smaller files — it fails** (login redirect). No logged-out
media ever entered the archive.
- **Videos: this is where there is something to gain.** Of ~1,300 archived videos in the six scraped
profiles, ~1,200 are 720p (min side ≤ 720) and ~100 (2026 reels) are 1080p. A 2026 reel
(`DTpycSlDKHs`, archived 720×1280) offers **1080×1920 VP9 video-only DASH + separate AAC audio**.
A 2024 post (`C7RmlCcSWNR`) tops out at 1276×720 — same as the archive — so older videos
probably gain nothing. Expected beneficiaries: **2025–26 videos, roughly 700 posts**. Not
verified how far back 1080p exists.
## Decisions already made (by the owner)
1. **Videos in a separate pass** from anything else (images need none).
2. **Keep the replaced files for review** in a `_superseded/` area — never overwrite/delete.
3. **Output `.webm`, not `.mp4`** (video stream copied, audio re-encoded AAC → Opus 96 kbps; the
audio stream is ~43 kbps HE-AAC so the loss is negligible). **Tested on real hardware:**
iPhone, iOS 18.7 Safari plays the VP9+Opus `.webm` at 1080×1920 and **fails the VP9+AAC `.mp4`**
(`MediaError` code 4, no video track) — even though `canPlayType` reports "probably" for it, so
never trust `canPlayType` here. VP9-in-MP4 (the lossless remux) is therefore out.
Re-encoding VP9 → H.264 was rejected (bigger, softer, no gain over the 720p original).
## How the sample was produced (reproducible, one post)
```sh
# portrait video: 1080 is the WIDTH — `height=1080` selects nothing and fails "format not available"
yt-dlp --cookies-from-browser chrome:/home/matt/.config/google-chrome-devtools \
-f "bv*[width>=1080]+ba" --merge-output-format mp4 -o 'X.%(ext)s' https://www.instagram.com/p/<shortcode>/
ffmpeg -i X.mp4 -c:v copy -c:a libopus -b:a 96k X.webm # ffmpeg 7.1.5 on mattellite
```
Sizes for that post: 6.46 MB `.mp4` / 6.53 MB `.webm`. For a browser test you need HTTP **Range**
support (Python's `http.server` has none; Safari requires it) — the throwaway server used is not kept.
`gallery-dl` needs `$HOME/.local/bin` on PATH over plain ssh.
## Open decisions / work before building
| item | notes |
|---|---|
| **Viewer MIME type** | `src/lib/archive-cache.ts:127` and `src/hooks/useArchiveScanner.ts:272,344,391` mint every video blob URL as `video/mp4`. Derive the type from the extension (`.webm` → `video/webm`) or Safari/iOS may refuse a WebM labelled mp4. `MEDIA_RE` / `isVideo` already recognise `.webm`. |
| **Filename / dedup** | The archive names `YYYY-MM-DD_<user> - <shortcode>[ - <n>].mp4`. Swapping to `.webm` must not leave **both** in `archives/` (the viewer would show duplicates) — the old `.mp4` has to be *moved out*, not just joined by a `.webm`. Check `parseArchiveFilename` (`src/lib/archive-patterns.ts`) for the extension handling and the JD2-vs-gdl same-stem duplicate logic. |
| **Skip-archive** | `artms.db` keys on `instagram<media_id>`, so a normal run will never re-fetch these. The upgrade tool must fetch targeted items deliberately and must not touch/poison `artms.db` semantics. |
| **`_superseded/` location** | Must be **outside** `/volume1/rslsync/sync/Instagram-archive/archives/` — that tree is scanned by the viewer **and** inside the Resilio-synced share (it would sync every replaced file to devices). Not decided. Also unchecked: whether `agentapi` can write outside that share. Use rsync `--backup --backup-dir=<dir>/<date>/` so the old copy is kept. |
| **Carousel videos** | yt-dlp on a carousel post URL fails ("No video formats found") — see `DIa5NpWRnIP`. gdl's own metadata gives per-child `ytdl:` URLs for videos in carousels; the tool needs to handle child items, not post URLs. |
| **Request budget** | One `yt-dlp -F`-style request per post to learn what 1080p exists (plus the download). ~700 candidates ⇒ suggested **≤ 40 posts/day**, resumable, behind `check-session.py`, hand-started or a slow timer — never a burst. Candidates can be pre-ranked from the archive alone (720p, 2025–26) at zero request cost. |
| **Storage** | 1080p VP9 ≈ 1.6× the 720p file (6 MB vs ~4 MB for that sample, plus the kept original). Estimate before running at scale. |
## Planned design
1. **Candidate list, zero requests:** scan the NAS archive (read-only, ssh `agentapi@<nas>` from
mattellite; the scans in this doc used header parsing in plain Python over stdin because the
NAS has no ffprobe) for videos ≤ 720 min-side from 2025-01 onward, newest first.
2. **Dry-run mode (default):** for each candidate, one request to learn the best offered stream,
record `{shortcode, archived WxH, offered WxH}` in a state file; print counts + estimated GB.
Downloads nothing.
3. **Execute mode:** only where offered width > archived width; download video+audio, `ffmpeg
-c:v copy -c:a libopus -b:a 96k`, verify with `ffprobe` (dimensions, duration within 0.5 s of
the original, both streams present), publish the `.webm`, move the old file to `_superseded/`.
Cap per run (`--max-posts`), pace with the same 12–20 s gaps, stop on any 429 / login redirect.
4. **First step when resuming:** do ONE reel end to end by hand (`DTpycSlDKHs`, ~3 requests) to prove
download → remux → verify → publish → supersede → viewer playback, *then* the dry-run, and only after
the weekly profiles timer has run cleanly on the new account (first fire ≈ 2026-10-23).
## Things measured along the way (for reference)
- Image header scan by year (six scraped profiles): ≥ 1440 px for the vast majority; ~75 images
< 1000 px, all checked samples genuinely small at the source. `official_artms` 2023–24 has a
cluster < 1440 px (~1080-class originals) — not probed.
- Video min-side classes (six profiles): 2023 ≤720: 304, 2024 ≤720: 248, 2025 ≤720: 368 (+1 at
1080), 2026 ≤720: 302 and ≤1080: 100 (+1 at 1440×2560). A handful ≤540.
- The scan tools (read-only, stdlib only, run on the NAS user over ssh stdin) were throwaway
and are not in the repo; recreate from this description if needed.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"version": "1.8.0", "version": "1.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"version": "1.8.0", "version": "1.8.1",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
+3 -4
View File
@@ -1,19 +1,18 @@
{ {
"name": "instaarchive-viewer", "name": "instaarchive-viewer",
"private": true, "private": true,
"version": "1.8.0", "version": "1.8.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build && npm run build:server", "build": "vite build && npm run build:server",
"build:server": "tsc server.ts --esModuleInterop --module ESNext --target ES2022 --moduleResolution bundler --outDir dist-server", "build:server": "tsc server.ts --esModuleInterop --module ESNext --target ES2022 --moduleResolution bundler --removeComments --outDir dist-server",
"preview": "vite preview", "preview": "vite preview",
"server": "tsx server.ts", "server": "tsx server.ts",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest"
"jd2": "tsx scripts/jd2-sync.ts"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
Preflight for gdl-cron.sh: is there a usable Instagram login in the cookie jar gdl reads?
gdl-sync.py runs gallery-dl with `--cookies-from-browser chrome:<profile>`, so what matters
is not what the interactive Chrome window LOOKS like (an open tab keeps showing its logged-in
UI long after the session died -- found 2026-10-14, stories had been empty for a week) but
whether the profile's cookie DB holds a `sessionid`. This reads the same DB, the same way.
Never prints cookie values -- only the name, expiry, and a verdict.
Exit 0 logged in (a non-expired sessionid exists)
Exit 1 logged out / unreadable -- the caller must not scrape
"""
import datetime as dt
import os
import shutil
import sqlite3
import sys
import tempfile
import time
from pathlib import Path
PROFILE = Path(os.environ.get("GDL_COOKIE_PROFILE",
str(Path.home() / ".config/google-chrome-devtools")))
WARN_DAYS = 7
def main() -> int:
src = PROFILE / "Default" / "Cookies"
if not src.exists():
print(f"no cookie DB at {src} -- Chrome profile missing?")
return 1
with tempfile.TemporaryDirectory() as tmp:
# Copy first: Chrome holds the live DB open, and a copy keeps this read-only.
for suffix in ("", "-journal", "-wal", "-shm"):
f = Path(str(src) + suffix)
if f.exists():
shutil.copy2(f, Path(tmp) / f.name)
try:
con = sqlite3.connect(str(Path(tmp) / "Cookies"))
rows = con.execute(
"SELECT expires_utc, has_expires FROM cookies "
"WHERE host_key LIKE '%instagram.com' AND name = 'sessionid'").fetchall()
except sqlite3.Error as exc:
print(f"cannot read cookie DB: {exc}")
return 1
if not rows:
print("NOT LOGGED IN: no Instagram sessionid in the Chrome profile "
f"{PROFILE}. Log in to Instagram in that Chrome window (the open tab can "
"look logged in while stale -- reload it), then re-run.")
return 1
expires, has_expires = rows[0]
if has_expires:
exp = dt.datetime.fromtimestamp(expires / 1_000_000 - 11644473600, dt.timezone.utc)
left = (exp.timestamp() - time.time()) / 86400
if left <= 0:
print(f"NOT LOGGED IN: the Instagram sessionid expired {exp:%Y-%m-%d}.")
return 1
note = f"expires {exp:%Y-%m-%d}"
if left < WARN_DAYS:
note += f" (WARNING: only {left:.1f} days left)"
print(f"session ok: sessionid present, {note}")
else:
print("session ok: sessionid present (session cookie, no expiry)")
return 0
if __name__ == "__main__":
sys.exit(main())
+120
View File
@@ -0,0 +1,120 @@
#!/bin/bash
# The full scrape-then-fetch pipeline for one profile: scroll the real page in
# the signed-in Chrome (instagram-chrome-scrape.py), then fetch and publish
# whatever's new as single posts. See TOOLING.md ("Reels: the API is blocked,
# scrape by scrolling instead", and 2026-10-22 for the posts grid) for why this
# exists at all -- it drives the actual signed-in browser session instead of
# the listing API, and is slower by design.
#
# Usage: GDL_TAB=reels|posts ./chrome-sync.sh <username-or-profile-url>
# Normally called through reels-sync.sh / posts-sync.sh, which set GDL_TAB.
#
# Also run by gdl-cron.sh (profiles, full-sweep) once per profile and tab since
# 2026-10-23, with GDL_MAX_KNOWN_ROUNDS set -- see TOOLING.md "Posts grid by browser".
# Exits non-zero if either step does. Everything is logged.
set -eu
RAW="${1:?usage: GDL_TAB=reels|posts chrome-sync.sh <username-or-profile-url>}"
TAB="${GDL_TAB:?set GDL_TAB=reels or posts (or use reels-sync.sh / posts-sync.sh)}"
GDL_HOME="${GDL_HOME:-$HOME/gdl}"
GDL_PYTHON="${GDL_PYTHON:-$HOME/.local/share/pipx/venvs/gallery-dl/bin/python3}"
INDEX="${GDL_INDEX:-https://instaarchive.ergosteur.com}"
PUBLISH="${GDL_PUBLISH:-agentapi@10.20.28.200:/volume1/rslsync/sync/Instagram-archive/archives/}"
# Same hand-paced pacing gdl-cron.sh uses -- see its comment for why. Override
# per-run with GDL_SLEEP_REQUEST etc. if you ever need to, but raise them
# rather than lower them.
SLEEP_REQUEST="${GDL_SLEEP_REQUEST:-12 20}"
SLEEP="${GDL_SLEEP:-5 10}"
RATE="${GDL_RATE:-500K}"
SCROLL_PAUSE="${GDL_SCROLL_PAUSE:-2.0 3.5}"
MAX_IDLE_ROUNDS="${GDL_MAX_IDLE_ROUNDS:-3}"
# Empty = instagram-chrome-scrape.py's own default (2 for posts, 0 = scroll to the end for reels).
MAX_KNOWN_ROUNDS="${GDL_MAX_KNOWN_ROUNDS:-}"
PATH="$HOME/.local/bin:$PATH"; export PATH
if [ ! -x "$GDL_PYTHON" ]; then
echo "instagram-chrome-scrape.py needs gallery-dl's own pipx venv python (websocket-client" >&2
echo "was injected there, not into the system python): $GDL_PYTHON not found" >&2
exit 2
fi
# Same username-from-URL parsing gdl-sync.py already does for --urls-file,
# reused rather than re-implemented so the two never drift apart.
PROFILE=$("$GDL_PYTHON" -c "
import re, sys, importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location('gdl_sync', Path('$GDL_HOME/gdl-sync.py'))
gdl = importlib.util.module_from_spec(spec)
sys.modules['gdl_sync'] = gdl
spec.loader.exec_module(gdl)
raw = '$RAW'
m = gdl.RE_PROFILE_URL.match(raw)
user = m.group('user') if m else raw.strip('/')
if not user or '/' in user or ' ' in user:
print(f'cannot read a username from {raw!r}', file=sys.stderr)
sys.exit(1)
print(user)
")
mkdir -p "$GDL_HOME/logs"
LOG="$GDL_HOME/logs/$TAB-$PROFILE-$(date +%Y%m%d-%H%M%S)-$$.log"
URLS_FILE="$GDL_HOME/$PROFILE-$TAB.txt"
STAGING="$GDL_HOME/staging-$TAB-$PROFILE"
echo "=== $TAB-sync $PROFILE $(date -Is) ===" | tee -a "$LOG"
# The exit status has to survive the pipe into tee -- see gdl-cron.sh's
# comment on PIPESTATUS for why this needs to be bash, not sh.
set +e
"$GDL_PYTHON" "$GDL_HOME/instagram-chrome-scrape.py" \
--profile "$PROFILE" \
--tab "$TAB" \
--index "$INDEX" \
--scroll-pause $SCROLL_PAUSE \
--max-idle-rounds "$MAX_IDLE_ROUNDS" \
${MAX_KNOWN_ROUNDS:+--max-known-rounds "$MAX_KNOWN_ROUNDS"} \
--out "$URLS_FILE" 2>&1 | tee -a "$LOG"
scrape_status=${PIPESTATUS[0]}
set -e
if [ "$scrape_status" -ne 0 ]; then
echo "=== exit $scrape_status (scrape failed) at $(date -Is) ===" | tee -a "$LOG"
exit "$scrape_status"
fi
if [ ! -s "$URLS_FILE" ]; then
echo "no new $TAB for $PROFILE; nothing to fetch" | tee -a "$LOG"
echo "=== exit 0 at $(date -Is) ===" | tee -a "$LOG"
exit 0
fi
# Staging is wiped every run on purpose -- same reasoning as gdl-cron.sh: what
# we already hold is decided by the archive dedupe in instagram-chrome-scrape.py, not by
# what happens to be sitting in staging.
rm -rf "$STAGING"
set +e
# shellcheck disable=SC2086
"$GDL_HOME/gdl-sync.py" \
--publish "$PUBLISH" \
--staging "$STAGING" \
--post-urls-file "$URLS_FILE" \
--sleep-request $SLEEP_REQUEST \
--sleep $SLEEP \
--rate "$RATE" \
--execute 2>&1 | tee -a "$LOG"
status=${PIPESTATUS[0]}
set -e
echo "=== exit $status at $(date -Is) ===" | tee -a "$LOG"
# Keep the log directory from growing without bound -- scoped to this
# script's own logs so it never touches gdl-cron.sh's rotation.
ls -1t "$GDL_HOME/logs" | grep "^$TAB-[^0-9]" | tail -n +30 | while read -r old; do
rm -f "$GDL_HOME/logs/$old"
done
exit "$status"
+225
View File
@@ -0,0 +1,225 @@
#!/bin/bash
# Unattended wrapper around gdl-sync.py. One argument: the run mode.
#
# stories daily ~6 requests; the only surface that cannot be backfilled
# profiles weekly every surface, --abort 50: stops enumerating a profile
# once it reaches content already held, so it costs
# ~40-60 requests and catches everything NEW
# full-sweep rarely every surface, no abort: walks each profile to the end
# for ~420 requests. The only run that notices posts
# EDITED after we archived them, and by far the most
# expensive thing here -- see TOOLING.md before running.
#
# Posts and reels are LISTED by scrolling the signed-in Chrome (chrome-sync.sh,
# per profile and tab) rather than by gallery-dl's listing API, which broke on
# 2026-10-22 (gh#9829) while single-post fetches kept working. gallery-dl still
# does stories and highlights. GDL_LISTING=api restores the old all-API run.
#
# Exits non-zero if the sync does, so cron mails you. Everything is logged.
set -eu
MODE="${1:?usage: gdl-cron.sh stories|profiles|full-sweep}"
# healthchecks.io (or compatible) ping URL, e.g. https://hc-ping.com/<uuid>.
# Set by the run-*.sh wrapper for whichever mode has a check configured;
# unset here means no ping, silently. A ping failure (network blip, check
# deleted) must never fail the sync itself -- hence `|| true` throughout, and
# a short timeout (-m) so a hung ping can't hang the run.
HC_URL="${HC_URL:-}"
GDL_HOME="${GDL_HOME:-$HOME/gdl}"
INDEX="${GDL_INDEX:-https://instaarchive.ergosteur.com}"
PUBLISH="${GDL_PUBLISH:-agentapi@10.20.28.200:/volume1/rslsync/sync/Instagram-archive/archives/}"
STAGING="$GDL_HOME/staging-$MODE"
# $$ in the name because two runs in the same SECOND would otherwise share a
# log file, and `tee -a` appends -- which made a test see the previous run.
LOG="$GDL_HOME/logs/$MODE-$(date +%Y%m%d-%H%M%S)-$$.log"
PATH="$HOME/.local/bin:$PATH"; export PATH
# Pacing. These are the values the 2026-08-22 runs used by hand, after the
# scraping warning -- roughly double the caution of gdl-sync.py's own defaults
# (6-10s / 3-6s / 1M). An archive sync has no deadline; being slow is free and
# being restricted is not. Override per-run with GDL_SLEEP_REQUEST etc. if you
# ever need to, but raise them rather than lower them.
SLEEP_REQUEST="${GDL_SLEEP_REQUEST:-12 20}"
SLEEP="${GDL_SLEEP:-5 10}"
RATE="${GDL_RATE:-500K}"
LISTING="${GDL_LISTING:-browser}"
case "$LISTING" in
browser|api) ;;
*) echo "GDL_LISTING must be browser or api, not '$LISTING'" >&2; exit 2 ;;
esac
GDL_PYTHON="${GDL_PYTHON:-$HOME/.local/share/pipx/venvs/gallery-dl/bin/python3}"
# Set per mode below when posts/reels go through the browser: empty = no browser step.
BROWSER_KNOWN_ROUNDS=""
case "$MODE" in
# --min-interval 8, not the 20h default. The floor exists to stop an ABORTED
# RESTART re-enumerating profiles -- a minutes-to-hours concern. At 20h the
# daily timer silently did nothing whenever a manual run had happened the
# previous afternoon, which is exactly what happened on 2026-08-21: it fired,
# skipped all six sources and reported success. A stories fetch is one
# request per profile, so the worst case 8h permits is roughly twelve
# requests in a day instead of six.
stories) ARGS="--only stories --min-interval 8" ;;
# Browser listing: stop scrolling a grid after 2 rounds of nothing unarchived,
# the browser's equivalent of --abort 50.
profiles)
if [ "$LISTING" = browser ]; then
ARGS="--only stories,highlights --abort 50"; BROWSER_KNOWN_ROUNDS=2
else
ARGS="--only posts,reels,stories,highlights --abort 50"
fi ;;
# No --abort: the whole point of full-sweep is enumerating to the end, so it
# is the only run that notices carousels edited after we archived them. The
# browser equivalent is scrolling every grid to the bottom (0 = no early stop).
full-sweep)
if [ "$LISTING" = browser ]; then
ARGS="--only stories,highlights"; BROWSER_KNOWN_ROUNDS=0
else
ARGS="--only posts,reels,stories,highlights"
fi ;;
# Renamed 2026-08-22: "full" was misleading (it is the abort-LIMITED run) and
# "sweep" did not say it was the exhaustive one. Catch the old names rather
# than failing with a bare error, in case something still passes them.
full) echo "mode 'full' was renamed to 'profiles'" >&2; exit 2 ;;
sweep) echo "mode 'sweep' was renamed to 'full-sweep'" >&2; exit 2 ;;
*) echo "unknown mode: $MODE (want stories|profiles|full-sweep)" >&2; exit 2 ;;
esac
mkdir -p "$GDL_HOME/logs"
[ -n "$HC_URL" ] && curl -fsS -m 10 --retry 3 -o /dev/null "$HC_URL/start" || true
# Preflight: refuse to scrape logged out. gdl reads the Chrome profile's cookie DB, and the
# session in it can die silently -- from 2026-10-07 every stories run fetched "No results"
# for all six profiles and still exited 0, so nothing alerted and a week of stories (which
# cannot be backfilled) was lost. An open Chrome tab keeps showing its logged-in UI after
# that, so only the cookie DB tells the truth. Exit 76 turns the Healthchecks check red.
SESSION_CHECK="$GDL_HOME/check-session.py"
if [ -x "$SESSION_CHECK" ]; then
if ! SESSION_MSG=$("$SESSION_CHECK" 2>&1); then
echo "=== $MODE run $(date -Is) ===" | tee -a "$LOG"
echo "ABORT (no requests sent): $SESSION_MSG" | tee -a "$LOG" >&2
echo "=== exit 76 at $(date -Is) ===" | tee -a "$LOG"
[ -n "$HC_URL" ] && curl -fsS -m 10 --retry 3 --data-binary "@$LOG" \
-o /dev/null "$HC_URL/76" || true
exit 76
fi
echo "preflight: $SESSION_MSG" | tee -a "$LOG"
fi
# Upgrade gallery-dl/yt-dlp first (PyPI only, no Instagram requests; never fatal). Instagram
# endpoints change and gallery-dl follows within days -- on 1.32.14 the posts listing silently
# returned "No results" after 1.32.17 had already fixed the query. See gdl-upgrade.sh.
UPGRADE="$GDL_HOME/gdl-upgrade.sh"
if [ -x "$UPGRADE" ]; then
"$UPGRADE" 2>&1 | tee -a "$LOG" || true
fi
# Staging is wiped every run ON PURPOSE. What we already hold is decided by the
# skip-archive (--download-archive), never by which files happen to be sitting
# in staging, so starting empty is correct -- and it keeps the publish rsync
# to just the new files instead of re-walking gigabytes each time.
rm -rf "$STAGING"
echo "=== $MODE run $(date -Is) ===" | tee -a "$LOG"
# The exit status has to survive the pipe into tee. The left-hand side of a
# pipeline runs in a SUBSHELL, so an `exit` in there sets the subshell's status
# and the script goes on to return tee's, which is always 0. An earlier version
# of this file did exactly that and reported success no matter what the sync
# did -- which is why the shebang is bash: PIPESTATUS is the fix.
# This run's output only. The check below must never see a previous run's
# lines, so it reads this rather than the (appended-to) log.
RUNOUT=$(mktemp)
trap 'rm -f "$RUNOUT"' EXIT
set +e
# shellcheck disable=SC2086
"$GDL_HOME/gdl-sync.py" \
--index "$INDEX" \
--staging "$STAGING" \
--publish "$PUBLISH" \
--archive-db "$GDL_HOME/artms.db" \
--urls-file "$GDL_HOME/artms_account_links.txt" \
--sleep-request $SLEEP_REQUEST \
--sleep $SLEEP \
--rate "$RATE" \
$ARGS --execute 2>&1 | tee -a "$LOG" "$RUNOUT"
status=${PIPESTATUS[0]}
set -e
# Posts and reels by browser: scrape each profile's grid and Reels tab, fetch only
# the new shortcodes as single posts. chrome-sync.sh logs and publishes on its own;
# its output is copied into this run's log so the Healthchecks body shows it too.
if [ -n "$BROWSER_KNOWN_ROUNDS" ]; then
mapfile -t PROFILES < <("$GDL_PYTHON" -c "
import sys, importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location('gdl_sync', Path('$GDL_HOME/gdl-sync.py'))
gdl = importlib.util.module_from_spec(spec)
sys.modules['gdl_sync'] = gdl
spec.loader.exec_module(gdl)
for p in gdl.read_urls_file(Path('$GDL_HOME/artms_account_links.txt')):
print(p)
")
browser_failed=0
for p in "${PROFILES[@]}"; do
for tab in posts reels; do
echo "==> browser: $p / $tab" | tee -a "$LOG"
set +e
GDL_TAB=$tab GDL_MAX_KNOWN_ROUNDS=$BROWSER_KNOWN_ROUNDS \
"$GDL_HOME/chrome-sync.sh" "$p" 2>&1 | tee -a "$LOG" >/dev/null
s=${PIPESTATUS[0]}
set -e
if [ "$s" -ne 0 ]; then
echo " browser $p / $tab FAILED (exit $s)" | tee -a "$LOG" >&2
browser_failed=$((browser_failed + 1))
fi
done
done
echo "browser listing: ${#PROFILES[@]} profile(s), $browser_failed step(s) failed" | tee -a "$LOG"
[ "$browser_failed" -gt 0 ] && [ "$status" -eq 0 ] && status=1
fi
# A stories run that skipped every source is NOT a success. It means the
# min-interval floor blocked the one surface that cannot be backfilled, and
# without this it looks identical to a clean run: exit 0, "0 step(s) failed".
# Note this is not the same as "no stories today" -- that shows up as sources
# being fetched and returning no results, which is normal and stays quiet.
if [ "$MODE" = "stories" ] && grep -q "sources : 0 to sync" "$RUNOUT"; then
echo "WARNING: every stories source was skipped by --min-interval." | tee -a "$LOG" >&2
echo " Nothing was fetched. Stories expire in 24h and cannot be" | tee -a "$LOG" >&2
echo " backfilled, so this is a real loss, not a quiet no-op." | tee -a "$LOG" >&2
[ "$status" -eq 0 ] && status=75
fi
# Every stories source empty for several days running = a session Instagram stopped
# honouring while the cookie is still in the jar (the preflight cannot see that). Stories-only:
# other modes legitimately return few items per source. See stories-streak.py.
if [ "$MODE" = "stories" ] && [ "$status" -eq 0 ] && [ -x "$GDL_HOME/stories-streak.py" ]; then
if ! STREAK_MSG=$("$GDL_HOME/stories-streak.py" "$RUNOUT" "$GDL_HOME/stories-empty-streak" 2>&1); then
echo "WARNING: $STREAK_MSG" | tee -a "$LOG" >&2
status=77
fi
fi
echo "=== exit $status at $(date -Is) ===" | tee -a "$LOG"
# The log becomes the ping body -- healthchecks.io keeps the last ping's body
# and shows it in the dashboard, so a failure is diagnosable without SSHing
# in. The trailing URL segment is the exit code by convention: 0 means
# success, anything else means fail (this is how the "0 skipped" -> 75
# remap above also reaches the dashboard as a failure, not a quiet success).
[ -n "$HC_URL" ] && curl -fsS -m 10 --retry 3 --data-binary "@$LOG" \
-o /dev/null "$HC_URL/$status" || true
# Keep the log directory from growing without bound.
ls -1t "$GDL_HOME/logs" | tail -n +30 | while read -r old; do
rm -f "$GDL_HOME/logs/$old"
done
exit $status
+1132
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Upgrade gallery-dl and its injected yt-dlp (both pipx). Instagram changes its
# endpoints often and gallery-dl follows within days -- 1.32.17 (2026-10-17)
# "update user & posts query parameters" landed while we were still on 1.32.14
# and the posts listing silently returned "No results".
#
# Called by gdl-cron.sh before every scrape and weekly by gdl-upgrade.timer.
# Never fatal: a failed upgrade (offline, PyPI down) must not stop a stories
# run, which cannot be backfilled -- it just scrapes with what is installed.
# Costs no Instagram requests (PyPI only).
set -u
PATH="$HOME/.local/bin:$PATH"; export PATH
VENV_PY="${GDL_VENV_PY:-$HOME/.local/share/pipx/venvs/gallery-dl/bin/python}"
versions() {
printf 'gallery-dl %s, yt-dlp %s' \
"$(gallery-dl --version 2>/dev/null || echo '?')" \
"$("$VENV_PY" -c 'import yt_dlp; print(yt_dlp.version.__version__)' 2>/dev/null || echo '?')"
}
before=$(versions)
if ! timeout 180 pipx upgrade --quiet --include-injected gallery-dl >/dev/null 2>&1; then
echo "upgrade: WARNING pipx upgrade failed; continuing with $before" >&2
exit 0
fi
after=$(versions)
if [ "$before" = "$after" ]; then
echo "upgrade: up to date ($after)"
else
echo "upgrade: CHANGED $before -> $after"
fi
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""
Find a profile's reels (or posts) by scrolling the real page, not calling the API.
gallery-dl's dedicated reels extractor POSTs to /api/v1/clips/user/, which
Instagram now 302-redirects to the home page for this account -- confirmed on
2026-08-26/27 across multiple profiles, hours apart, with a freshly-warmed
session and a correct X-IG-WWW-Claim header. The reels tab itself loads fine
in a real browser, so this drives the SAME logged-in Chrome (already exposed
on loopback CDP for MCP automation -- see TOOLING.md) via the DevTools
protocol, scrolls it like a person would, and scrapes `/reel/<code>/` links
out of the rendered page instead.
`--tab posts` does the same for the main profile grid (`/<user>/`, both `/p/` and
`/reel/` links), for when gallery-dl's posts listing breaks -- on 2026-10-22,
1.32.14 returned "No results" and 1.32.17 "Requested user could not be found"
(upstream gh#9829), while single-post fetches kept working. Since the grid
runs newest-first, it stops scrolling once rounds turn up nothing but
already-archived posts (`--max-known-rounds`) instead of walking a whole
profile's history.
This only finds shortcodes; it never downloads anything itself. What comes
out the other end is deduped against the archive (via the same --index used
elsewhere) and printed as plain post URLs -- feed them to gdl-sync.py:
./scripts/instagram-chrome-scrape.py --profile someuser \\
--index https://instaarchive.ergosteur.com > /tmp/someuser-reels.txt
./scripts/gdl-sync.py --publish user@host:/path --staging /var/tmp/gdl \\
--post-urls-file /tmp/someuser-reels.txt \\
--sleep-request 12 20 --sleep 5 10 --rate 500K --execute
Requires the `websocket-client` package (only imported inside scrape_reels,
so everything else here stays importable -- and testable -- without it).
"""
from __future__ import annotations
import argparse
import itertools
import json
import random
import re
import sys
import time
from pathlib import Path
from urllib.request import urlopen
RE_POST_HREF = re.compile(r"/(p|reel)/([^/?#]+)")
# tab -> (page path under the profile, link kinds to collect)
TABS = {
"reels": ("reels/", ("reel",)),
"posts": ("", ("p", "reel")),
}
# The reels tab has finished loading once a scroll round finds no NEW
# shortcodes this many times in a row -- lazy-loaded feeds sometimes stall
# for a round or two before producing more, so one dry round is not enough.
DEFAULT_MAX_IDLE_ROUNDS = 3
DEFAULT_MAX_SCROLLS = 200
DEFAULT_SCROLL_PAUSE = (2.0, 3.5)
# Posts tab only: stop after this many consecutive scroll rounds whose new
# shortcodes were all already archived. The first round is exempt -- it holds
# the pinned posts, which are old by nature and sit above the new ones.
DEFAULT_MAX_KNOWN_ROUNDS = 2
def scrape_js(kinds: tuple[str, ...]) -> str:
selector = ", ".join(f'a[href*="/{k}/"]' for k in kinds)
return (
"(() => {"
"window.scrollTo(0, document.body.scrollHeight);"
f"return Array.from(document.querySelectorAll({json.dumps(selector)}))"
".map(a => a.getAttribute('href'));"
"})()"
)
def extract_shortcodes(hrefs: list[str],
kinds: tuple[str, ...] = ("reel",)) -> list[str]:
codes = []
for href in hrefs:
if (m := RE_POST_HREF.search(href)) and m.group(1) in kinds:
codes.append(m.group(2))
return codes
class CDPError(RuntimeError):
pass
class CDP:
"""
A deliberately minimal synchronous DevTools Protocol client: one request
in flight at a time, which is all a linear scroll-and-scrape loop needs.
Anything fancier (concurrent requests, event subscriptions) is scope this
script has no reason to carry.
"""
def __init__(self, ws_url: str, timeout: float = 30.0):
import websocket # local: keep this importable without the package
self.ws = websocket.create_connection(ws_url, timeout=timeout)
self._ids = itertools.count(1)
def send(self, method: str, params: dict | None = None,
session_id: str | None = None) -> dict:
msg_id = next(self._ids)
payload = {"id": msg_id, "method": method, "params": params or {}}
if session_id:
payload["sessionId"] = session_id
self.ws.send(json.dumps(payload))
while True:
msg = json.loads(self.ws.recv())
if msg.get("id") != msg_id:
continue # an event notification, not our reply -- ignore
if "error" in msg:
raise CDPError(f"{method}: {msg['error']}")
return msg.get("result", {})
def close(self) -> None:
self.ws.close()
def should_stop_known(round_no: int, new: list[str], have: set[str],
streak: int, max_known_rounds: int) -> tuple[bool, int]:
"""
Early-stop bookkeeping for a newest-first grid: returns (stop, streak).
A round counts toward the streak when it found new shortcodes and every
one is already archived; a round with anything unarchived resets it. An
empty round leaves it alone (that is what max_idle_rounds is for), and
round 1 never counts -- pinned posts come first.
"""
if max_known_rounds <= 0 or round_no == 1 or not new:
return False, streak
if all(c in have for c in new):
streak += 1
else:
streak = 0
return streak >= max_known_rounds, streak
def scrape_grid(user: str, tab: str = "reels", cdp_port: int = 9222,
scroll_pause: tuple[float, float] = DEFAULT_SCROLL_PAUSE,
max_idle_rounds: int = DEFAULT_MAX_IDLE_ROUNDS,
max_scrolls: int = DEFAULT_MAX_SCROLLS,
have: set[str] | None = None,
max_known_rounds: int = 0,
log=lambda msg: None) -> list[str]:
"""
Open the profile's reels tab (or main grid) in a NEW tab of the
already-signed-in Chrome, scroll it to the bottom repeatedly, and collect
every unique shortcode that appears. Closes the tab when done either way.
"""
path, kinds = TABS[tab]
js = scrape_js(kinds)
version = json.loads(urlopen(f"http://localhost:{cdp_port}/json/version",
timeout=10).read())
browser = CDP(version["webSocketDebuggerUrl"])
target_id = None
try:
target = browser.send("Target.createTarget", {
"url": f"https://www.instagram.com/{user}/{path}"})
target_id = target["targetId"]
attach = browser.send("Target.attachToTarget", {
"targetId": target_id, "flatten": True})
session_id = attach["sessionId"]
browser.send("Page.enable", session_id=session_id)
browser.send("Runtime.enable", session_id=session_id)
time.sleep(4.0) # initial page load, before the first scroll
seen: set[str] = set()
idle = 0
known_streak = 0
for i in range(max_scrolls):
result = browser.send(
"Runtime.evaluate",
{"expression": js, "returnByValue": True},
session_id=session_id)
hrefs = result.get("result", {}).get("value") or []
codes = extract_shortcodes(hrefs, kinds)
new = list(dict.fromkeys(c for c in codes if c not in seen))
seen.update(new)
log(f" scroll {i + 1}: {len(seen)} unique {tab} so far (+{len(new)})")
stop, known_streak = should_stop_known(
i + 1, new, have or set(), known_streak, max_known_rounds)
if stop:
log(f" {known_streak} round(s) of already-archived {tab} in a row; "
f"stopping")
break
if new:
idle = 0
else:
idle += 1
if idle >= max_idle_rounds:
break
time.sleep(random.uniform(*scroll_pause))
return sorted(seen)
finally:
if target_id:
try:
browser.send("Target.closeTarget", {"targetId": target_id})
except CDPError:
pass # best-effort cleanup; a leftover tab is harmless
browser.close()
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--profile", required=True,
help="Instagram username to scrape")
ap.add_argument("--tab", choices=sorted(TABS), default="reels",
help="reels tab, or the main posts grid (default: reels)")
ap.add_argument("--index", required=True,
help="existing archive listing, same as gdl-sync.py's "
"--index: a local root, or the viewer's base URL. "
"Used only to dedupe -- already-archived shortcodes "
"are dropped before anything is printed")
ap.add_argument("--cdp-port", type=int, default=9222,
help="Chrome's loopback DevTools port (see TOOLING.md)")
ap.add_argument("--scroll-pause", nargs=2, type=float,
default=list(DEFAULT_SCROLL_PAUSE), metavar=("MIN", "MAX"),
help="random pause between scrolls, seconds")
ap.add_argument("--max-idle-rounds", type=int, default=DEFAULT_MAX_IDLE_ROUNDS,
help="stop after this many consecutive scrolls with no "
"new reels")
ap.add_argument("--max-scrolls", type=int, default=DEFAULT_MAX_SCROLLS,
help="hard ceiling on scroll rounds, in case a page never "
"goes idle")
ap.add_argument("--max-known-rounds", type=int, default=None,
help="stop after this many consecutive scroll rounds of "
"only already-archived shortcodes; 0 disables "
f"(default: {DEFAULT_MAX_KNOWN_ROUNDS} for posts, "
"0 for reels)")
ap.add_argument("--out", type=Path,
help="write new reel URLs here, one per line (default: "
"stdout)")
args = ap.parse_args()
import importlib.util
spec = importlib.util.spec_from_file_location(
"gdl_sync", Path(__file__).with_name("gdl-sync.py"))
gdl = importlib.util.module_from_spec(spec)
sys.modules["gdl_sync"] = gdl
spec.loader.exec_module(gdl)
index = gdl.ArchiveIndex(args.index)
# Deduped against the WHOLE archive, not just this profile's own
# directories: a shortcode is globally unique, and a reels tab commonly
# surfaces reposts/collabs by OTHER tracked accounts. Missing that let a
# 2026-08-27 run re-fetch 9 reels already held under their true owner's
# directory -- gallery-dl filed them correctly there (by the post's real
# `username`, not the scraped profile), so nothing was lost, but it spent
# 9 avoidable Instagram requests to find that out. All of this is local
# requests to our own viewer, never to instagram.com, so checking every
# profile costs nothing on the budget that actually matters.
profiles = index.profiles()
have: set[str] = set()
for profile in profiles:
have.update(code for code, _ in gdl.index_existing(index.listing(profile)))
print(f"archive already holds {len(have)} shortcode(s) across "
f"{len(profiles)} profile(s)", file=sys.stderr)
max_known = args.max_known_rounds
if max_known is None:
max_known = DEFAULT_MAX_KNOWN_ROUNDS if args.tab == "posts" else 0
path, _ = TABS[args.tab]
print(f"scraping https://www.instagram.com/{args.profile}/{path} ...",
file=sys.stderr)
codes = scrape_grid(args.profile, tab=args.tab, cdp_port=args.cdp_port,
scroll_pause=tuple(args.scroll_pause),
max_idle_rounds=args.max_idle_rounds,
max_scrolls=args.max_scrolls,
have=have, max_known_rounds=max_known,
log=lambda msg: print(msg, file=sys.stderr))
new_codes = [c for c in codes if c not in have]
print(f"found {len(codes)} {args.tab} on the page, {len(codes) - len(new_codes)} "
f"already archived, {len(new_codes)} new", file=sys.stderr)
# /p/<code>/ resolves reels too, so the posts grid needs no per-kind URL.
if args.tab == "posts":
urls = [f"https://www.instagram.com/p/{c}/" for c in new_codes]
else:
urls = [f"https://www.instagram.com/{args.profile}/reel/{c}/"
for c in new_codes]
text = "\n".join(urls)
if args.out:
args.out.write_text(text + ("\n" if text else ""))
print(f"wrote {len(urls)} URL(s) to {args.out}", file=sys.stderr)
else:
if text:
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())
+277
View File
@@ -0,0 +1,277 @@
/**
* Generate JDownloader2 .crawljob files for the archives on disk.
*
* The manual flow is: paste a profile URL into JDownloader, paste the /reels
* URL separately (the profile page misses some reels), and set the output
* folder by hand — times however many profiles you keep. This emits one
* crawljob per source with the folder already pointed at the right directory,
* so JDownloader's folder-watch picks the whole batch up at once.
*
* Profiles and their sidecar directories are derived with the same grouping
* logic the server uses, so the output folders always match what the viewer
* expects to find.
*
* Only posts and reels are emitted. Story and highlight URLs can't be rebuilt
* from a directory name — highlights need their numeric id and stories expire —
* so those stay manual.
*
* Crawljob format verified against JDownloader's own docs for the extension:
* src/org/jdownloader/extensions/folderwatchV2/explain.txt. JDownloader
* develops on SVN; read it via the daily mirror at
* https://github.com/mycodedoesnotcompile2/jdownloader_mirror (svn_trunk/),
* not one of the abandoned GitHub copies — several are a decade stale.
*
* Entries are separated by `->NEW ENTRY<-` and any property may be omitted.
* There is also a `setBeforePackagizerEnabled` companion to
* `overwritePackagizerEnabled`, if the Packagizer ever needs to see these
* values before they're applied.
*
* Usage:
* npx tsx scripts/jd2-sync.ts --archives <dir> [options]
*
* --archives <dir> Archive root to scan (default: $ARCHIVES_DIR)
* --out <dir> JDownloader folder-watch directory to write into
* --download-base <dir> Root path as *JDownloader* sees it, when it runs on
* a different machine than this script (e.g. a mapped
* drive). Defaults to --archives.
* --user <name> Only this profile (repeatable)
* --skip <name> Never emit jobs for this directory (repeatable).
* Also read from a `.jd2ignore` file in the archive
* root, one name per line.
* --chunks <n> Connections per file (default 1: multi-chunk ranged
* requests are the one CDN pattern that doesn't look
* like a browser)
* --auto-start Start downloads immediately instead of parking them
* in the LinkGrabber for review
* --all-reels Emit a reels job even where no reels directory
* exists yet
* --dry-run Print the crawljob instead of writing it
*/
import fs from 'fs';
import path from 'path';
import { groupArchiveDirectories, ArchiveSource } from '../src/lib/archive-grouping.js';
interface Options {
archives: string;
out: string | null;
downloadBase: string;
users: string[];
skip: Set<string>;
chunks: number;
autoStart: boolean;
allReels: boolean;
dryRun: boolean;
}
const parseArgs = (argv: string[]): Options => {
const opts: Options = {
archives: process.env.ARCHIVES_DIR ?? '',
out: null,
downloadBase: '',
users: [],
skip: new Set(),
chunks: 1,
autoStart: false,
allReels: false,
dryRun: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = () => argv[++i];
switch (arg) {
case '--archives': opts.archives = path.resolve(next()); break;
case '--out': opts.out = path.resolve(next()); break;
case '--download-base': opts.downloadBase = next(); break;
case '--user': opts.users.push(next()); break;
case '--skip': opts.skip.add(next()); break;
case '--chunks': opts.chunks = parseInt(next(), 10); break;
case '--auto-start': opts.autoStart = true; break;
case '--all-reels': opts.allReels = true; break;
case '--dry-run': opts.dryRun = true; break;
case '--help': case '-h': printUsage(); process.exit(0);
default:
console.error(`Unknown argument: ${arg}`);
process.exit(1);
}
}
if (!opts.archives) {
console.error('No archive root. Pass --archives <dir> or set ARCHIVES_DIR.');
process.exit(1);
}
if (!opts.downloadBase) opts.downloadBase = opts.archives;
if (!opts.out && !opts.dryRun) {
console.error('No destination. Pass --out <folder-watch dir>, or --dry-run to preview.');
process.exit(1);
}
return opts;
};
const printUsage = () => {
const header = readHeaderComment();
console.log(header);
};
/** Print the usage block from this file's own header comment. */
const readHeaderComment = () => {
try {
const self = fs.readFileSync(new URL(import.meta.url), 'utf8');
const usage = self.slice(self.indexOf(' * Usage:'), self.indexOf(' */'));
return usage.split('\n').map(l => l.replace(/^ \* ?/, '')).join('\n');
} catch {
return 'See the comment at the top of scripts/jd2-sync.ts';
}
};
/**
* JDownloader escapes nothing in crawljob values, so a stray newline would
* silently split a property. Paths with spaces are fine as-is.
*/
const sanitise = (value: string) => value.replace(/[\r\n]+/g, ' ').trim();
/**
* Instagram usernames are 1–30 characters of letters, digits, dots and
* underscores. Archive roots also collect directories that aren't profiles at
* all — tool output, exports from other services — and pointing a crawl at
* those spends requests on instagram.com to be told the profile doesn't exist.
* That's the exact traffic worth not spending.
*/
const USERNAME_RE = /^[A-Za-z0-9._]{1,30}$/;
/** Directory names to skip, from `.jd2ignore` in the archive root. */
const readIgnoreFile = (archives: string): string[] => {
try {
return fs.readFileSync(path.join(archives, '.jd2ignore'), 'utf8')
.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
} catch {
return [];
}
};
interface Job {
user: string;
kind: 'posts' | 'reels';
url: string;
packageName: string;
downloadFolder: string;
fileCount: number | null;
}
const buildJobs = (opts: Options): Job[] => {
const dirNames = fs.readdirSync(opts.archives, { withFileTypes: true })
.filter(e => e.isDirectory() && !/^[.@_]/.test(e.name))
.map(e => e.name);
const groups = groupArchiveDirectories(dirNames);
const jobs: Job[] = [];
const skipped: string[] = [];
for (const name of readIgnoreFile(opts.archives)) opts.skip.add(name);
const countFiles = (dir: string): number | null => {
try {
return fs.readdirSync(path.join(opts.archives, dir)).length;
} catch {
return null;
}
};
// JDownloader must be given the path *it* can see, which differs from the
// scan path whenever the archive lives on a share.
const downloadFolderFor = (dir: string) =>
opts.downloadBase.includes('\\')
? `${opts.downloadBase.replace(/\\$/, '')}\\${dir}`
: path.posix.join(opts.downloadBase, dir);
for (const [user, sources] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
if (opts.users.length && !opts.users.includes(user)) continue;
if (opts.skip.has(user)) { skipped.push(`${user} (ignored)`); continue; }
if (!USERNAME_RE.test(user)) { skipped.push(`${user} (not a username)`); continue; }
const has = (kind: ArchiveSource['kind']) => sources.find(s => s.kind === kind);
const base = has('posts');
if (!base) continue; // sidecar-only group: nothing sensible to point a URL at
jobs.push({
user, kind: 'posts',
url: `https://www.instagram.com/${encodeURIComponent(user)}/`,
packageName: base.dir,
downloadFolder: downloadFolderFor(base.dir),
fileCount: countFiles(base.dir),
});
const reels = has('reels');
if (reels || opts.allReels) {
const dir = reels?.dir ?? `${user} - reels`;
jobs.push({
user, kind: 'reels',
url: `https://www.instagram.com/${encodeURIComponent(user)}/reels/`,
packageName: dir,
downloadFolder: downloadFolderFor(dir),
fileCount: reels ? countFiles(dir) : null,
});
}
}
if (skipped.length) {
console.error(`Skipped ${skipped.length} director${skipped.length === 1 ? 'y' : 'ies'}:`);
for (const s of skipped) console.error(` - ${s}`);
console.error('');
}
return jobs;
};
const renderCrawljob = (jobs: Job[], opts: Options): string =>
jobs.map(job => [
`text=${sanitise(job.url)}`,
`packageName=${sanitise(job.packageName)}`,
`downloadFolder=${sanitise(job.downloadFolder)}`,
`chunks=${opts.chunks}`,
// Without this a Packagizer rule can override downloadFolder and scatter
// files away from the directory the viewer reads.
'overwritePackagizerEnabled=TRUE',
`autoStart=${opts.autoStart ? 'TRUE' : 'FALSE'}`,
`autoConfirm=${opts.autoStart ? 'TRUE' : 'FALSE'}`,
'enabled=TRUE',
`comment=instaarchive jd2-sync (${job.kind})`,
].join('\n')).join('\n->NEW ENTRY<-\n');
const main = () => {
const opts = parseArgs(process.argv.slice(2));
const jobs = buildJobs(opts);
if (!jobs.length) {
console.error('No profiles matched.');
process.exit(1);
}
console.error(`Archive root : ${opts.archives}`);
console.error(`JD sees root : ${opts.downloadBase}`);
console.error(`Jobs : ${jobs.length} (${new Set(jobs.map(j => j.user)).size} profiles)\n`);
for (const job of jobs) {
const count = job.fileCount === null ? 'new' : `${job.fileCount} files`;
console.error(` ${job.kind.padEnd(5)} ${job.user.padEnd(24)} -> ${job.packageName} (${count})`);
}
console.error('');
const body = renderCrawljob(jobs, opts);
if (opts.dryRun || !opts.out) {
console.log(body);
return;
}
fs.mkdirSync(opts.out, { recursive: true });
const file = path.join(opts.out, `instaarchive-${new Date().toISOString().replace(/[:.]/g, '-')}.crawljob`);
fs.writeFileSync(file, body, 'utf8');
console.error(`Wrote ${file}`);
console.error(opts.autoStart
? 'Downloads will start automatically.'
: 'Links land in the LinkGrabber for review; start them when ready.');
};
main();
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# chrome-sync.sh for the main profile grid: scroll /<user>/ in the signed-in
# Chrome, collect /p/ and /reel/ links, fetch only the new ones as single
# posts. A by-hand fallback for when gallery-dl's posts listing is broken
# (2026-10-22: upstream gh#9829) while single-post fetches still work -- see
# TOOLING.md. Stops scrolling once it reaches already-archived posts.
#
# Usage: ./posts-sync.sh <username-or-profile-url>
set -eu
export GDL_TAB=posts
exec "$(dirname "$0")/chrome-sync.sh" "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# chrome-sync.sh for a profile's reels tab. See TOOLING.md ("Reels: the API is
# blocked, scrape by scrolling instead").
#
# Usage: ./reels-sync.sh <username-or-profile-url>
set -eu
export GDL_TAB=reels
exec "$(dirname "$0")/chrome-sync.sh" "$@"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Rarely: every surface, no --abort -- walks each profile to the end, ~420
# requests. The only run that notices posts EDITED after they were archived
# (e.g. a caption added later -- see TOOLING.md "Reels: the API is blocked"
# for how that showed up), and by far the most expensive thing here. Read
# TOOLING.md before running this; prefer running it by hand, not on a timer.
set -eu
exec "$(dirname "$0")/gdl-cron.sh" full-sweep "$@"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# Routine run: every surface, --abort 50 (only gdl-cron.sh's "profiles" mode
# does this), ~5 min. Covers everything but is no substitute for stories.sh --
# a story posted and expired between two runs of this is simply gone.
set -eu
# healthchecks.io check "instagram-archiver-posts". Override or unset (e.g.
# HC_URL= ./run-profiles.sh) to run without pinging it.
export HC_URL="${HC_URL:-https://hc-ping.com/3f40abd8-e9bd-43af-a105-c583efaad88c}"
exec "$(dirname "$0")/gdl-cron.sh" profiles "$@"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Run reels-sync.sh for every tracked ARTMS profile, one after another.
# TOOLING.md ("Reels: the API is blocked, scrape by scrolling instead")
# covers what this actually does and why it exists; this is just the loop
# version of running reels-sync.sh six times by hand, which is what the
# 2026-09-03 run was before this script existed.
#
# A by-hand full sweep of every Reels tab (no early stop). The weekly
# gdl-cron.sh profiles run lists reels through chrome-sync.sh itself, with an
# early stop, since 2026-10-23 -- see TOOLING.md "Posts grid by browser".
#
# Usage: ./reels-all.sh
# GDL_ARTMS_LINKS=/path/to/list ./reels-all.sh # override the profile list
set -eu
GDL_HOME="${GDL_HOME:-$HOME/gdl}"
LINKS="${GDL_ARTMS_LINKS:-$GDL_HOME/artms_account_links.txt}"
GDL_PYTHON="${GDL_PYTHON:-$HOME/.local/share/pipx/venvs/gallery-dl/bin/python3}"
if [ ! -f "$LINKS" ]; then
echo "profile list not found: $LINKS" >&2
exit 2
fi
# Same parser gdl-sync.py uses for --urls-file (URLs or bare usernames, #
# comments, blank lines, duplicates dropped), reused so this never drifts
# from what a routine profiles.sh run actually tracks.
mapfile -t PROFILES < <("$GDL_PYTHON" -c "
import sys, importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location('gdl_sync', Path('$GDL_HOME/gdl-sync.py'))
gdl = importlib.util.module_from_spec(spec)
sys.modules['gdl_sync'] = gdl
spec.loader.exec_module(gdl)
for p in gdl.read_urls_file(Path('$LINKS')):
print(p)
")
if [ "${#PROFILES[@]}" -eq 0 ]; then
echo "no usable profiles in $LINKS" >&2
exit 2
fi
echo "=== reels-all: ${#PROFILES[@]} profile(s) $(date -Is) ==="
status=0
for p in "${PROFILES[@]}"; do
echo "=== starting $p ==="
if ! "$(dirname "$0")/reels-sync.sh" "$p"; then
status=1
echo "=== $p FAILED ===" >&2
fi
echo "=== finished $p ==="
done
echo "=== reels-all: exit $status at $(date -Is) ==="
exit "$status"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Daily run: stories only, ~1 min. The one surface that cannot be backfilled
# -- do this often. See TOOLING.md "Quick reference" for the full picture.
#
# Just a fixed-mode entry point so you don't have to remember gdl-cron.sh's
# mode argument; override pacing/paths the same way gdl-cron.sh does, with
# GDL_* env vars (see gdl-cron.sh's own comments for what's available).
set -eu
# healthchecks.io check "instagram-archiver-stories". Override or unset (e.g.
# HC_URL= ./run-stories.sh) to run without pinging it.
export HC_URL="${HC_URL:-https://hc-ping.com/9fe7347f-95be-47aa-a46b-affd27691554}"
exec "$(dirname "$0")/gdl-cron.sh" stories "$@"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
Detect a silently dead Instagram session from a stories run's output.
A dead session (killed server-side while the sessionid is still in the cookie jar, so
check-session.py passes) shows up as every fetched stories source returning
"No results" -- the daily run still exits 0. From 2026-10-07 all six profiles came back
empty every day and nothing alerted. One all-empty day is normal (nobody posted a story);
several in a row across ALL sources is not.
Usage: stories-streak.py <this run's output file> [state file]
Counts at most one day per calendar date (manual re-runs don't inflate it). Prints a message
and exits 1 once the streak reaches GDL_EMPTY_STREAK_FAIL days (default 3); exits 0 otherwise.
Runs that fetched nothing (all skipped / no sources) leave the streak untouched.
"""
import datetime as dt
import os
import re
import sys
from pathlib import Path
THRESHOLD = int(os.environ.get("GDL_EMPTY_STREAK_FAIL", "3"))
def main() -> int:
out = Path(sys.argv[1]).read_text(errors="replace")
state = Path(sys.argv[2] if len(sys.argv) > 2
else Path.home() / "gdl" / "stories-empty-streak")
m = re.search(r"sources\s*:\s*(\d+) to sync", out)
to_sync = int(m.group(1)) if m else 0
if to_sync == 0:
return 0 # nothing fetched: no evidence either way
empty = len(re.findall(r"No results for \S*/stories/", out))
today = dt.date.today().isoformat()
last_date, streak = "", 0
try:
last_date, s = state.read_text().split()
streak = int(s)
except (OSError, ValueError):
pass
if empty >= to_sync: # every fetched source came back empty
if last_date != today:
streak += 1
else:
streak = 0
state.write_text(f"{today} {streak}\n")
if streak >= THRESHOLD:
print(f"SESSION LIKELY DEAD: stories came back empty for ALL {to_sync} profile(s) on "
f"{streak} consecutive day(s). The cookie is present but Instagram is probably "
"no longer honouring it -- log in again in the Chrome window (reload the tab to "
"check) and re-run.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+16
View File
@@ -0,0 +1,16 @@
[Unit]
# One templated service for all three modes; the instance name (%i) is the
# mode: stories, full or sweep.
Description=Instagram archive sync (%i)
Documentation=file:%h/gdl/TOOLING.md
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=%h/gdl/run-%i.sh
# A sync has no deadline and the pacing is deliberately slow; a full sweep can
# run for hours. Never let systemd kill one midway -- a half-published run is
# the one state the publish step is designed to avoid.
TimeoutStartSec=infinity
Nice=10
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Full Instagram archive sweep, no abort (~420 requests)
# The only run that enumerates every profile to the end, and so the only one
# that notices carousels edited after we archived them (test case 15).
#
# It is also by far the most expensive thing here: ~420 requests to
# instagram.com, the same order as the run that preceded the 2026-08-21
# scraping warning, spent to catch a handful of retroactively edited posts.
# Consider running it by hand when you mean to, rather than on a timer.
[Timer]
# Month names are not valid in OnCalendar's date field -- numeric only.
OnCalendar=*-01,04,07,10-07 04:00:00
RandomizedDelaySec=45m
Persistent=true
[Install]
WantedBy=timers.target
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Weekly Instagram profile sync (all surfaces, --abort 50)
[Timer]
# Weekly, but deliberately not pinned to a day: OnCalendar anchors to Monday
# 00:00:00 and RandomizedDelaySec spreads the actual fire up to 6 days later,
# so which day it lands on varies week to week -- same "don't look like a
# machine" reasoning as the stories timer's 45m jitter, just applied to the
# day instead of the minute. See TOOLING.md's healthchecks.io section: the
# matching instagram-archiver-posts check uses Simple Schedule (period +
# grace) rather than Cron Schedule for exactly this reason -- a fixed cron
# day/time would fight this jitter instead of tolerating it.
OnCalendar=weekly
RandomizedDelaySec=6d
Persistent=true
[Install]
WantedBy=timers.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Daily Instagram stories sync
# Stories expire in 24h and cannot be backfilled. This is the only timer whose
# missed run means content is gone for good, which is what Persistent= is for.
[Timer]
OnCalendar=*-*-* 09:00:00
# Not a fixed time: a job firing at exactly 09:00 every day is obviously a
# machine, and the whole safety model is about not looking like one.
RandomizedDelaySec=45m
# Catch up after the host was asleep or off. cron would silently skip.
Persistent=true
[Install]
WantedBy=timers.target
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Upgrade gallery-dl and yt-dlp (pipx)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=%h/gdl/gdl-upgrade.sh
Nice=10
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Weekly gallery-dl and yt-dlp upgrade
# gdl-cron.sh also upgrades before every scrape; this keeps the tools current
# on weeks when no run happens to call it.
[Timer]
OnCalendar=weekly
RandomizedDelaySec=1d
Persistent=true
[Install]
WantedBy=timers.target
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""
Tests for the request-budget logic in gdl-sync.py.
python3 -m unittest discover -s scripts -p 'test_*.py'
Deliberately stdlib-only, so it runs anywhere the sync itself runs. What is
covered here is the part that decides whether to spend a request — the part
whose absence got the archive's Instagram account suspended.
"""
import datetime as dt
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
_spec = importlib.util.spec_from_file_location(
"gdl_sync", Path(__file__).with_name("gdl-sync.py"))
gdl = importlib.util.module_from_spec(_spec)
sys.modules["gdl_sync"] = gdl
_spec.loader.exec_module(gdl)
NOW = dt.datetime(2026, 8, 18, 12, 0, tzinfo=dt.timezone.utc)
NOW_TS = NOW.timestamp()
def ago(hours: float) -> str:
return (NOW - dt.timedelta(hours=hours)).isoformat()
class SourceSelection(unittest.TestCase):
def test_only_stories_is_a_single_cheap_source(self):
srcs = gdl.Profile("u").sources(gdl.STORIES_ONLY)
self.assertEqual([s.kind for s in srcs], ["stories"])
self.assertEqual(srcs[0].directory, "story - u")
def test_full_sync_covers_every_surface(self):
srcs = gdl.Profile("u").sources(set(gdl.ALL_KINDS))
self.assertEqual([s.kind for s in srcs], list(gdl.ALL_KINDS))
def test_reels_and_stories_go_to_their_own_directories(self):
by_kind = {s.kind: s for s in gdl.Profile("u").sources(set(gdl.ALL_KINDS))}
self.assertEqual(by_kind["posts"].directory, "u")
self.assertEqual(by_kind["reels"].directory, "u - reels")
# Highlights derive their directory from the title mid-extraction.
self.assertEqual(by_kind["highlights"].directory, "")
class PlanSource(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.state = gdl.SyncState(Path(self.tmp.name) / "state.json")
self.posts = gdl.Profile("u").sources({"posts"})[0]
self.stories = gdl.Profile("u").sources({"stories"})[0]
def tearDown(self):
self.tmp.cleanup()
def test_first_run_seeds(self):
fetch, seed, _ = gdl.plan_source(self.posts, self.state, NOW_TS, 20)
self.assertTrue(fetch)
self.assertTrue(seed)
def test_seeding_happens_only_once(self):
self.state.mark_seeded(self.posts.url, ago(720))
fetch, seed, reason = gdl.plan_source(self.posts, self.state, NOW_TS, 20)
self.assertTrue(fetch)
self.assertFalse(seed, "a seeded source must never be re-probed")
self.assertIn("already seeded", reason)
def test_stories_never_seed(self):
# A story cannot be in the archive before it is fetched, so probing
# would double the cost of the cheapest surface for no benefit.
_, seed, reason = gdl.plan_source(self.stories, self.state, NOW_TS, 20)
self.assertFalse(seed)
self.assertIn("no seed", reason)
def test_recent_fetch_is_refused(self):
self.state.mark_fetched(self.posts.url, ago(3))
fetch, _, reason = gdl.plan_source(self.posts, self.state, NOW_TS, 20)
self.assertFalse(fetch)
self.assertIn("under the", reason)
def test_an_old_fetch_is_allowed_again(self):
self.state.mark_fetched(self.posts.url, ago(30))
fetch, _, _ = gdl.plan_source(self.posts, self.state, NOW_TS, 20)
self.assertTrue(fetch)
def test_daily_stories_pass_a_20h_floor(self):
# The cadence this is built for: once a day, every day.
self.state.mark_fetched(self.stories.url, ago(24))
fetch, _, _ = gdl.plan_source(self.stories, self.state, NOW_TS, 20)
self.assertTrue(fetch)
def test_force_disables_the_floor(self):
self.state.mark_fetched(self.posts.url, ago(1))
fetch, _, _ = gdl.plan_source(self.posts, self.state, NOW_TS, 0.0)
self.assertTrue(fetch)
def test_the_aborted_run_scenario(self):
"""
Yesterday's failure: a run died mid-way and the restart re-enumerated
every profile. Seeded-but-not-fetched must not re-probe.
"""
self.state.mark_seeded(self.posts.url, ago(0.5))
fetch, seed, _ = gdl.plan_source(self.posts, self.state, NOW_TS, 20)
self.assertTrue(fetch, "the fetch still needs to happen")
self.assertFalse(seed, "but the listing pass must not be paid for twice")
class StatePersistence(unittest.TestCase):
def test_state_survives_a_reload(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "state.json"
a = gdl.SyncState(path)
a.mark_seeded("https://x/", ago(1))
a.mark_fetched("https://x/", ago(1))
a.save()
b = gdl.SyncState(path)
self.assertFalse(b.needs_seed("https://x/"))
self.assertEqual(b.last_fetch("https://x/"), ago(1))
def test_a_corrupt_state_file_never_blocks_a_sync(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "state.json"
path.write_text("{ not json")
self.assertTrue(gdl.SyncState(path).needs_seed("https://x/"))
class ProbeCaching(unittest.TestCase):
def test_fresh_entries_are_reused_and_stale_ones_are_not(self):
with tempfile.TemporaryDirectory() as d:
cache = gdl.ProbeCache(Path(d) / "p.json", ttl_hours=24)
cache.put("https://x/", [{"shortcode": "A", "post_shortcode": "A",
"num": 1, "media_id": "1"}], ago(1))
self.assertEqual(len(cache.get("https://x/", NOW_TS)), 1)
cache.put("https://y/", [{"shortcode": "B", "post_shortcode": "B",
"num": 1, "media_id": "2"}], ago(48))
self.assertIsNone(cache.get("https://y/", NOW_TS))
def test_cache_keeps_only_the_fields_seeding_needs(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "p.json"
cache = gdl.ProbeCache(path, ttl_hours=24)
cache.put("https://x/", [{"shortcode": "A", "post_shortcode": "A",
"num": 1, "media_id": "1",
"description": "x" * 5000}], ago(0))
cache.save()
self.assertNotIn("description", path.read_text())
def test_a_miss_is_reported_rather_than_guessed(self):
with tempfile.TemporaryDirectory() as d:
cache = gdl.ProbeCache(Path(d) / "p.json", ttl_hours=24)
self.assertIsNone(cache.get("https://never-seen/", NOW_TS))
class Seeding(unittest.TestCase):
"""The bug that seeded 5 of 2275: matching the wrong shortcode field."""
def test_posts_are_keyed_by_post_shortcode(self):
item = {"shortcode": "childcode", "post_shortcode": "POSTCODE",
"num": 2, "media_id": "9"}
self.assertEqual(gdl.live_key(item, "posts"), ("POSTCODE", 2))
def test_stories_are_keyed_by_the_per_item_shortcode(self):
item = {"shortcode": "ITEMCODE", "post_shortcode": "reelid",
"num": 3, "media_id": "9"}
self.assertEqual(gdl.live_key(item, "stories"), ("ITEMCODE", 1))
self.assertEqual(gdl.live_key(item, "highlights"), ("ITEMCODE", 1))
def test_index_existing_normalises_a_missing_index_to_one(self):
held = gdl.index_existing([
"u/2023-04-19_u - ABC.mp4",
"u/2023-04-12_u - DEF - 3.jpg",
"u/2023-04-12_u - DEF.txt", # sidecars are not media
"u/2023-04-12_u - DEF.json",
])
self.assertEqual(held, {("ABC", 1), ("DEF", 3)})
def test_seeding_marks_only_what_is_already_held(self):
with tempfile.TemporaryDirectory() as d:
db = Path(d) / "a.db"
live = [
{"post_shortcode": "HELD", "shortcode": "x", "num": 1, "media_id": "11"},
{"post_shortcode": "NEW", "shortcode": "y", "num": 1, "media_id": "22"},
]
n = gdl.seed_archive_db(db, {("HELD", 1)}, live, "posts")
self.assertEqual(n, 1)
import sqlite3
rows = {r[0] for r in sqlite3.connect(db).execute(
"SELECT entry FROM archive")}
self.assertEqual(rows, {"instagram11"})
class Publishing(unittest.TestCase):
def test_publish_only_ever_adds(self):
cmd = gdl.rsync_command(Path("/stage"), "host:/archives", dry_run=False)
self.assertIn("--ignore-existing", cmd)
self.assertNotIn("--delete", cmd)
def test_tooling_files_are_excluded_from_the_archive(self):
cmd = " ".join(gdl.rsync_command(Path("/stage"), "/dest", dry_run=True))
for pattern in ("gdl-sync*.json", "*.db"):
self.assertIn(pattern, cmd)
self.assertIn("--dry-run", cmd)
def _publish_with(self, returncode, stderr=""):
with tempfile.TemporaryDirectory() as d:
staging = Path(d)
(staging / "file.txt").write_text("x") # publish() skips empty dirs
completed = subprocess.CompletedProcess(
args=["rsync"], returncode=returncode, stdout="", stderr=stderr)
with mock.patch("subprocess.run", return_value=completed):
return gdl.publish(staging, "host:/dest", dry_run=False)
def test_a_clean_rsync_is_success(self):
self.assertEqual(self._publish_with(0), 0)
def test_the_expected_chown_warning_is_not_a_failure(self):
# The exact shape seen on every real run: the archive share is never
# owned by the ssh user, so rsync's own chown step fails -- exit 23,
# "some files/attrs were not transferred" -- even though every file
# landed. TOOLING.md documents this as something to read past; the
# exit CODE needs to agree, or every healthcheck ping reports a
# clean run as a failure.
stderr = (
'rsync: failed to set permissions on "/dest/.": Operation not permitted (1)\n'
'rsync: failed to set permissions on "/dest/user": Operation not permitted (1)\n'
'rsync error: some files/attrs were not transferred (see previous errors) '
'(code 23) at main.c(1347) [sender=3.4.1]\n'
)
self.assertEqual(self._publish_with(23, stderr), 0)
def test_a_real_transfer_failure_still_fails(self):
# Same exit code (23) can also mean a real problem -- e.g. a file
# vanishing mid-transfer -- so only the SPECIFIC permission-warning
# shape above is masked, not the exit code in general.
stderr = (
'rsync: [sender] readlink_stat("/stage/broken") failed: '
'No such file or directory (2)\n'
'rsync error: some files/attrs were not transferred (see previous errors) '
'(code 23) at main.c(1347) [sender=3.4.1]\n'
)
self.assertEqual(self._publish_with(23, stderr), 23)
def test_an_unrelated_exit_code_still_fails(self):
self.assertEqual(self._publish_with(1, "rsync: some other error\n"), 1)
class PostUrl(unittest.TestCase):
"""--post-url: a one-off fetch outside the tracked profile list, whose
owning account is only known mid-extraction -- same reasoning as
highlights, so it must be exempted from the same forced-destination rule."""
def test_config_keys_a_username_directory(self):
config = gdl.build_config("1M", [6.0, 10.0], [3.0, 6.0])
for kind in ("post", "reel"):
self.assertEqual(
config["extractor"]["instagram"][kind]["directory"],
["{username}"])
def test_destination_is_not_forced_like_posts_and_reels(self):
staging = Path("/stage")
for subcategory in ("post", "reel"):
src = gdl.Source(subcategory, "https://www.instagram.com/p/ABC/",
"", subcategory)
cmd = gdl.gdl_command(src, staging, Path("/cfg.json"), "chrome:x",
None)
self.assertIn(str(staging), cmd)
self.assertNotIn(str(staging / subcategory), cmd)
class MetadataFields(unittest.TestCase):
"""Extra fields captured from the raw API response, verified against two
saved real examples on 2026-09-01 (see docs/gallery-dl.md)."""
def test_post_level_json_captures_coauthors(self):
config = gdl.build_config("1M", [6.0, 10.0], [3.0, 6.0])
pps = config["extractor"]["instagram"]["posts"]["postprocessors"]
post_json = next(pp for pp in pps if pp.get("filename", "").endswith(".json"))
self.assertIn("coauthors", post_json["include"])
def test_per_item_dimensions_and_tags_get_their_own_sidecar(self):
config = gdl.build_config("1M", [6.0, 10.0], [3.0, 6.0])
pps = config["extractor"]["instagram"]["posts"]["postprocessors"]
# No "filename" of its own -- unlike the other two, which are keyed
# by POST_STEM -- because it must vary per carousel item, not per post.
media_pp = next(pp for pp in pps if "filename" not in pp)
for field in ("width", "height", "width_original", "height_original",
"tagged_users", "shortcode", "num"):
self.assertIn(field, media_pp["include"])
# `owner` is a full user object (profile pic URLs, privacy flags) for
# whoever posted that item -- deliberately excluded, same reasoning
# as `audio_user` on the post-level json.
self.assertNotIn("owner", media_pp["include"])
def test_reels_get_the_same_capture_as_posts(self):
config = gdl.build_config("1M", [6.0, 10.0], [3.0, 6.0])
posts_pps = config["extractor"]["instagram"]["posts"]["postprocessors"]
reels_pps = config["extractor"]["instagram"]["reels"]["postprocessors"]
self.assertEqual(posts_pps, reels_pps)
class UrlsFile(unittest.TestCase):
def test_reads_every_form_a_person_might_paste(self):
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "urls.txt"
p.write_text(
"# comment\n"
"https://www.instagram.com/a/\n"
"https://instagram.com/b\n"
"www.instagram.com/c/\n"
"d\n"
" e # trailing\n"
"\n"
"https://www.instagram.com/a/\n" # duplicate
"https://www.instagram.com/p/ABC123/\n" # a post, not a profile
"not a username\n")
self.assertEqual(gdl.read_urls_file(p), ["a", "b", "c", "d", "e"])
class PostUrlsFile(unittest.TestCase):
"""The format instagram-chrome-scrape.py writes: whole URLs, not usernames."""
def test_skips_comments_blanks_and_duplicates(self):
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "reels.txt"
p.write_text(
"# scraped 2026-08-27\n"
"https://www.instagram.com/u/reel/AAA/\n"
"\n"
"https://www.instagram.com/u/reel/BBB/\n"
"https://www.instagram.com/u/reel/AAA/\n") # duplicate
self.assertEqual(gdl.read_post_urls_file(p), [
"https://www.instagram.com/u/reel/AAA/",
"https://www.instagram.com/u/reel/BBB/",
])
class ProgressOutput(unittest.TestCase):
"""The heartbeat that stops a healthy paced run (12-20s between requests, no per-file
output) from looking hung."""
def test_elapsed_formatting(self):
self.assertEqual(gdl._fmt_elapsed(5), "5s")
self.assertEqual(gdl._fmt_elapsed(125), "2m05s")
self.assertEqual(gdl._fmt_elapsed(7300), "2h01m")
def test_count_new_counts_media_written_since_and_ignores_sidecars(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "sub").mkdir()
old = root / "old.jpg"
old.write_text("x")
import os
os.utime(old, (1, 1)) # long before `since`
(root / "sub" / "new.jpg").write_text("x")
(root / "sub" / "new.mp4").write_text("x")
for sidecar in ("new.txt", "new.json", "half.jpg.part"):
(root / "sub" / sidecar).write_text("x")
n, newest = gdl._count_new(root, since=1000)
self.assertEqual(n, 2)
self.assertIn(newest, ("new.jpg", "new.mp4"))
def test_heartbeat_prints_while_running_and_stops_cleanly(self):
with tempfile.TemporaryDirectory() as d:
with mock.patch("builtins.print") as pr:
with gdl.Heartbeat("fetching", watch=Path(d), every=0.05) as hb:
(Path(d) / "a.jpg").write_text("x")
import time
time.sleep(0.2)
lines = [str(c.args[0]) for c in pr.call_args_list]
self.assertTrue(any("fetching" in s and "new file(s) so far" in s for s in lines))
self.assertFalse(hb._thread.is_alive())
self.assertGreater(hb.elapsed, 0.1)
if __name__ == "__main__":
unittest.main(verbosity=2)
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Tests for the pure parts of instagram-chrome-scrape.py -- everything except the actual
CDP session, which needs a live signed-in Chrome and is exercised by hand.
python3 -m unittest discover -s scripts -p 'test_*.py'
"""
import importlib.util
import sys
import unittest
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"instagram_chrome_scrape", Path(__file__).with_name("instagram-chrome-scrape.py"))
scrape = importlib.util.module_from_spec(_spec)
sys.modules["instagram_chrome_scrape"] = scrape
_spec.loader.exec_module(scrape)
class ExtractShortcodes(unittest.TestCase):
def test_reads_relative_and_absolute_hrefs(self):
hrefs = [
"/someuser/reel/ABC123/",
"https://www.instagram.com/someuser/reel/DEF456/",
"/reel/GHI789/?img_index=1",
]
self.assertEqual(scrape.extract_shortcodes(hrefs),
["ABC123", "DEF456", "GHI789"])
def test_ignores_non_reel_links(self):
hrefs = ["/someuser/", "/someuser/p/ABC123/", "/explore/tags/foo/"]
self.assertEqual(scrape.extract_shortcodes(hrefs), [])
def test_empty_input(self):
self.assertEqual(scrape.extract_shortcodes([]), [])
def test_posts_kinds_read_both_post_and_reel_links(self):
hrefs = ["/someuser/p/ABC123/", "/p/DEF456/?img_index=2",
"/someuser/reel/GHI789/", "/explore/tags/foo/"]
self.assertEqual(scrape.extract_shortcodes(hrefs, ("p", "reel")),
["ABC123", "DEF456", "GHI789"])
class ShouldStopKnown(unittest.TestCase):
have = {"OLD1", "OLD2", "OLD3", "OLD4"}
def step(self, round_no, new, streak=0, max_known=2):
return scrape.should_stop_known(round_no, new, self.have, streak, max_known)
def test_first_round_never_counts(self):
# pinned posts: old, but on top of the grid
self.assertEqual(self.step(1, ["OLD1", "OLD2"]), (False, 0))
def test_stops_after_consecutive_known_rounds(self):
stop, streak = self.step(2, ["OLD1"])
self.assertEqual((stop, streak), (False, 1))
self.assertEqual(self.step(3, ["OLD2"], streak), (True, 2))
def test_unarchived_resets_streak(self):
self.assertEqual(self.step(3, ["OLD3", "NEW1"], streak=1), (False, 0))
def test_empty_round_leaves_streak(self):
self.assertEqual(self.step(3, [], streak=1), (False, 1))
def test_disabled(self):
self.assertEqual(self.step(5, ["OLD1"], streak=9, max_known=0), (False, 9))
if __name__ == "__main__":
unittest.main(verbosity=2)
+160
View File
@@ -0,0 +1,160 @@
"""
Tests for the two guards that stop a dead Instagram session going unnoticed:
check-session.py preflight: is there a usable `sessionid` in the Chrome cookie DB?
stories-streak.py postflight: did every stories source come back empty for days running?
Both are run as subprocesses (the way gdl-cron.sh runs them); neither touches the network.
python3 -I -m unittest scripts/test_session_guards.py
"""
import datetime as dt
import os
import sqlite3
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
CHROME_EPOCH = 11644473600 # seconds between 1601-01-01 and 1970-01-01
def run(script, *args, env=None):
e = {**os.environ, **(env or {})}
return subprocess.run([sys.executable, str(HERE / script), *args],
capture_output=True, text=True, env=e)
class CheckSession(unittest.TestCase):
def profile(self, rows):
"""A fake Chrome profile whose cookie DB holds `rows` = [(host, name, expires_unix|None)]."""
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
d = Path(tmp.name) / "Default"
d.mkdir()
con = sqlite3.connect(d / "Cookies")
con.execute("CREATE TABLE cookies (host_key TEXT, name TEXT, expires_utc INTEGER, has_expires INTEGER)")
for host, name, exp in rows:
con.execute("INSERT INTO cookies VALUES (?,?,?,?)",
(host, name, 0 if exp is None else (exp + CHROME_EPOCH) * 1_000_000,
0 if exp is None else 1))
con.commit()
con.close()
return tmp.name
def check(self, rows):
return run("check-session.py", env={"GDL_COOKIE_PROFILE": self.profile(rows)})
def test_valid_sessionid_passes(self):
r = self.check([(".instagram.com", "sessionid", time.time() + 200 * 86400)])
self.assertEqual(r.returncode, 0, r.stdout)
self.assertIn("session ok", r.stdout)
self.assertNotIn("WARNING", r.stdout)
def test_missing_sessionid_fails_even_with_other_instagram_cookies(self):
# What the real failure looked like: csrftoken/ds_user_id/mid linger after logout.
r = self.check([(".instagram.com", "csrftoken", time.time() + 86400),
(".instagram.com", "ds_user_id", time.time() + 86400),
(".instagram.com", "mid", time.time() + 86400)])
self.assertEqual(r.returncode, 1)
self.assertIn("NOT LOGGED IN", r.stdout)
def test_expired_sessionid_fails(self):
r = self.check([(".instagram.com", "sessionid", time.time() - 86400)])
self.assertEqual(r.returncode, 1)
self.assertIn("expired", r.stdout)
def test_near_expiry_warns_but_passes(self):
r = self.check([(".instagram.com", "sessionid", time.time() + 3 * 86400)])
self.assertEqual(r.returncode, 0)
self.assertIn("WARNING", r.stdout)
def test_session_cookie_without_expiry_passes(self):
r = self.check([(".instagram.com", "sessionid", None)])
self.assertEqual(r.returncode, 0)
def test_other_sites_sessionid_does_not_count(self):
r = self.check([(".example.com", "sessionid", time.time() + 86400)])
self.assertEqual(r.returncode, 1)
def test_missing_profile_fails_loudly(self):
r = run("check-session.py", env={"GDL_COOKIE_PROFILE": "/nonexistent/profile"})
self.assertEqual(r.returncode, 1)
self.assertIn("no cookie DB", r.stdout)
def test_never_prints_cookie_values(self):
# The fake DB has no value column at all; the script must not need one.
r = self.check([(".instagram.com", "sessionid", time.time() + 90 * 86400)])
self.assertNotIn("sessionid=", r.stdout + r.stderr)
class StoriesStreak(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.dir = Path(tmp.name)
self.state = self.dir / "streak"
self.today = dt.date.today().isoformat()
self.yesterday = (dt.date.today() - dt.timedelta(days=1)).isoformat()
def out(self, to_sync, empty, skipped=0):
lines = [f"sources : {to_sync} to sync, {skipped} skipped"]
lines += [f"[instagram][info] No results for https://www.instagram.com/stories/user{i}/"
for i in range(empty)]
p = self.dir / "run.out"
p.write_text("\n".join(lines) + "\n")
return p
def streak(self, to_sync, empty, skipped=0):
return run("stories-streak.py", str(self.out(to_sync, empty, skipped)), str(self.state))
def stored(self):
return self.state.read_text().split()
def test_all_empty_for_three_days_fails(self):
self.state.write_text(f"{self.yesterday} 2\n")
r = self.streak(6, 6)
self.assertEqual(r.returncode, 1)
self.assertIn("SESSION LIKELY DEAD", r.stdout)
self.assertEqual(self.stored(), [self.today, "3"])
def test_first_empty_day_is_quiet(self):
r = self.streak(6, 6)
self.assertEqual(r.returncode, 0)
self.assertEqual(self.stored(), [self.today, "1"])
def test_same_day_rerun_does_not_inflate_the_streak(self):
self.state.write_text(f"{self.today} 2\n")
r = self.streak(6, 6)
self.assertEqual(r.returncode, 0)
self.assertEqual(self.stored(), [self.today, "2"])
def test_any_story_resets_the_streak(self):
self.state.write_text(f"{self.yesterday} 2\n")
r = self.streak(6, 5) # one profile had a story
self.assertEqual(r.returncode, 0)
self.assertEqual(self.stored(), [self.today, "0"])
def test_run_that_fetched_nothing_leaves_state_alone(self):
self.state.write_text(f"{self.yesterday} 2\n")
r = self.streak(0, 0, skipped=6)
self.assertEqual(r.returncode, 0)
self.assertEqual(self.stored(), [self.yesterday, "2"])
def test_threshold_is_configurable(self):
r = run("stories-streak.py", str(self.out(6, 6)), str(self.state),
env={"GDL_EMPTY_STREAK_FAIL": "1"})
self.assertEqual(r.returncode, 1)
def test_corrupt_state_file_is_treated_as_no_history(self):
self.state.write_text("garbage\n")
r = self.streak(6, 6)
self.assertEqual(r.returncode, 0)
self.assertEqual(self.stored(), [self.today, "1"])
if __name__ == "__main__":
unittest.main()