Users now go through profile, posts, content info and downloads one by one (preparing the next user while the current one downloads), saving each index as soon as that user is done. The queue is re-read between users, users are removed once archived, and a lock file lets a second --queue invocation hand its users to the running process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9.3 KiB
ripunzel
Node.js CLI that archives reddit users' posts (and direct links to supported media hosts) to disk, with configurable filename patterns and a per-user YAML index to avoid redownloads.
Branches
masteris maintained by hand. Do not commit to or merge intomasterunless explicitly asked.public-reddit-apiwas largely written with Claude Code to get the app working again after reddit revoked API keys (Snoowrap/OAuth no longer usable). It needs a thorough manual review and a major version bump before it may be merged intomaster(see commit2e4ba27).
Running
- Node
>=24.19.0(.nvmrc).npm install, thennpm start -- --user <name>(seesrc/cli.jsor README for all flags). - ffmpeg must be on the PATH to mux v.redd.it video+audio; exiftool ships via
dist-exiftool. - No test suite. Lint with
npx eslint src(airbnb-base, 4-space indent, CommonJS,'use strict'). - Config uses the
configpackage:config/default.jsis committed;config/local.jsoverrides it and is gitignored, as isconfig/cookies.js(a Netscape cookie-jar dump, exported as a string and pulled in bylocal.js). Never print, commit or log cookie contents. output/,logs/,users*,ignoreare local data and gitignored.
Architecture
src/app.js processes users one at a time through the whole pipeline, via runUsers(). While one user's media downloads, the next user is already being prepared (lookahead of one). Each user's index is saved as soon as that user is done. Do not go back to processing stage by stage across all users: that meant hours of reddit requests before the first download, everything lost on a crash, and no way to queue new users.
Per user:
- Sources (
src/sources/):getUserPosts(username, tracker)resolves to{ status, user }. Status isactive,empty,suspended,deletedorerror. It fetches the profile (user.js), the existing index (getIndex, YAML atlibrary.index.file), social links if needed, and submissions. - Curate (
src/curate/):posts.jsnormalizes reddit post objects, applies filters (index, dates, limit, ignore, include/exclude, duplicates) and runsdissectLinkto choose a host method.user.jsnormalizes the profile. - Info (
src/fetch/info.js): callsmethods[host.method](insrc/methods/) to resolve each post into{ album, items[] }. If that fails, it falls back to reddit preview images (redditPreview). Some methods export{ fetchInfo, fetchPredata }. Predata (e.g. a RedGifs temporary token) is cached inapp.jsforfetch.predataTtl. - Content (
src/fetch/content.js): downloads items (src/fetch/item.js, using bhttp), dedupes by blake2 hash, saves to a path built bysrc/interpolate.jsfromconfig.librarypatterns, muxes video+audio, writes EXIF and finally updates the index (src/save/writeToIndex.js).
Where users come from: src/queue.js provides createListSource (--users/--file-users) and createQueueSource (--queue [file], default queue). --posts and --fetch still run as small batches.
Queue (--queue [file], --wait)
- The queue file is reread before each next user. A user is removed after finishing, including when suspended or deleted. Users with status
errorstay queued and are skipped for the rest of the run, so they can't loop. <queue>.lock(holding a pid, created withwx) ensures only one process runs a queue. A second--queue --users …process only appends its users and exits. Without--queue, the lock is ignored.- At the end of a run, the lock is released first and the queue checked once more, which closes the race with a process that appended while it still saw the lock.
- List files are edited with
src/utils/listFile.js: reread right before writing, only the matching lines touched, replaced atomically via rename.
Reddit client (src/reddit/client.js), the core of this branch
- Calls reddit's public
.jsonendpoints onwww.reddit.com(/user/x/about.json,/user/x/submitted.json,/comments/id.json,/api/morechildren.json) withraw_json=1. There is no OAuth. - Anonymous requests run into reddit's bot challenge, so a logged-in session's cookies (
config.reddit.cookies, a cookie-jar string, orconfig.reddit.cookie, a raw header) are sent. Live testing (2026-11) showed that onlyreddit_session(the login, valid ~6 months) is required; without it, reddit returns a 403 HTML page. The client warns whenreddit_sessionis missing or expires within 48h. - Social links have no public JSON endpoint (the widgets API needs OAuth). They are scraped from the profile page HTML (
data-faceplate-tracking-contextattributes), which is fragile and breaks whenever reddit changes its markup. - All reddit requests go through one Bottleneck limiter:
maxConcurrent: 1, reservoir 15/min,minTime1500ms (config.reddit.throttle). reddit sends no rate-limit headers to cookie sessions, so the ceiling is unknown. Raise it with care, because being flagged as a bot defeats the purpose. Retries for 429/503 and network errors happen outsidelimiter.schedule(). Retrying inside it previously caused a deadlock; keep it that way. - Avoid unnecessary reddit calls: gallery posts reuse
media_metadata/gallery_datafrom the submission listing (curate/posts.js→methods/redditAlbum.js).getSubmission(post only, cheap) is separate fromgetSubmissionWithComments(full comment tree includingmorestubs, which is expensive and only used for self posts).
Networking (src/proxy.js, src/limiter.js)
proxy.jsexports a wrappedfetch(undici; per-host proxy fromconfig.proxy.hostswith suffix matching, plus a hardconfig.fetch.timeout) andagentFor()for bhttp. Both cover the reddit API whenreddit.comis configured. fetch() supports http(s) and SOCKS5 (undici's experimentalSocks5ProxyAgent;socks5hmaps to it and hostnames are resolved on the proxy). SOCKS4 throws for fetch() rather than going out directly; bhttp supports all SOCKS variants.config.proxy.retryFlippedretries a failed request via the opposite route (proxied ↔ direct). It is off by default for privacy reasons.limiter.jsprovides per-host Bottleneck limiters for media downloads only (config.limiter.hosts). It is separate from the reddit API limiter.- bhttp has no timeout of its own.
responseTimeoutinfetch/item.jsmakes it abort the socket. Don't replace this with aPromise.race, which would leak connections and eventually freeze downloads.
Account status and cleanup
src/sources/user.jsfetchUser()classifies accounts asactive,suspended(about.json returns 200 withis_suspended: true; there is no date),deleted(JSON 404, which may also be a shadowban) orerror.error(network failure, 5xx, HTML bot block) must never count as dead; only genuine JSON API errors carryerror.status(seerequestJson).- Suspended and deleted users are skipped after that one request.
src/sources/accountStatus.jsrecords them inusers_dead.yml(config.users.dead) withfirstSeen. Once they have been dead forretainDays[status](default 90), they are removed from the--file-usersfile, never from--users. A record is dropped if the account turns out active again. The record is saved after every user. --cleanupruns only this check (one request per user) without archiving. Regular archive runs do it too unlessusers.dead.duringArchiveis false.- Social links (an extra HTML request) are only fetched when the profile description will actually be saved, i.e. it isn't indexed yet or
--redownload-profileis set.
Fetching strategy
- By default, the entire submission listing is paged (up to reddit's ~1000-post cap). Every post not yet in the index is downloaded, so gaps anywhere in the archive are backfilled.
- With
--after <date>or--after-indexed oldest|latest(sortnewonly),getSubmissionsstops paging at the first page that crosses the cutoff, ignoring pinned posts.--after-indexed latestis the cheap "only fetch what's new" mode, and it does not backfill gaps. - The user, then the index, then the posts are fetched in that order, because the index decides the cutoff.
- Media data for videos and galleries comes from the listing, including crossposts via
crosspost_parent_list[0]. A post is only fetched again if that data is missing.
Known caveats / review notes
- Posts beyond reddit's 1000-post listing cap can't be reached through reddit. Arctic Shift (
arctic-shift.photon-reddit.com/api/posts/search?author=…) returns full reddit post objects and is the candidate replacement for the abandoned--archivescode (src/archives/, whose sites are gone). getUserPostsprocesses users with concurrency 10, but the reddit client is serial anyway.- Self posts fetch their full comment tree, which takes at least one request per post (more with
morestubs). - gfycat (shut down in 2023) links are routed to the RedGifs method under the same ID, keeping the label
gfycat. eroshare is recovered from a fixed Wayback Machine snapshot. vidble is still online. - The README's "API keys" section and date-format notes predate this branch.
- Several files have long explanatory comments from the rewrite. Keep them accurate when changing behavior.
- No test suite. To test reddit logic offline, stub
require('./src/proxy').fetchbefore requiring the client, and raisereddit.throttleviaNODE_CONFIG, or the 6/min limiter applies.