Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f7ece973 | ||
|
|
07df53acde | ||
|
|
c81f18276f | ||
|
|
4ce5cf048e | ||
|
|
7a085d2272 | ||
|
|
087304243e | ||
|
|
e14dbf6ec8 | ||
|
|
3e29976866 | ||
|
|
0ac1ef951f | ||
|
|
ee66b87bff | ||
|
|
68568ef855 | ||
|
|
3a34e4359e | ||
|
|
4fee8b1dfe | ||
|
|
7b63ba6a76 | ||
|
|
b84113af18 | ||
|
|
abf22eb50f | ||
|
|
e6d874a5d9 | ||
|
|
ff45b6d898 | ||
|
|
30ff8de1e8 | ||
|
|
9560f85515 | ||
|
|
f0c054946f | ||
|
|
42eb598d1c | ||
|
|
aac753ced9 | ||
|
|
9173504190 | ||
|
|
1340d85945 | ||
|
|
c5b0a5cb5f | ||
|
|
ae0f075855 | ||
|
|
3146f896c3 | ||
|
|
ed078c7b46 | ||
|
|
55b0752b05 | ||
|
|
53703cd7cd | ||
|
|
97f5d19ce4 | ||
|
|
1b4aba54d6 | ||
|
|
b30285fe70 | ||
|
|
20209bcad5 | ||
|
|
74902234b3 | ||
|
|
d62bddc3aa | ||
|
|
42c13ea106 | ||
|
|
a4e9ce16a7 | ||
|
|
4f89a69ee3 | ||
|
|
147dcdf2f1 | ||
|
|
d4e20d9b98 | ||
|
|
ec8c771733 | ||
|
|
3784e8729b | ||
|
|
69d62eaa5c | ||
|
|
767f9c508b | ||
|
|
103ce6f207 | ||
|
|
5267dab236 | ||
|
|
ebf2bf660a | ||
|
|
c0f3523a9c | ||
|
|
6f5021638c | ||
|
|
67f7750157 | ||
|
|
9e306eb85e | ||
|
|
b2da08d52d | ||
|
|
d7c13ecc19 | ||
|
|
d396b356be | ||
|
|
e23dfe4474 | ||
|
|
41e7c5e206 | ||
|
|
f685eaebd7 | ||
|
|
47e44ec5e9 | ||
|
|
cd7dc5f981 | ||
|
|
a724e5bc87 |
@@ -15,6 +15,9 @@ InstaArchive Viewer is a React 19 + Vite 6 PWA for browsing archived Instagram d
|
||||
- `npm run lint` — type-check only (`tsc --noEmit`)
|
||||
- `npm test` / `npm run test:watch` — vitest
|
||||
- `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.
|
||||
|
||||
@@ -34,10 +37,10 @@ Loading is unified behind the `ArchiveFile` interface (`src/types/index.ts`, imp
|
||||
An archive root holds one directory per profile plus *sidecars* that belong to it:
|
||||
|
||||
```
|
||||
4utumn07 -> posts (base)
|
||||
4utumn07 - reels -> reels
|
||||
story - 4utumn07 -> stories
|
||||
story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
||||
0ct0ber19 -> posts (base)
|
||||
0ct0ber19 - reels -> reels
|
||||
story - 0ct0ber19 -> stories
|
||||
story highlights - 0ct0ber19 - Heestory -> highlight "Heestory"
|
||||
```
|
||||
|
||||
`src/lib/archive-grouping.ts` (shared by server and tests) folds these into a single profile with a `sources` list. Sidecars never appear as standalone archives. Each file the server returns carries its `kind`, so the client routes posts / reels / story ring / highlight circles without re-deriving naming rules.
|
||||
@@ -69,7 +72,7 @@ Three different JSON shapes turn up as `.json`, so they are told apart structura
|
||||
| Instaloader `.json.xz` | GraphQL node under `node` / `__typename` |
|
||||
| gallery-dl sidecar | flat, `post_shortcode` + `type`, none of the above |
|
||||
|
||||
The gallery-dl sidecar is the only source that states what a post *is*: its `type` (`post` / `reel` / `story` / `highlight`) is Instagram's own classification, so `post.isReel` set from it beats every fallback in `post-tabs.ts`. This matters — of the 781 items in `official_band - reels`, the sidecars say only **360 are reels**; the other 421 are ordinary feed videos the clips endpoint returns via `include_feed_video`. Directory-based classification counted all 781.
|
||||
The gallery-dl sidecar is the only source that states what a post *is*: its `type` (`post` / `reel` / `story` / `highlight`) is Instagram's own classification, so `post.isReel` set from it beats every fallback in `post-tabs.ts`. This matters — of the 781 items in `official_artms - reels`, the sidecars say only **360 are reels**; the other 421 are ordinary feed videos the clips endpoint returns via `include_feed_video`. Directory-based classification counted all 781.
|
||||
|
||||
**Dates are ranked, not last-write-wins** (`src/lib/post-dates.ts`): sidecar (what Instagram reported) beats filename (what the fetcher wrote) beats mtime (when the file hit disk, and unrelated to when it was posted). Ties keep the incumbent. Several files describe one post and they are scanned in directory order, not in order of trustworthiness, so without the ranking the date was decided by whichever file came first. Only JDownloader highlights fall to mtime at all — `parseArchiveFilename` flags those via `dateFromMtime`.
|
||||
|
||||
@@ -85,15 +88,15 @@ Images over 1MiB are downscaled in a Web Worker via `OffscreenCanvas`. The queue
|
||||
|
||||
### Profile tabs (`src/lib/post-tabs.ts`)
|
||||
|
||||
The grid holds **everything**, reels included, and the Reels tab is a *filtered view* of that same set. Only the Reels tab filters. The tabs were mutually exclusive until v1.7.0, which hid a lot: 1100 of `groupfandom`'s 3813 posts and 533 of `for.member`'s 1225 never appeared in the grid at all.
|
||||
The grid holds **everything**, reels included, and the Reels tab is a *filtered view* of that same set. Only the Reels tab filters. The tabs were mutually exclusive until v1.7.0, which hid a lot: 1100 of `loonatheworld`'s 3813 posts and 533 of `for.heejin`'s 1225 never appeared in the grid at all.
|
||||
|
||||
This *approximates* Instagram rather than matching it. Instagram's grid includes a reel only if the creator shared it to feed — a per-post choice, measured live on 2026-08-16: `official_band` had 21 reels in its first 34 grid tiles, `4utumn07` just 1 in 214. That flag appears nowhere in an archive (JD2 stores no metadata, and Instaloader's `product_type` says what a post *is*, not whether it was shared to feed), so showing everything is the closest reachable behaviour. Instagram's "N posts" counter equals its grid, which is why the header counts `postsForTab(allPosts, 'posts')` and not `allPosts` — the raw list still holds both copies of a double-fetched post.
|
||||
This *approximates* Instagram rather than matching it. Instagram's grid includes a reel only if the creator shared it to feed — a per-post choice, measured live on 2026-08-16: `official_artms` had 21 reels in its first 34 grid tiles, `0ct0ber19` just 1 in 214. That flag appears nowhere in an archive (JD2 stores no metadata, and Instaloader's `product_type` says what a post *is*, not whether it was shared to feed), so showing everything is the closest reachable behaviour. Instagram's "N posts" counter equals its grid, which is why the header counts `postsForTab(allPosts, 'posts')` and not `allPosts` — the raw list still holds both copies of a double-fetched post.
|
||||
|
||||
When checking the live site, note that grid reels link to `/reel/<code>/` while ordinary posts link to `/<user>/p/<code>/`. Matching only `/p/` silently drops every reel, which once produced a confident and completely wrong conclusion that Instagram never shows reels in the grid.
|
||||
|
||||
Deciding *what is a reel* has no good answer for most archives. Instagram's own marker is `product_type` on the post's GraphQL node (`clips` = reel, `feed` = ordinary feed video, `igtv`, `story`) — `__typename` is `GraphVideo` for all three, and aspect ratio does not separate them either. But:
|
||||
|
||||
- Only Instaloader archives carry that metadata, and only newer captures. A survey of `hazelofficial` found `product_type` on 1101 of 5919 sidecars, and just **2** posts marked `clips`.
|
||||
- Only Instaloader archives carry that metadata, and only newer captures. A survey of `gibiofficial` found `product_type` on 1101 of 5919 sidecars, and just **2** posts marked `clips`.
|
||||
- JDownloader archives carry none at all — media plus a `.txt` holding the bare caption.
|
||||
|
||||
So the viewer believes a `- reels` sidecar directory when one exists, and otherwise falls back to treating a lone video as a reel. **The fallback is a guess**: it cannot tell a reel from a feed video or an old IGTV upload, and it misses videos inside carousels.
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
# 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
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
https://www.instagram.com/heejin/
|
||||
https://www.instagram.com/0ct0ber19/
|
||||
https://www.instagram.com/kimxxlip/
|
||||
https://www.instagram.com/withaseul/
|
||||
https://www.instagram.com/cher_ryppo/
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
[
|
||||
[
|
||||
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
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -1,181 +0,0 @@
|
||||
[
|
||||
[
|
||||
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
|
||||
}
|
||||
]
|
||||
]
|
||||
+21
-266
@@ -1,10 +1,10 @@
|
||||
# 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.
|
||||
Status: **design + verified config.** `scripts/gdl-sync.py` is a skeleton; no
|
||||
profile has been migrated yet.
|
||||
|
||||
Everything below was measured against the live site and the real archive on
|
||||
2026-08-16 and 2026-08-20, not inferred from documentation.
|
||||
2026-08-16, not inferred from documentation.
|
||||
|
||||
## Why gallery-dl and not a hand-rolled script
|
||||
|
||||
@@ -16,33 +16,10 @@ 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:
|
||||
|
||||
@@ -96,9 +73,6 @@ complaining, the rate-limited one has been unhappy for a while.
|
||||
|
||||
### 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.
|
||||
@@ -166,19 +140,6 @@ 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
|
||||
@@ -309,79 +270,6 @@ viewer retire the lone-video heuristic in `src/lib/post-tabs.ts` — see
|
||||
`/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
|
||||
@@ -391,7 +279,7 @@ means an infrequent full sync costs barely more than a frequent one, because it
|
||||
only fetches what is new.
|
||||
|
||||
```
|
||||
# weekly, everything
|
||||
# monthly, 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
|
||||
@@ -471,71 +359,6 @@ The one trap, which silently seeds almost nothing if you get it backwards:
|
||||
|
||||
`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
|
||||
@@ -549,46 +372,15 @@ 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.
|
||||
In use. `withaseul` has been fetched and published to the live archive — 322
|
||||
files added (74 media, 241 `.json`, 7 `.txt`), nothing overwritten or deleted.
|
||||
Of the 74 new media files, **zero** duplicated media already held under a
|
||||
different name, which is the check that says JD2 and gallery-dl naming really
|
||||
do converge.
|
||||
|
||||
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.
|
||||
a `chown` if that ever matters.
|
||||
|
||||
The profiles to fetch live in `artms_account_links.txt` at the archive root,
|
||||
passed with `--urls-file`.
|
||||
@@ -634,23 +426,7 @@ Re-running with those recovered both videos and produced **0 failures and 0
|
||||
|
||||
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'
|
||||
```
|
||||
429s hit hardest. `pipx install yt-dlp` on the fetch host.
|
||||
|
||||
## Known quirks
|
||||
|
||||
@@ -665,40 +441,19 @@ Verify by asking gallery-dl's own interpreter, not the shell:
|
||||
- **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
|
||||
## Scanner work (not done yet)
|
||||
|
||||
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`:
|
||||
`useArchiveScanner` currently treats any `.json` in the tree as a possible
|
||||
manifest. Adding gallery-dl sidecars needs it to distinguish three things:
|
||||
|
||||
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.
|
||||
1. Instagram export manifests (`posts_1.json`) — existing path.
|
||||
2. Instaloader `.json.xz` — existing path, GraphQL node shape.
|
||||
3. gallery-dl `.json` — new, flat shape, identified by having
|
||||
`post_shortcode` + `type` at the top level.
|
||||
|
||||
`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.
|
||||
Once (3) is read, `source`/`isStory` and the reel flag should come from `type`
|
||||
rather than from the directory and the lone-video heuristic.
|
||||
|
||||
## Test cases
|
||||
|
||||
@@ -722,7 +477,7 @@ Real subjects, all present in the archive today. See
|
||||
| 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 |
|
||||
| 16 | pinned posts | `0ct0ber19` | 3 pinned, returned out of date order |
|
||||
| 17 | profile avatar | `0ct0ber19.jpg` | base dir, undated |
|
||||
|
||||
Cases 14–16 are reconciliation, not naming: **a sync must never delete**, since
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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
-14
@@ -1,17 +1,4 @@
|
||||
# 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
|
||||
> ```
|
||||
# JDownloader2 — archive fetching quick reference
|
||||
|
||||
How content gets into this archive, and why the setup is shaped the way it is.
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "instaarchive-viewer",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "instaarchive-viewer",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
+4
-3
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "instaarchive-viewer",
|
||||
"private": true,
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build && npm run build:server",
|
||||
"build:server": "tsc server.ts --esModuleInterop --module ESNext --target ES2022 --moduleResolution bundler --removeComments --outDir dist-server",
|
||||
"build:server": "tsc server.ts --esModuleInterop --module ESNext --target ES2022 --moduleResolution bundler --outDir dist-server",
|
||||
"preview": "vite preview",
|
||||
"server": "tsx server.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"jd2": "tsx scripts/jd2-sync.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,225 +0,0 @@
|
||||
#!/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
|
||||
+14
-329
@@ -23,79 +23,18 @@ the one that would touch the archive.
|
||||
|
||||
Run it from the host whose public IP matches the browser the cookie came from;
|
||||
using the cookie from elsewhere is what session-hijack detection looks for.
|
||||
|
||||
See `--help` for every flag, with worked examples for each way of selecting
|
||||
what to fetch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
EXAMPLES = """\
|
||||
examples:
|
||||
|
||||
Routine incremental sync of the tracked profiles (what gdl-cron.sh runs) --
|
||||
--abort 50 stops enumerating each profile once it reaches content already
|
||||
held, so a run that has seeded once costs ~40-60 requests, not a full walk:
|
||||
|
||||
./scripts/gdl-sync.py --index https://instaarchive.ergosteur.com \\
|
||||
--staging /var/tmp/gdl --publish user@host:/path/to/archives \\
|
||||
--urls-file artms_account_links.txt --archive-db /var/tmp/gdl.db \\
|
||||
--abort 50 --dry-run
|
||||
# ...then swap --dry-run for --execute once the plan looks right.
|
||||
|
||||
Cheapest possible run -- stories only, the one surface that expires in 24h
|
||||
and cannot be backfilled, so it is worth doing often:
|
||||
|
||||
./scripts/gdl-sync.py --index https://instaarchive.ergosteur.com \\
|
||||
--staging /var/tmp/gdl --publish user@host:/path/to/archives \\
|
||||
--urls-file artms_account_links.txt --only stories --execute
|
||||
|
||||
One profile, by name, without a urls-file:
|
||||
|
||||
./scripts/gdl-sync.py --index /path/to/archives --staging /var/tmp/gdl \\
|
||||
--publish /path/to/archives --profile some_account --execute
|
||||
|
||||
Every profile the archive already knows about (no urls-file, no --profile):
|
||||
|
||||
./scripts/gdl-sync.py --index /path/to/archives --staging /var/tmp/gdl \\
|
||||
--publish /path/to/archives --all --execute
|
||||
|
||||
An arbitrary single post or reel from an account NOT otherwise tracked --
|
||||
e.g. a link someone shared. Filed under its owner like any other post; no
|
||||
--index needed, since there is no profile list to plan against:
|
||||
|
||||
./scripts/gdl-sync.py --staging /var/tmp/gdl --publish user@host:/path \\
|
||||
--post-url https://www.instagram.com/p/SHORTCODE/ --execute
|
||||
|
||||
Full sweep -- no --abort, walks every profile to the end. The only run that
|
||||
notices a carousel edited after it was archived, and by far the most
|
||||
expensive thing here (~420 requests for six profiles). Read docs/gallery-dl.md
|
||||
and TOOLING.md before running this one:
|
||||
|
||||
./scripts/gdl-sync.py --index https://instaarchive.ergosteur.com \\
|
||||
--staging /var/tmp/gdl --publish user@host:/path/to/archives \\
|
||||
--urls-file artms_account_links.txt --execute
|
||||
|
||||
Hand-paced caution after a scraping warning (roughly double the defaults;
|
||||
see docs/gallery-dl.md for where these numbers come from):
|
||||
|
||||
./scripts/gdl-sync.py ... --sleep-request 12 20 --sleep 5 10 --rate 500K
|
||||
|
||||
Always --dry-run first (the default): it prints the plan and the rsync
|
||||
command that would publish, without spending a single Instagram request.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -222,23 +161,6 @@ def read_urls_file(path: Path) -> list[str]:
|
||||
return users
|
||||
|
||||
|
||||
def read_post_urls_file(path: Path) -> list[str]:
|
||||
"""
|
||||
Read individual post/reel URLs from a file, one per line -- the output
|
||||
format `instagram-chrome-scrape.py` writes. Blank lines and `#` comments are skipped;
|
||||
order is kept and duplicates dropped, same conventions as read_urls_file.
|
||||
"""
|
||||
urls: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in path.read_text().splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line or line in seen:
|
||||
continue
|
||||
seen.add(line)
|
||||
urls.append(line)
|
||||
return urls
|
||||
|
||||
|
||||
class ArchiveIndex:
|
||||
"""
|
||||
What the archive already holds, as filenames only.
|
||||
@@ -297,7 +219,7 @@ class ArchiveIndex:
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_config(rate: str, sleep_request: list[float],
|
||||
sleep: list[float], abort: int = 0) -> dict:
|
||||
sleep: list[float]) -> dict:
|
||||
"""
|
||||
The config is generated rather than checked in so the safety-critical
|
||||
options cannot drift out of sync with the docs.
|
||||
@@ -326,57 +248,12 @@ def build_config(rate: str, sleep_request: list[float],
|
||||
"post_shortcode", "post_id", "type", "date", "post_date",
|
||||
"username", "fullname", "owner_id", "description", "count",
|
||||
"likes", "post_url", "sidecar_shortcode",
|
||||
# A Collab's `username`/`fullname`/`owner_id` above are always the
|
||||
# original poster's, never the scraped account's -- see
|
||||
# docs/gallery-dl.md. `coauthors` is the API's own list of every
|
||||
# OTHER collaborator (it excludes the post's owner), a direct
|
||||
# signal instead of inferring a collab from identity mismatches.
|
||||
"coauthors",
|
||||
],
|
||||
}
|
||||
# Per-carousel-item fields (`width`/`height`/`tagged_users`/`owner`) live
|
||||
# on the per-FILE kwdict, not the per-post one `meta_pp` reads -- a
|
||||
# carousel's items can each have different dimensions and tags, which one
|
||||
# post-level JSON can't represent. `owner` is deliberately left out: it is
|
||||
# a full user object (profile pic URLs, privacy flags) for whoever posted
|
||||
# that specific item, the same reason `audio_user` is excluded above.
|
||||
media_pp = {
|
||||
"name": "metadata",
|
||||
"mode": "json",
|
||||
# event defaults to "file" when omitted -- runs once per downloaded
|
||||
# file, appending ".json" to that file's own full name, e.g.
|
||||
# "... - 01.jpg" gets "... - 01.jpg.json" alongside it. Never
|
||||
# collides with the post-level "....json" above, which has no
|
||||
# per-item number.
|
||||
"include": [
|
||||
"media_id", "shortcode", "num",
|
||||
"width", "height", "width_original", "height_original",
|
||||
"tagged_users",
|
||||
],
|
||||
}
|
||||
|
||||
def post_like(stem: str) -> dict:
|
||||
"""Naming for surfaces whose unit is a post (posts, reels)."""
|
||||
skip: dict = {}
|
||||
if abort:
|
||||
# Stop enumerating once `abort` consecutive files are already in
|
||||
# the skip-archive. The listing pass -- not the downloading -- is
|
||||
# what costs `instagram.com` requests, and it otherwise walks the
|
||||
# whole profile every run to find three new posts.
|
||||
#
|
||||
# Safe here only because the REST listing is strictly
|
||||
# reverse-chronological: the web grid hoists pinned posts to the
|
||||
# front, but this endpoint does not (measured 2026-08-20), so old
|
||||
# posts never appear before new ones.
|
||||
#
|
||||
# Counted in FILES, not posts, so it must clear the largest
|
||||
# already-held carousel -- 22 media for one real post in this
|
||||
# archive. It also means edited carousels (test case 15) stop
|
||||
# being noticed, so a full sweep is still worth running
|
||||
# occasionally.
|
||||
skip["skip"] = f"abort:{abort}"
|
||||
return {
|
||||
**skip,
|
||||
# `sidecar_shortcode` is set only for carousels, so it is the
|
||||
# carousel discriminator. First matching condition wins.
|
||||
"filename": {
|
||||
@@ -390,7 +267,6 @@ def build_config(rate: str, sleep_request: list[float],
|
||||
"postprocessors": [
|
||||
{**caption_pp, "filename": stem + ".txt"},
|
||||
{**meta_pp, "filename": stem + ".json"},
|
||||
media_pp,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -436,12 +312,6 @@ def build_config(rate: str, sleep_request: list[float],
|
||||
"directory": [],
|
||||
"posts": post_like(POST_STEM),
|
||||
"reels": post_like(POST_STEM),
|
||||
# Ad hoc single-post fetches (--post-url) go through gallery-dl's
|
||||
# own post/reel extractor instead of a profile listing, so the
|
||||
# owning account is never known ahead of time -- only mid-
|
||||
# extraction, same reasoning as highlights below.
|
||||
"post": {**post_like(POST_STEM), "directory": ["{username}"]},
|
||||
"reel": {**post_like(POST_STEM), "directory": ["{username}"]},
|
||||
"stories": item_like(ITEM_STEM),
|
||||
"highlights": {
|
||||
**item_like(ITEM_STEM),
|
||||
@@ -461,71 +331,6 @@ def build_config(rate: str, sleep_request: list[float],
|
||||
# Planning and execution
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _fmt_elapsed(sec: float) -> str:
|
||||
sec = int(sec)
|
||||
h, rest = divmod(sec, 3600)
|
||||
m, s = divmod(rest, 60)
|
||||
return f"{h}h{m:02d}m" if h else (f"{m}m{s:02d}s" if m else f"{s}s")
|
||||
|
||||
|
||||
def _count_new(root: Path, since: float) -> tuple[int, str | None]:
|
||||
"""Media files under `root` written since `since`, and the newest one's name."""
|
||||
n, newest, newest_t = 0, None, 0.0
|
||||
for d, _dirs, files in os.walk(root):
|
||||
for f in files:
|
||||
if f.endswith((".txt", ".json", ".part")):
|
||||
continue
|
||||
try:
|
||||
mt = os.stat(os.path.join(d, f)).st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if mt >= since:
|
||||
n += 1
|
||||
if mt > newest_t:
|
||||
newest, newest_t = f, mt
|
||||
return n, newest
|
||||
|
||||
|
||||
class Heartbeat:
|
||||
"""
|
||||
Prints a progress line every `every` seconds while a quiet step runs.
|
||||
|
||||
A healthy run looks hung otherwise: pacing is 12-20s between requests, the
|
||||
generated config sets `output: null` (no per-file lines), and the live
|
||||
listing probe captures gallery-dl output until it finishes. With `watch`
|
||||
set, the line also reports how many files have landed there so far.
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, watch: Path | None = None, every: float = 60.0):
|
||||
self.label, self.watch, self.every = label, watch, every
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.t0 = time.time()
|
||||
|
||||
def _run(self) -> None:
|
||||
while not self._stop.wait(self.every):
|
||||
msg = f" ... {self.label}: {_fmt_elapsed(time.time() - self.t0)} elapsed"
|
||||
if self.watch is not None:
|
||||
n, newest = _count_new(self.watch, self.t0)
|
||||
msg += f", {n} new file(s) so far"
|
||||
if newest:
|
||||
msg += f" (latest: {newest})"
|
||||
print(msg, flush=True)
|
||||
|
||||
def __enter__(self) -> "Heartbeat":
|
||||
self.t0 = time.time()
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self._stop.set()
|
||||
self._thread.join()
|
||||
|
||||
@property
|
||||
def elapsed(self) -> float:
|
||||
return time.time() - self.t0
|
||||
|
||||
|
||||
def gdl_command(src: Source, staging: Path, config: Path, cookies: str,
|
||||
archive_db: Path | None) -> list[str]:
|
||||
cmd = [
|
||||
@@ -539,11 +344,9 @@ def gdl_command(src: Source, staging: Path, config: Path, cookies: str,
|
||||
cmd += ["--download-archive", str(archive_db)]
|
||||
# Forced destination -- never `{username}` -- because a reels tab returns
|
||||
# collab reels owned by other accounts, which would otherwise be filed
|
||||
# under the wrong profile. Highlights and ad hoc single-post fetches are
|
||||
# the exception: their directory is only known mid-extraction (a title, or
|
||||
# the post's owner), so the config formats it instead.
|
||||
dest = (staging if src.subcategory in ("highlights", "post", "reel")
|
||||
else staging / src.directory)
|
||||
# under the wrong profile. Highlights are the exception: their directory
|
||||
# embeds a title only known mid-extraction, so the config formats it.
|
||||
dest = staging if src.subcategory == "highlights" else staging / src.directory
|
||||
cmd += ["--destination", str(dest)]
|
||||
cmd.append(src.url)
|
||||
return cmd
|
||||
@@ -814,82 +617,7 @@ def publish(staging: Path, dest: str, dry_run: bool) -> int:
|
||||
return 0
|
||||
cmd = rsync_command(staging, dest, dry_run)
|
||||
print(" " + " ".join(cmd))
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
sys.stdout.write(result.stdout)
|
||||
sys.stderr.write(result.stderr)
|
||||
if result.returncode == 0:
|
||||
return 0
|
||||
|
||||
# The archive share is never owned by the ssh user, so rsync's `chown`
|
||||
# step fails on every single run -- exit 23 ("some files/attrs were not
|
||||
# transferred"), even though every file landed. This is long documented
|
||||
# (TOOLING.md "Reading the output") as something to read past, not a
|
||||
# real failure -- but until now the exit CODE didn't know that, so it
|
||||
# was 1 on every clean run too, silently. Only mask it when every rsync
|
||||
# error line is that specific permission warning; anything else (a real
|
||||
# transfer failure sharing the same exit code) still fails loudly.
|
||||
err_lines = [l for l in result.stderr.splitlines() if l.startswith("rsync:")]
|
||||
if (result.returncode == 23 and err_lines
|
||||
and all("failed to set permissions" in l for l in err_lines)):
|
||||
print(" (rsync exit 23 was only the expected chown-permission "
|
||||
"warning; files landed)")
|
||||
return 0
|
||||
return result.returncode
|
||||
|
||||
|
||||
def run_post_urls(args) -> int:
|
||||
"""
|
||||
Fetch one or more individual posts/reels by URL -- an ad hoc pull outside
|
||||
the tracked profile list, e.g. a link shared from some other account. Each
|
||||
is a single request, not a recurring surface, so there is no archive-db
|
||||
seeding and no --min-interval floor to plan around.
|
||||
"""
|
||||
config = build_config(args.rate, list(args.sleep_request),
|
||||
list(args.sleep), abort=0)
|
||||
args.staging.mkdir(parents=True, exist_ok=True)
|
||||
config_path = args.staging.parent / f"{args.staging.name}.gdl-config.json"
|
||||
|
||||
sources = [Source("post", url, "", "post") for url in args.post_url]
|
||||
|
||||
print(f"post-url : {len(sources)} to fetch")
|
||||
print(f"pacing : {args.sleep_request[0]}-{args.sleep_request[1]}s between "
|
||||
f"requests, rate cap {args.rate}")
|
||||
print(f"staging : {args.staging}")
|
||||
print(f"publish : {args.publish}")
|
||||
print()
|
||||
|
||||
if not args.execute:
|
||||
for src in sources:
|
||||
print(f" {src.url}")
|
||||
print()
|
||||
print(" " + " ".join(rsync_command(args.staging, args.publish, True)))
|
||||
print("\ndry run; nothing fetched. pass --execute to run.")
|
||||
return 0
|
||||
|
||||
config_path.write_text(json.dumps(config, indent=2))
|
||||
failures = 0
|
||||
for i, src in enumerate(sources):
|
||||
# gallery-dl's own sleep-request only paces requests INSIDE one
|
||||
# invocation; each URL here is its own subprocess, so back-to-back
|
||||
# items would otherwise fire with no gap at all -- the same pacing
|
||||
# applied here as between requests within a single fetch.
|
||||
if i:
|
||||
pause = random.uniform(*args.sleep_request)
|
||||
time.sleep(pause)
|
||||
print(f"==> {src.url}")
|
||||
cmd = gdl_command(src, args.staging, config_path, args.cookies,
|
||||
args.archive_db)
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
failures += 1
|
||||
print(f" FAILED (exit {result.returncode})", file=sys.stderr)
|
||||
|
||||
print("\n==> publish")
|
||||
if publish(args.staging, args.publish, dry_run=False) != 0:
|
||||
failures += 1
|
||||
|
||||
print(f"\ndone; {failures} step(s) failed")
|
||||
return 1 if failures else 0
|
||||
return subprocess.run(cmd).returncode
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -900,13 +628,12 @@ def main() -> int:
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
sys.stderr.reconfigure(line_buffering=True)
|
||||
|
||||
ap = argparse.ArgumentParser(description=__doc__, epilog=EXAMPLES,
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--index",
|
||||
ap.add_argument("--index", required=True,
|
||||
help="existing archive listing: a local root, or the "
|
||||
"viewer's base URL (only a FILE LISTING is needed, "
|
||||
"never the contents). Required unless --post-url/"
|
||||
"--post-urls-file")
|
||||
"never the contents)")
|
||||
ap.add_argument("--publish", required=True,
|
||||
help="rsync destination for fetched files; a local path or "
|
||||
"user@host:/path")
|
||||
@@ -919,17 +646,6 @@ def main() -> int:
|
||||
g.add_argument("--urls-file", type=Path,
|
||||
help="file of Instagram profile URLs or usernames, one per "
|
||||
"line; # comments and blank lines allowed")
|
||||
g.add_argument("--post-url", action="append", default=[],
|
||||
help="fetch one post or reel by URL (e.g. "
|
||||
"https://www.instagram.com/p/SHORTCODE/), filed under "
|
||||
"its owner's account like any other post; repeatable. "
|
||||
"A one-off fetch outside the tracked profile list: no "
|
||||
"archive-db seeding, no --min-interval floor")
|
||||
g.add_argument("--post-urls-file", type=Path,
|
||||
help="file of post/reel URLs, one per line; # comments and "
|
||||
"blank lines allowed. Same handling as --post-url, "
|
||||
"paced with --sleep-request between items. This is "
|
||||
"the format instagram-chrome-scrape.py writes")
|
||||
ap.add_argument("--cookies", default="chrome:/home/matt/.config/google-chrome-devtools",
|
||||
help="gallery-dl --cookies-from-browser value")
|
||||
ap.add_argument("--archive-db", type=Path, default=None,
|
||||
@@ -950,12 +666,6 @@ def main() -> int:
|
||||
ap.add_argument("--max-sources", type=int, default=0, metavar="N",
|
||||
help="hard ceiling on sources touched in one run "
|
||||
"(0 = no limit)")
|
||||
ap.add_argument("--abort", type=int, default=0, metavar="N",
|
||||
help="stop enumerating posts/reels after N consecutive "
|
||||
"already-archived FILES (0 = walk everything, the "
|
||||
"default). 50 is a safe routine value; it cuts the "
|
||||
"per-run listing cost by roughly 85%%, at the price "
|
||||
"of no longer noticing edited carousels")
|
||||
ap.add_argument("--probe-ttl", type=float, default=24.0, metavar="HOURS",
|
||||
help="reuse cached listing results younger than this")
|
||||
ap.add_argument("--force", action="store_true",
|
||||
@@ -974,22 +684,6 @@ def main() -> int:
|
||||
print("rsync not on PATH", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.post_urls_file:
|
||||
if not args.post_urls_file.is_file():
|
||||
print(f"post-urls file not found: {args.post_urls_file}", file=sys.stderr)
|
||||
return 2
|
||||
args.post_url = read_post_urls_file(args.post_urls_file)
|
||||
if not args.post_url:
|
||||
print(f"no usable URLs in {args.post_urls_file}", file=sys.stderr)
|
||||
return 2
|
||||
if args.post_url:
|
||||
return run_post_urls(args)
|
||||
|
||||
if not args.index:
|
||||
print("--index is required unless --post-url/--post-urls-file is given",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
index = ArchiveIndex(args.index)
|
||||
names = index.profiles()
|
||||
if args.urls_file:
|
||||
@@ -1010,8 +704,7 @@ def main() -> int:
|
||||
else:
|
||||
selected = [Profile(p) for p in sorted(names)]
|
||||
|
||||
config = build_config(args.rate, list(args.sleep_request),
|
||||
list(args.sleep), args.abort)
|
||||
config = build_config(args.rate, list(args.sleep_request), list(args.sleep))
|
||||
args.staging.mkdir(parents=True, exist_ok=True)
|
||||
# Deliberately a SIBLING of the staging directory, not inside it: staging is
|
||||
# rsynced wholesale into the archive, and a dry run caught this file being
|
||||
@@ -1069,9 +762,8 @@ def main() -> int:
|
||||
0.0 if args.force else args.probe_ttl)
|
||||
failures = 0
|
||||
|
||||
for n_src, (prof, src, seed) in enumerate(plan, 1):
|
||||
print(f"==> [{n_src}/{len(plan)}] {prof.user} / {src.kind} "
|
||||
f"({dt.datetime.now().strftime('%H:%M:%S')})")
|
||||
for prof, src, seed in plan:
|
||||
print(f"==> {prof.user} / {src.kind}")
|
||||
stage_dir = args.staging / (src.directory or ".")
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1083,10 +775,7 @@ def main() -> int:
|
||||
try:
|
||||
live = probes.get(src.url, now_ts)
|
||||
if live is None:
|
||||
print(" listing live items (metadata only; quiet until done)...")
|
||||
with Heartbeat("listing") as hb:
|
||||
live = probe_live(src, config_path, args.cookies)
|
||||
print(f" listed {len(live)} item(s) in {_fmt_elapsed(hb.elapsed)}")
|
||||
live = probe_live(src, config_path, args.cookies)
|
||||
probes.put(src.url, live, stamp)
|
||||
probes.save()
|
||||
else:
|
||||
@@ -1104,16 +793,12 @@ def main() -> int:
|
||||
|
||||
cmd = gdl_command(src, args.staging, config_path, args.cookies,
|
||||
args.archive_db)
|
||||
with Heartbeat("fetching", watch=stage_dir) as hb:
|
||||
result = subprocess.run(cmd)
|
||||
n_new, _ = _count_new(stage_dir, hb.t0)
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
failures += 1
|
||||
# Keep going: one private or renamed profile must not abort the run.
|
||||
print(f" FAILED (exit {result.returncode}) after "
|
||||
f"{_fmt_elapsed(hb.elapsed)}", file=sys.stderr)
|
||||
print(f" FAILED (exit {result.returncode})", file=sys.stderr)
|
||||
else:
|
||||
print(f" done in {_fmt_elapsed(hb.elapsed)}, {n_new} new file(s)")
|
||||
# Recorded even for an empty fetch: the request was still spent.
|
||||
state.mark_fetched(src.url, stamp)
|
||||
state.save()
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,306 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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" "$@"
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/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" "$@"
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,16 +0,0 @@
|
||||
[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
|
||||
@@ -1,18 +0,0 @@
|
||||
[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
|
||||
@@ -1,18 +0,0 @@
|
||||
[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
|
||||
@@ -1,15 +0,0 @@
|
||||
[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
|
||||
@@ -1,9 +0,0 @@
|
||||
[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
|
||||
@@ -1,12 +0,0 @@
|
||||
[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
|
||||
@@ -12,12 +12,10 @@ 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"))
|
||||
@@ -210,102 +208,6 @@ class Publishing(unittest.TestCase):
|
||||
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):
|
||||
@@ -325,61 +227,5 @@ class UrlsFile(unittest.TestCase):
|
||||
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)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -3,39 +3,39 @@ import { classifyDirectory, groupArchiveDirectories } from './archive-grouping';
|
||||
|
||||
describe('classifyDirectory', () => {
|
||||
it('treats a bare profile directory as the base', () => {
|
||||
expect(classifyDirectory('4utumn07')).toEqual({
|
||||
owner: '4utumn07',
|
||||
source: { kind: 'posts', dir: '4utumn07' },
|
||||
expect(classifyDirectory('0ct0ber19')).toEqual({
|
||||
owner: '0ct0ber19',
|
||||
source: { kind: 'posts', dir: '0ct0ber19' },
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a reels sidecar', () => {
|
||||
expect(classifyDirectory('4utumn07 - reels')).toEqual({
|
||||
owner: '4utumn07',
|
||||
source: { kind: 'reels', dir: '4utumn07 - reels' },
|
||||
expect(classifyDirectory('0ct0ber19 - reels')).toEqual({
|
||||
owner: '0ct0ber19',
|
||||
source: { kind: 'reels', dir: '0ct0ber19 - reels' },
|
||||
});
|
||||
});
|
||||
|
||||
it('recognises a stories sidecar', () => {
|
||||
expect(classifyDirectory('story - dawn_petal')).toEqual({
|
||||
owner: 'dawn_petal',
|
||||
source: { kind: 'stories', dir: 'story - dawn_petal' },
|
||||
expect(classifyDirectory('story - cher_ryppo')).toEqual({
|
||||
owner: 'cher_ryppo',
|
||||
source: { kind: 'stories', dir: 'story - cher_ryppo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('splits highlight owner from title', () => {
|
||||
const { owner, source } = classifyDirectory('story highlights - 4utumn07 - Sunstory');
|
||||
expect(owner).toBe('4utumn07');
|
||||
const { owner, source } = classifyDirectory('story highlights - 0ct0ber19 - Heestory');
|
||||
expect(owner).toBe('0ct0ber19');
|
||||
expect(source.kind).toBe('highlight');
|
||||
expect(source.title).toBe('Sunstory');
|
||||
expect(source.title).toBe('Heestory');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['story highlights - theoldlyricmuseinsta - 💙1999-2005 era', 'theoldlyricmuseinsta', '💙1999-2005 era'],
|
||||
['story highlights - member_theworld - [Bracket]', 'member_theworld', '[Bracket]'],
|
||||
['story highlights - official_band - Tour Schedule', 'official_band', 'Tour Schedule'],
|
||||
['story highlights - 4utumn07 - Sketching⠀', '4utumn07', 'Sketching⠀'],
|
||||
['story highlights - official_band - A.B.C', 'official_band', 'A.B.C'],
|
||||
['story highlights - theoldtaylorswiftinsta - 💙2014-1989 era', 'theoldtaylorswiftinsta', '💙2014-1989 era'],
|
||||
['story highlights - heejin_theworld - [Dall]', 'heejin_theworld', '[Dall]'],
|
||||
['story highlights - official_artms - Cosmo Schedule', 'official_artms', 'Cosmo Schedule'],
|
||||
['story highlights - 0ct0ber19 - Drawheeing⠀', '0ct0ber19', 'Drawheeing⠀'],
|
||||
['story highlights - official_artms - G.C.I', 'official_artms', 'G.C.I'],
|
||||
])('handles real-world title %s', (dir, owner, title) => {
|
||||
const result = classifyDirectory(dir);
|
||||
expect(result.owner).toBe(owner);
|
||||
@@ -57,25 +57,25 @@ describe('classifyDirectory', () => {
|
||||
|
||||
describe('groupArchiveDirectories', () => {
|
||||
const dirs = [
|
||||
'4utumn07',
|
||||
'4utumn07 - reels',
|
||||
'story - 4utumn07',
|
||||
'story highlights - 4utumn07 - Sunstory',
|
||||
'story highlights - 4utumn07 - Sketching⠀',
|
||||
'kestrelsings',
|
||||
'0ct0ber19',
|
||||
'0ct0ber19 - reels',
|
||||
'story - 0ct0ber19',
|
||||
'story highlights - 0ct0ber19 - Heestory',
|
||||
'story highlights - 0ct0ber19 - Drawheeing⠀',
|
||||
'carlyraejepsen',
|
||||
];
|
||||
|
||||
it('folds sidecars into their base profile', () => {
|
||||
const groups = groupArchiveDirectories(dirs);
|
||||
expect([...groups.keys()].sort()).toEqual(['4utumn07', 'kestrelsings']);
|
||||
expect(groups.get('4utumn07')).toHaveLength(5);
|
||||
expect(groups.get('kestrelsings')).toHaveLength(1);
|
||||
expect([...groups.keys()].sort()).toEqual(['0ct0ber19', 'carlyraejepsen']);
|
||||
expect(groups.get('0ct0ber19')).toHaveLength(5);
|
||||
expect(groups.get('carlyraejepsen')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('orders sources posts, reels, stories, then highlights by title', () => {
|
||||
const sources = groupArchiveDirectories(dirs).get('4utumn07')!;
|
||||
const sources = groupArchiveDirectories(dirs).get('0ct0ber19')!;
|
||||
expect(sources.map(s => s.kind)).toEqual(['posts', 'reels', 'stories', 'highlight', 'highlight']);
|
||||
expect(sources.slice(3).map(s => s.title)).toEqual(['Sketching⠀', 'Sunstory']);
|
||||
expect(sources.slice(3).map(s => s.title)).toEqual(['Drawheeing⠀', 'Heestory']);
|
||||
});
|
||||
|
||||
it('still groups a sidecar whose base profile is missing', () => {
|
||||
|
||||
@@ -18,10 +18,10 @@ export interface ArchiveSource {
|
||||
/**
|
||||
* Sidecar directories sit next to the profile directory they belong to:
|
||||
*
|
||||
* 4utumn07 -> posts (base)
|
||||
* 4utumn07 - reels -> reels
|
||||
* story - 4utumn07 -> stories
|
||||
* story highlights - 4utumn07 - Sunstory -> highlight "Sunstory"
|
||||
* 0ct0ber19 -> posts (base)
|
||||
* 0ct0ber19 - reels -> reels
|
||||
* story - 0ct0ber19 -> stories
|
||||
* story highlights - 0ct0ber19 - Heestory -> highlight "Heestory"
|
||||
*
|
||||
* Instagram usernames cannot contain spaces, so matching the username as a
|
||||
* run of non-space characters reliably separates it from a highlight title
|
||||
|
||||
@@ -14,11 +14,11 @@ describe('isSystemDirectory', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
'4utumn07',
|
||||
'4utumn07 - reels',
|
||||
'story - dawn_petal',
|
||||
'story highlights - official_band - A.B.C',
|
||||
'story highlights - theoldlyricmuseinsta - 💙1999-2005 era',
|
||||
'0ct0ber19',
|
||||
'0ct0ber19 - reels',
|
||||
'story - cher_ryppo',
|
||||
'story highlights - official_artms - G.C.I',
|
||||
'story highlights - theoldtaylorswiftinsta - 💙2014-1989 era',
|
||||
'Heejin_Bubble heejinmedia',
|
||||
'gallery-dl',
|
||||
'posts',
|
||||
|
||||
@@ -3,10 +3,10 @@ import { canonicalItemId, parseArchiveFilename, scopedPostId } from './archive-p
|
||||
|
||||
describe('parseArchiveFilename — Instagram export format', () => {
|
||||
it('parses a single-image post', () => {
|
||||
expect(parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')).toEqual({
|
||||
expect(parseArchiveFilename('2023-04-19_0ct0ber19 - CrORBIcJJbM.mp4')).toEqual({
|
||||
postId: 'CrORBIcJJbM',
|
||||
date: '2023-04-19',
|
||||
username: '4utumn07',
|
||||
username: '0ct0ber19',
|
||||
index: 1,
|
||||
ext: 'mp4',
|
||||
isStory: false,
|
||||
@@ -15,7 +15,7 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
});
|
||||
|
||||
it('parses a carousel slide index', () => {
|
||||
const parsed = parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE - 3.jpg');
|
||||
const parsed = parseArchiveFilename('2023-04-12_0ct0ber19 - Cq8LrxSJAJE - 3.jpg');
|
||||
expect(parsed).toMatchObject({ postId: 'Cq8LrxSJAJE', index: 3, ext: 'jpg' });
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
});
|
||||
|
||||
it('parses caption sidecar files', () => {
|
||||
expect(parseArchiveFilename('2023-04-12_4utumn07 - Cq8LrxSJAJE.txt')).toMatchObject({
|
||||
expect(parseArchiveFilename('2023-04-12_0ct0ber19 - Cq8LrxSJAJE.txt')).toMatchObject({
|
||||
postId: 'Cq8LrxSJAJE',
|
||||
ext: 'txt',
|
||||
});
|
||||
@@ -39,8 +39,8 @@ describe('parseArchiveFilename — Instagram export format', () => {
|
||||
|
||||
it('parses the story sidecar layout (date_user - N - shortcode)', () => {
|
||||
// Files in `story - <user>` carry a per-day ordinal before the shortcode.
|
||||
const parsed = parseArchiveFilename('2025-10-26_4utumn07 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
||||
expect(parsed).toMatchObject({ date: '2025-10-26', username: '4utumn07', ext: 'jpg' });
|
||||
const parsed = parseArchiveFilename('2025-10-26_0ct0ber19 - 2 - DQRuDx9iW5Q.jpg', 'stories');
|
||||
expect(parsed).toMatchObject({ date: '2025-10-26', username: '0ct0ber19', ext: 'jpg' });
|
||||
expect(parsed!.postId).toContain('DQRuDx9iW5Q');
|
||||
});
|
||||
|
||||
@@ -71,9 +71,9 @@ describe('parseArchiveFilename — Instaloader format', () => {
|
||||
|
||||
describe('parseArchiveFilename — story highlights', () => {
|
||||
it('parses the dateless highlight layout', () => {
|
||||
expect(parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
||||
expect(parseArchiveFilename('0ct0ber19 - C5dQPEYpd9W.mp4', 'highlight')).toMatchObject({
|
||||
postId: 'C5dQPEYpd9W',
|
||||
username: '4utumn07',
|
||||
username: '0ct0ber19',
|
||||
ext: 'mp4',
|
||||
isStory: false,
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe('parseArchiveFilename — story highlights', () => {
|
||||
});
|
||||
|
||||
describe('parseArchiveFilename — non-matching files', () => {
|
||||
it.each(['4utumn07.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
||||
it.each(['0ct0ber19.jpg', 'profile_pic.jpg', 'README.md', 'no-separator.png'])(
|
||||
'returns null for %s',
|
||||
name => expect(parseArchiveFilename(name)).toBeNull(),
|
||||
);
|
||||
@@ -107,8 +107,8 @@ describe('scopedPostId', () => {
|
||||
});
|
||||
|
||||
it('namespaces sidecar ids by directory', () => {
|
||||
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Sunstory'))
|
||||
.toBe('story highlights - u - Sunstory/C5dQ');
|
||||
expect(scopedPostId('C5dQ', 'highlight', 'story highlights - u - Heestory'))
|
||||
.toBe('story highlights - u - Heestory/C5dQ');
|
||||
});
|
||||
|
||||
it('keeps the same shortcode distinct across sources', () => {
|
||||
@@ -125,8 +125,8 @@ describe('scopedPostId', () => {
|
||||
*/
|
||||
describe('gallery-dl / JDownloader naming interop', () => {
|
||||
it('treats a single-media post the same with or without an index', () => {
|
||||
const jd2 = parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM.mp4')!;
|
||||
const gdl = parseArchiveFilename('2023-04-19_4utumn07 - CrORBIcJJbM - 1.mp4')!;
|
||||
const jd2 = parseArchiveFilename('2023-04-19_0ct0ber19 - CrORBIcJJbM.mp4')!;
|
||||
const gdl = parseArchiveFilename('2023-04-19_0ct0ber19 - CrORBIcJJbM - 1.mp4')!;
|
||||
expect(jd2.postId).toBe(gdl.postId);
|
||||
expect(jd2.index).toBe(gdl.index);
|
||||
expect(jd2.index).toBe(1);
|
||||
@@ -136,21 +136,21 @@ describe('gallery-dl / JDownloader naming interop', () => {
|
||||
// JD2 pads to the width of the media count (10+ items -> "01"), and
|
||||
// gallery-dl's count can be one higher, so the same post may be padded
|
||||
// by one tool and not the other.
|
||||
expect(parseArchiveFilename('2024-04-17_4utumn07 - C53YPQzp7Wj - 09.jpg')!.index).toBe(9);
|
||||
expect(parseArchiveFilename('2024-04-17_4utumn07 - C53YPQzp7Wj - 9.jpg')!.index).toBe(9);
|
||||
expect(parseArchiveFilename('2023-11-03_4utumn07 - CzM8Uf6B6H_ - 01.jpg')!.index).toBe(1);
|
||||
expect(parseArchiveFilename('2024-04-17_0ct0ber19 - C53YPQzp7Wj - 09.jpg')!.index).toBe(9);
|
||||
expect(parseArchiveFilename('2024-04-17_0ct0ber19 - C53YPQzp7Wj - 9.jpg')!.index).toBe(9);
|
||||
expect(parseArchiveFilename('2023-11-03_0ct0ber19 - CzM8Uf6B6H_ - 01.jpg')!.index).toBe(1);
|
||||
});
|
||||
|
||||
it('reads a gallery-dl story name, which carries a per-item shortcode', () => {
|
||||
const p = parseArchiveFilename('2026-08-16_official_band - DcF9OyhBJ1H.jpg', 'stories')!;
|
||||
const p = parseArchiveFilename('2026-08-16_official_artms - DcF9OyhBJ1H.jpg', 'stories')!;
|
||||
expect(p.postId).toBe('DcF9OyhBJ1H');
|
||||
expect(p.date).toBe('2026-08-16');
|
||||
});
|
||||
|
||||
it('gives a dated highlight a real date instead of the mtime fallback', () => {
|
||||
const mtime = Date.parse('2026-08-17T00:00:00Z');
|
||||
const undated = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const dated = parseArchiveFilename('2024-08-04_4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const undated = parseArchiveFilename('0ct0ber19 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const dated = parseArchiveFilename('2024-08-04_0ct0ber19 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
// Same item either way, so re-fetching cannot split it into two posts.
|
||||
expect(dated.postId).toBe(undated.postId);
|
||||
expect(undated.date).toBe('2026-08-17');
|
||||
@@ -167,13 +167,13 @@ describe('dateFromMtime', () => {
|
||||
const mtime = Date.parse('2026-08-17T00:00:00Z');
|
||||
|
||||
it('flags an undated highlight name as mtime-dated', () => {
|
||||
const p = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const p = parseArchiveFilename('0ct0ber19 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
expect(p.date).toBe('2026-08-17');
|
||||
expect(p.dateFromMtime).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag a highlight that carries its own date', () => {
|
||||
const p = parseArchiveFilename('2024-08-04_4utumn07 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
const p = parseArchiveFilename('2024-08-04_0ct0ber19 - C-IImhvpFuk.jpg', 'highlight', mtime)!;
|
||||
expect(p.date).toBe('2024-08-04');
|
||||
expect(p.dateFromMtime).toBe(false);
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe('dateFromMtime', () => {
|
||||
});
|
||||
|
||||
it('leaves the date empty rather than guessing when no mtime is given', () => {
|
||||
const p = parseArchiveFilename('4utumn07 - C-IImhvpFuk.jpg', 'highlight')!;
|
||||
const p = parseArchiveFilename('0ct0ber19 - C-IImhvpFuk.jpg', 'highlight')!;
|
||||
expect(p.date).toBe('');
|
||||
expect(p.dateFromMtime).toBe(false);
|
||||
});
|
||||
@@ -196,9 +196,9 @@ describe('dateFromMtime', () => {
|
||||
*/
|
||||
describe('canonicalItemId', () => {
|
||||
it('collapses the two highlight naming conventions onto one id', () => {
|
||||
const dir = 'story highlights - 4utumn07 - Sunstory';
|
||||
const undated = parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight', 1)!;
|
||||
const dated = parseArchiveFilename('2024-04-07_4utumn07 - 01 - C5dQPEYpd9W.mp4', 'highlight')!;
|
||||
const dir = 'story highlights - 0ct0ber19 - Heestory';
|
||||
const undated = parseArchiveFilename('0ct0ber19 - C5dQPEYpd9W.mp4', 'highlight', 1)!;
|
||||
const dated = parseArchiveFilename('2024-04-07_0ct0ber19 - 01 - C5dQPEYpd9W.mp4', 'highlight')!;
|
||||
expect(scopedPostId(dated.postId, 'highlight', dir))
|
||||
.toBe(scopedPostId(undated.postId, 'highlight', dir));
|
||||
});
|
||||
@@ -216,7 +216,7 @@ describe('canonicalItemId', () => {
|
||||
});
|
||||
|
||||
it('leaves a shortcode that merely starts with digits alone', () => {
|
||||
expect(canonicalItemId('4utumn07')).toBe('4utumn07');
|
||||
expect(canonicalItemId('0ct0ber19')).toBe('0ct0ber19');
|
||||
expect(canonicalItemId('C5dQPEYpd9W')).toBe('C5dQPEYpd9W');
|
||||
expect(canonicalItemId('12345')).toBe('12345');
|
||||
});
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
const REEL: GalleryDlSidecar = {
|
||||
post_shortcode: 'Db-lNCoib9m', post_id: '3962768346034323302', type: 'reel',
|
||||
date: '2026-08-13 11:00:44', post_date: '2026-08-13 11:00:44',
|
||||
username: 'official_band', fullname: 'Official ARTMS',
|
||||
username: 'official_artms', fullname: 'Official ARTMS',
|
||||
description: 'Dancing in the spotlight', count: 1, likes: 22914,
|
||||
};
|
||||
const FEED_VIDEO: GalleryDlSidecar = { ...REEL, post_shortcode: 'DbdG9L9jU4m', type: 'post', count: 2 };
|
||||
const HIGHLIGHT: GalleryDlSidecar = {
|
||||
post_shortcode: 'BATVdRZi_3', post_id: '18099435932626935', type: 'highlight',
|
||||
date: '2026-08-08 16:22:09', username: 'official_band', count: 154,
|
||||
date: '2026-08-08 16:22:09', username: 'official_artms', count: 154,
|
||||
};
|
||||
|
||||
describe('isGalleryDlSidecar', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Tab } from './routing';
|
||||
/**
|
||||
* The shortcode shared by every copy of a post, regardless of which source
|
||||
* directory it came from. Sidecar ids are directory-scoped
|
||||
* (`4utumn07 - reels/Cq8LrxSJAJE`); the trailing segment is the shortcode.
|
||||
* (`0ct0ber19 - reels/Cq8LrxSJAJE`); the trailing segment is the shortcode.
|
||||
*/
|
||||
const shortcode = (post: Post): string => post.id.split('/').pop() ?? post.id;
|
||||
|
||||
|
||||
+16
-16
@@ -12,21 +12,21 @@ describe('parseRoute', () => {
|
||||
});
|
||||
|
||||
it('reads a profile', () => {
|
||||
expect(parseRoute('/4utumn07/')).toEqual({ archive: '4utumn07', tab: 'posts', post: null });
|
||||
expect(parseRoute('/0ct0ber19/')).toEqual({ archive: '0ct0ber19', tab: 'posts', post: null });
|
||||
});
|
||||
|
||||
it('reads a profile without a trailing slash', () => {
|
||||
expect(parseRoute('/4utumn07')).toEqual({ archive: '4utumn07', tab: 'posts', post: null });
|
||||
expect(parseRoute('/0ct0ber19')).toEqual({ archive: '0ct0ber19', tab: 'posts', post: null });
|
||||
});
|
||||
|
||||
it('reads a tab', () => {
|
||||
expect(parseRoute('/4utumn07/reels/').tab).toBe('reels');
|
||||
expect(parseRoute('/4utumn07/saved/').tab).toBe('saved');
|
||||
expect(parseRoute('/0ct0ber19/reels/').tab).toBe('reels');
|
||||
expect(parseRoute('/0ct0ber19/saved/').tab).toBe('saved');
|
||||
});
|
||||
|
||||
it('reads a post in Instagram form', () => {
|
||||
expect(parseRoute('/4utumn07/p/Db5tIoRCcvm/')).toEqual({
|
||||
archive: '4utumn07', tab: 'posts', post: 'Db5tIoRCcvm',
|
||||
expect(parseRoute('/0ct0ber19/p/Db5tIoRCcvm/')).toEqual({
|
||||
archive: '0ct0ber19', tab: 'posts', post: 'Db5tIoRCcvm',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,8 @@ describe('parseRoute', () => {
|
||||
});
|
||||
|
||||
it('still understands the legacy query form', () => {
|
||||
expect(parseRoute('/', '?a=4utumn07&t=reels&p=ABC')).toEqual({
|
||||
archive: '4utumn07', tab: 'reels', post: 'ABC',
|
||||
expect(parseRoute('/', '?a=0ct0ber19&t=reels&p=ABC')).toEqual({
|
||||
archive: '0ct0ber19', tab: 'reels', post: 'ABC',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,9 +54,9 @@ describe('parseRoute', () => {
|
||||
describe('buildPath', () => {
|
||||
it.each([
|
||||
[{ archive: null, tab: 'posts', post: null }, '/'],
|
||||
[{ archive: '4utumn07', tab: 'posts', post: null }, '/4utumn07/'],
|
||||
[{ archive: '4utumn07', tab: 'reels', post: null }, '/4utumn07/reels/'],
|
||||
[{ archive: '4utumn07', tab: 'posts', post: 'Db5tIoRCcvm' }, '/4utumn07/p/Db5tIoRCcvm/'],
|
||||
[{ archive: '0ct0ber19', tab: 'posts', post: null }, '/0ct0ber19/'],
|
||||
[{ archive: '0ct0ber19', tab: 'reels', post: null }, '/0ct0ber19/reels/'],
|
||||
[{ archive: '0ct0ber19', tab: 'posts', post: 'Db5tIoRCcvm' }, '/0ct0ber19/p/Db5tIoRCcvm/'],
|
||||
] as const)('builds %j', (route, expected) => {
|
||||
expect(buildPath(route as any)).toBe(expected);
|
||||
});
|
||||
@@ -71,8 +71,8 @@ describe('buildPath', () => {
|
||||
|
||||
it('round-trips through parseRoute', () => {
|
||||
for (const route of [
|
||||
{ archive: '4utumn07', tab: 'posts' as const, post: null },
|
||||
{ archive: '4utumn07', tab: 'reels' as const, post: null },
|
||||
{ archive: '0ct0ber19', tab: 'posts' as const, post: null },
|
||||
{ archive: '0ct0ber19', tab: 'reels' as const, post: null },
|
||||
{ archive: 'Heejin_Bubble heejinmedia', tab: 'posts' as const, post: null },
|
||||
]) {
|
||||
expect(parseRoute(buildPath(route))).toEqual(route);
|
||||
@@ -86,12 +86,12 @@ describe('postSlug / findPostBySlug', () => {
|
||||
});
|
||||
|
||||
it('strips the sidecar directory from the slug', () => {
|
||||
expect(postSlug(post('story highlights - u - Sunstory/C5dQPEYpd9W'))).toBe('C5dQPEYpd9W');
|
||||
expect(postSlug(post('story highlights - u - Heestory/C5dQPEYpd9W'))).toBe('C5dQPEYpd9W');
|
||||
});
|
||||
|
||||
it('resolves a slug back to its post', () => {
|
||||
const posts = [post('AAA'), post('4utumn07 - reels/BBB', 'reels')];
|
||||
expect(findPostBySlug(posts, 'BBB')?.id).toBe('4utumn07 - reels/BBB');
|
||||
const posts = [post('AAA'), post('0ct0ber19 - reels/BBB', 'reels')];
|
||||
expect(findPostBySlug(posts, 'BBB')?.id).toBe('0ct0ber19 - reels/BBB');
|
||||
expect(findPostBySlug(posts, 'AAA')?.id).toBe('AAA');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user