Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit df945a23403c034c7e1f2a9afada915a414d77ad
parent 0512803e29a0d64c2b0c75f8b5aba6bb1d37a33f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 11:27:37 -0400

common: X login — the Connect window is the operator's own browser without the automation signals, and the X fetchers can use the operator's browser login (social.x.cookieSource)

- social/xBrowser.ts: the Connect window's browser (ARCHILYZER_X_BROWSER, else
  chromium / google-chrome / google-chrome-stable / chrome on PATH, else the
  bundled build) and one pure launch-options builder: ignoreDefaultArgs
  --enable-automation only, --disable-blink-features=AutomationControlled,
  --test-type, the sandbox on for the headed window. The profile records the
  executable that wrote it; the headless refresh keeps the bundled build and
  falls back to the recorded one when it cannot open the profile.
- social/xCookieSource.ts: social.x.cookieSource "browser" | "profile", the
  default resolved at read time (browser when cookiesFromBrowser is set and no
  profile is connected, else profile), the spec parser.
- social/xBrowserLogin.ts: xCookiesFromBrowser — Firefox's cookies.sqlite (and
  its WAL) copied to a private temp dir, X's rows only, never written; the
  Chromium family is left to gallery-dl. readXLoginStatus: the source in use,
  whether an auth_token for x.com is visible, when it was last seen.
- gallery-dl gets --cookies-from-browser <spec> on the browser source; the
  Playwright fallback gets the same cookies in a fresh context; fetchPosts
  resolves the source per channel.
- settings: the `social` block (SETTINGS.md, settings.json.example regenerated);
  ENVIRONMENT.md: ARCHILYZER_X_BROWSER.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
MENVIRONMENT.md | 1+
MSETTINGS.md | 25+++++++++++++++++++++++++
Mcommon/controller/fetchPosts.ts | 22++++++++++++++++++----
Mcommon/lib/envVars.ts | 1+
Mcommon/lib/settingsDocs.test.ts | 2+-
Mcommon/lib/settingsDocs.ts | 12++++++++++++
Mcommon/lib/settingsSchema.test.ts | 22+++++++++++++++++++++-
Mcommon/lib/settingsSchema.ts | 29+++++++++++++++++++++++++++++
Acommon/social/__fixtures__/firefoxCookieStore.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/social/fetchers.ts | 15++++++++++++++-
Acommon/social/nodeSqlite.ts | 36++++++++++++++++++++++++++++++++++++
Mcommon/social/playwrightRuntime.ts | 3+++
Acommon/social/xBrowser.test.ts | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xBrowser.ts | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xBrowserLogin.test.ts | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xBrowserLogin.ts | 410+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xCookieSource.test.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xCookieSource.ts | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xGalleryDlFetcher.test.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/social/xGalleryDlFetcher.ts | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcommon/social/xPlaywrightFetcher.ts | 48++++++++++++++++++++++++++++++++++++++++--------
Mcommon/social/xSessionBroker.ts | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msettings.json.example | 3+++
23 files changed, 1840 insertions(+), 36 deletions(-)

diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -70,6 +70,7 @@ Tokens, credentials and knobs a running process reads. Most configuration is not | `OLLAMA_DIGEST_MODEL` | `qwen2.5:7b` | The ollama model the local digest lane asks for when settings name none. | common/lib/digestApps.ts | | `CLAUDE_DIGEST_MODEL` | the CLI's default | The model the metered digest lane asks `claude` for when settings name none. | common/lib/digestApps.ts | | `NITTER_INSTANCES` | a built-in list | Comma-separated Nitter instances for the X fallback fetcher, in order of preference. | common/social/xNitterFetcher.ts | +| `ARCHILYZER_X_BROWSER` | the first of `chromium`, `google-chrome`, `google-chrome-stable`, `chrome` on PATH, else Playwright's bundled Chromium | The Chromium-family browser /settings' "Connect X account" opens (a path, or a name looked up on PATH). It is launched without the automation signals, in the X session profile; a value that is not an executable refuses the connect rather than opening another browser. | common/social/xBrowser.ts | | `UMTOOL_URL` | unset (no link) | umtool's front door; when set, the video page links to it. | editor/app/channels/[slug]/videos/[id]/page.tsx | | `TRANSCRIPT_SITE_URL` | — | MCP server: one published archive to read over HTTP. | mcp/src/sources.ts | | `TRANSCRIPT_HUB_URL` | — | MCP server: a hub, federating every archive it lists. | mcp/src/sources.ts | diff --git a/SETTINGS.md b/SETTINGS.md @@ -19,6 +19,7 @@ A copied example PINS every default it spells — including each lane's `autoQue | [`workers`](#workers) | `[]` | | [`cookiesFromBrowser`](#cookiesfrombrowser) | `""` | | [`cookieMode`](#cookiemode) | `"when-required"` | +| [`social`](#social) | object — see below | | [`sleepBetweenDownloadsSeconds`](#sleepbetweendownloadsseconds) | `10` | | [`downloadFormat`](#downloadformat) | `"auto"` | | [`minFreeDiskGB`](#minfreediskgb) | `5` | @@ -157,6 +158,30 @@ How yt-dlp invocations use the configured cookies (see common/lib/cookiePolicy.t Default: `"when-required"` +## `social` + +Per-platform settings of the social-post fetchers. Today one key: where the X fetchers' login comes from (`social.x.cookieSource`, chosen in the X account session section of /settings). See common/social/xCookieSource.ts. + +#### `social` + +| Key | Default | Description | +|---|---|---| +| `x` | `{}` | X / Twitter. See `social.x` below. | + +#### `social.x` + +| Key | Default | Description | +|---|---|---| +| `cookieSource` | absent | Where the X fetchers' login comes from. `"browser"`: the operator's everyday browser, named by `cookiesFromBrowser` — gallery-dl is handed `--cookies-from-browser <spec>` and reads it on every run, and the Playwright fallback reads the same store (Firefox only; common/social/xBrowserLogin.ts), so the login lasts as long as the browser's. `"profile"`: the session broker's persistent profile ("Connect X account" on /settings) and the cookie jar it exports. ABSENT (the default) is resolved at read time, never stored: `"browser"` when `cookiesFromBrowser` is set and no profile is connected (no exported jar carrying an auth_token), else `"profile"`. The source, not `cookieMode`, governs the X fetchers. | + +Default: + +```json +{ + "x": {} +} +``` + ## `sleepBetweenDownloadsSeconds` Pause (seconds) inserted between per-video yt-dlp invocations in managed batch downloads. yt-dlp's own `-t sleep` only paces requests within one invocation, so without this the managed loop hammers the source IP back-to-back. 0 disables. Per-channel override available. diff --git a/common/controller/fetchPosts.ts b/common/controller/fetchPosts.ts @@ -41,13 +41,17 @@ import "../social/xGalleryDlFetcher"; // only ever used when a channel opts into it via postFetcher: "x-playwright". import "../social/xPlaywrightFetcher"; import "../social/xNitterFetcher"; +import { + resolveXCookieSourceFor, + type XLoginSettings, +} from "../social/xBrowserLogin"; export type FetchPostsOptions = { paths: Paths; slug: string; - // Global settings, for the cookie policy. Structural so settings.ts need not - // be imported here. - settings: CookiePolicyInputs; + // Global settings, for the cookie policy and the X login source. Structural + // so settings.ts need not be imported here. + settings: CookiePolicyInputs & XLoginSettings; // Ignore the stored watermark and re-walk the account's full history. New // posts are still deduped against the archive, so this is a safe repair // operation rather than a duplicate-maker. @@ -122,7 +126,15 @@ export async function fetchPosts( opts.full || resumeCursor ? undefined : ((await latestPostCreatedAt(channelRoot)) ?? undefined); - const cookies = alwaysCookies(resolveCookiePolicy(settings, config)); + const policy = resolveCookiePolicy(settings, config); + const cookies = alwaysCookies(policy); + // An X fetcher's login source (social.x.cookieSource, xCookieSource.ts), + // resolved against THIS channel's browser spec — its own cookiesFromBrowser + // over the global one, whatever the cookieMode. + const xLogin = + fetcher.platform === "twitter" + ? await resolveXCookieSourceFor(paths, settings, policy.cookies) + : undefined; log( `Fetching posts for ${slug} via ${fetcher.label} (@${handle})` + @@ -154,6 +166,8 @@ export async function fetchPosts( cursor: resumeCursor, seenIds, cookies, + cookieSource: xLogin?.source, + browserCookies: xLogin?.browserSpec, limit: opts.limit, signal: effectiveSignal, onLog: log, diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -106,6 +106,7 @@ const DECLARED: EnvVarDecl[] = [ { name: "OLLAMA_DIGEST_MODEL", audience: "runtime", default: "`qwen2.5:7b`", readBy: "common/lib/digestApps.ts", doc: "The ollama model the local digest lane asks for when settings name none." }, { name: "CLAUDE_DIGEST_MODEL", audience: "runtime", default: "the CLI's default", readBy: "common/lib/digestApps.ts", doc: "The model the metered digest lane asks `claude` for when settings name none." }, { name: "NITTER_INSTANCES", audience: "runtime", default: "a built-in list", readBy: "common/social/xNitterFetcher.ts", doc: "Comma-separated Nitter instances for the X fallback fetcher, in order of preference." }, + { name: "ARCHILYZER_X_BROWSER", audience: "runtime", default: "the first of `chromium`, `google-chrome`, `google-chrome-stable`, `chrome` on PATH, else Playwright's bundled Chromium", readBy: "common/social/xBrowser.ts", doc: "The Chromium-family browser /settings' \"Connect X account\" opens (a path, or a name looked up on PATH). It is launched without the automation signals, in the X session profile; a value that is not an executable refuses the connect rather than opening another browser." }, { name: "UMTOOL_URL", audience: "runtime", default: "unset (no link)", readBy: "editor/app/channels/[slug]/videos/[id]/page.tsx", doc: "umtool's front door; when set, the video page links to it." }, { name: "TRANSCRIPT_SITE_URL", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: one published archive to read over HTTP." }, { name: "TRANSCRIPT_HUB_URL", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: a hub, federating every archive it lists." }, diff --git a/common/lib/settingsDocs.test.ts b/common/lib/settingsDocs.test.ts @@ -33,7 +33,7 @@ test("the example parses back to the defaults", async () => { assert.deepEqual(parsed, defaultSiteSettings()); }); -// EVERY FIELD, NOT ONLY THE 31 TOP-LEVEL ONES. The *_FIELD_DOCS records are +// EVERY FIELD, NOT ONLY THE 32 TOP-LEVEL ONES. The *_FIELD_DOCS records are // complete by type (FieldDocs<T> requires one entry per key); these two check // the wiring — that every object-valued block has a key table, and that a // block's table names every key its default actually carries. diff --git a/common/lib/settingsDocs.ts b/common/lib/settingsDocs.ts @@ -19,6 +19,8 @@ import { DIGEST_SETTINGS_FIELD_DOCS, SAVED_VIDEO_BACKUP_SETTINGS_FIELD_DOCS, SOCIAL_LINK_FIELD_DOCS, + SOCIAL_SETTINGS_FIELD_DOCS, + X_SOCIAL_SETTINGS_FIELD_DOCS, SYNC_SCHEDULER_SETTINGS_FIELD_DOCS, defaultSiteSettings, siteSettingsSchema, @@ -160,6 +162,16 @@ export function blockTables(d: SiteSettings): Partial<Record<keyof SiteSettings, docs: CHANNEL_AUTO_PAUSE_FIELD_DOCS, }, ], + social: [ + { path: "social", docs: SOCIAL_SETTINGS_FIELD_DOCS, defaults: fromObject(d.social) }, + { + path: "social.x", + docs: X_SOCIAL_SETTINGS_FIELD_DOCS, + // Absent from the default block: the source is resolved at read time + // and only a chosen one is written. + defaults: fromObject(d.social.x), + }, + ], socialLinks: [{ path: "socialLinks[]", docs: SOCIAL_LINK_FIELD_DOCS }], savedVideoBackup: [ { diff --git a/common/lib/settingsSchema.test.ts b/common/lib/settingsSchema.test.ts @@ -35,6 +35,7 @@ import type { ReportDebouncePreset, SavedVideoBackupSettings, SocialLink, + SocialSettings, SyncSchedulerSettings, } from "./settingsSchema"; @@ -62,6 +63,8 @@ type PreSchemaSiteSettings = { workers: Worker[]; cookiesFromBrowser: string; cookieMode: CookieMode; + // Release 16 slice XL — the one key it adds. + social: SocialSettings; sleepBetweenDownloadsSeconds: number; downloadFormat: DownloadFormatPreset; minFreeDiskGB: number; @@ -92,7 +95,7 @@ type PreSchemaSiteSettings = { type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const shapeUnchanged: Same<SiteSettings, PreSchemaSiteSettings> = true; -test("SiteSettings keeps its 31 fields, in file order", () => { +test("SiteSettings keeps its 32 fields, in file order", () => { assert.equal(shapeUnchanged, true); assert.deepEqual(Object.keys(siteSettingsSchema.shape), [ "adminTitle", @@ -102,6 +105,7 @@ test("SiteSettings keeps its 31 fields, in file order", () => { "workers", "cookiesFromBrowser", "cookieMode", + "social", "sleepBetweenDownloadsSeconds", "downloadFormat", "minFreeDiskGB", @@ -154,6 +158,22 @@ test("defaults() and defaultSiteSettings() are the schema's answer for an empty assert.deepEqual(d.archiveStorage, { bucket: "", publicBaseUrl: "" }); assert.equal(d.skipLiveDownloads, true); assert.equal(d.inlineTranscribeOnFallback, false); + // The X login source is resolved at read time, so the default stores none. + assert.deepEqual(d.social, { x: {} }); +}); + +test("social.x.cookieSource keeps a known source and drops anything else", () => { + const parse = (social: unknown) => siteSettingsSchema.parse({ social }).social; + assert.deepEqual(parse({ x: { cookieSource: "browser" } }), { x: { cookieSource: "browser" } }); + assert.deepEqual(parse({ x: { cookieSource: "profile" } }), { x: { cookieSource: "profile" } }); + // An unknown source reads as absent — the read-time default — never a throw. + assert.deepEqual(parse({ x: { cookieSource: "chrome" } }), { x: {} }); + assert.deepEqual(parse({ x: { cookieSource: "browser", extra: 1 }, bsky: {} }), { + x: { cookieSource: "browser" }, + }); + for (const bad of [null, "browser", [], 3, { x: "browser" }, { x: [] }]) { + assert.deepEqual(parse(bad), { x: {} }, JSON.stringify(bad)); + } }); // ── THE CLAMP BOUNDARIES ───────────────────────────────────────────────────── diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts @@ -89,8 +89,34 @@ import { type DigestTimestampMode, } from "./digest"; import type { FieldDocs } from "./fieldDocs"; +import { + sanitizeSocial, + type SocialSettings, + type XSocialSettings, +} from "../social/xCookieSource"; export type { Worker } from "./workers"; +export type { SocialSettings, XSocialSettings } from "../social/xCookieSource"; + +// The `social` block (release 16 slice XL). The types and the coercion live +// with the source they choose, in social/xCookieSource.ts (pure, client-safe); +// the documentation lives here with every other block's. +export const SOCIAL_SETTINGS_FIELD_DOCS: FieldDocs<SocialSettings> = { + x: "X / Twitter. See `social.x` below.", +}; + +export const X_SOCIAL_SETTINGS_FIELD_DOCS: FieldDocs<XSocialSettings> = { + cookieSource: + "Where the X fetchers' login comes from. `\"browser\"`: the operator's everyday browser, " + + "named by `cookiesFromBrowser` — gallery-dl is handed `--cookies-from-browser <spec>` and " + + "reads it on every run, and the Playwright fallback reads the same store (Firefox only; " + + "common/social/xBrowserLogin.ts), so the login lasts as long as the browser's. " + + "`\"profile\"`: the session broker's persistent profile (\"Connect X account\" on /settings) " + + "and the cookie jar it exports. ABSENT (the default) is resolved at read time, never " + + "stored: `\"browser\"` when `cookiesFromBrowser` is set and no profile is connected (no " + + "exported jar carrying an auth_token), else `\"profile\"`. The source, not `cookieMode`, " + + "governs the X fetchers.", +}; export type { AutoQueueSettings } from "./autoQueueTypes"; export type { ChannelPriority } from "./channelPriority"; @@ -1450,6 +1476,9 @@ export const siteSettingsSchema = z.object({ cookieMode: settingsField((v): CookieMode => (isCookieMode(v) ? v : DEFAULT_COOKIE_MODE)).describe( "How yt-dlp invocations use the configured cookies (see common/lib/cookiePolicy.ts): \"always\" passes them on every invocation, \"when-required\" (default; the historical behavior) only to retry an auth/age failure, \"defer\" never in normal runs — auth-gated videos are excluded from batches and collected into the per-channel \"Needs cookies\" bucket for a manual cookie run. Per-channel override available (ChannelConfig.cookieMode).", ), + social: settingsField((v): SocialSettings => sanitizeSocial(v)).describe( + "Per-platform settings of the social-post fetchers. Today one key: where the X fetchers' login comes from (`social.x.cookieSource`, chosen in the X account session section of /settings). See common/social/xCookieSource.ts.", + ), sleepBetweenDownloadsSeconds: settingsField((v): number => clampSleepBetweenDownloadsSeconds(v)).describe( "Pause (seconds) inserted between per-video yt-dlp invocations in managed batch downloads. yt-dlp's own `-t sleep` only paces requests within one invocation, so without this the managed loop hammers the source IP back-to-back. 0 disables. Per-channel override available.", ), diff --git a/common/social/__fixtures__/firefoxCookieStore.ts b/common/social/__fixtures__/firefoxCookieStore.ts @@ -0,0 +1,90 @@ +// A FIREFOX COOKIE STORE FOR TESTS — `cookies.sqlite` with Firefox's +// `moz_cookies` table, written through node:sqlite. Used by +// social/xBrowserLogin.test.ts and the editor's x-session e2e, so neither +// reads a real browser profile. Test-only: nothing outside a test imports it. + +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { loadSqlite, type SqliteDb } from "../nodeSqlite"; + +export type FixtureCookie = { + host: string; + name: string; + value: string; + path?: string; + // Seconds since the epoch, as Firefox has stored it. + expiry?: number; + // PRTime: microseconds since the epoch. + lastAccessed?: number; + isSecure?: 0 | 1; + isHttpOnly?: 0 | 1; + sameSite?: number; + originAttributes?: string; +}; + +// Firefox's table as recent releases create it. +const SCHEMA = `CREATE TABLE moz_cookies ( + id INTEGER PRIMARY KEY, + originAttributes TEXT NOT NULL DEFAULT '', + name TEXT, + value TEXT, + host TEXT, + path TEXT, + expiry INTEGER, + lastAccessed INTEGER, + creationTime INTEGER, + isSecure INTEGER, + isHttpOnly INTEGER, + inBrowserElement INTEGER DEFAULT 0, + sameSite INTEGER DEFAULT 0, + schemeMap INTEGER DEFAULT 0, + isPartitionedAttributeSet INTEGER DEFAULT 0, + CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes) +)`; + +export function insertFixtureCookies(db: SqliteDb, cookies: ReadonlyArray<FixtureCookie>): void { + const insert = db.prepare( + "INSERT INTO moz_cookies (originAttributes, name, value, host, path, expiry, " + + "lastAccessed, creationTime, isSecure, isHttpOnly, sameSite) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ); + const nowUs = Date.now() * 1000; + for (const c of cookies) { + insert.run( + c.originAttributes ?? "", + c.name, + c.value, + c.host, + c.path ?? "/", + c.expiry ?? Math.floor(Date.now() / 1000) + 86_400 * 365, + c.lastAccessed ?? nowUs, + c.lastAccessed ?? nowUs, + c.isSecure ?? 1, + c.isHttpOnly ?? 1, + c.sameSite ?? 0, + ); + } +} + +// Write `<profileDir>/cookies.sqlite` holding `cookies`. With `keepOpen`, the +// store is left open in WAL mode with the rows still in `cookies.sqlite-wal` +// — what a running Firefox looks like — and the caller closes it. +export async function writeFirefoxCookieStore( + profileDir: string, + cookies: ReadonlyArray<FixtureCookie>, + opts: { keepOpen?: boolean } = {}, +): Promise<{ file: string; db?: SqliteDb }> { + await mkdir(profileDir, { recursive: true }); + const file = path.join(profileDir, "cookies.sqlite"); + const { DatabaseSync } = await loadSqlite(); + const db = new DatabaseSync(file); + db.exec(SCHEMA); + if (opts.keepOpen) { + db.exec("PRAGMA journal_mode=WAL"); + db.exec("PRAGMA wal_autocheckpoint=0"); + } + insertFixtureCookies(db, cookies); + if (opts.keepOpen) return { file, db }; + db.close(); + return { file }; +} diff --git a/common/social/fetchers.ts b/common/social/fetchers.ts @@ -14,6 +14,7 @@ // in the client bundle. import type { Post, PostAvailability, PostPlatform } from "../lib/posts"; +import type { XCookieSource } from "./xCookieSource"; export type PostFetchInput = { // The account's canonical URL as configured on the channel. @@ -37,6 +38,17 @@ export type PostFetchInput = { // Resolved cookie spec from resolveCookiePolicy(), when the fetcher needs // credentials. Bluesky ignores it entirely. cookies?: string; + // WHERE AN X FETCHER'S LOGIN COMES FROM this run — `social.x.cookieSource`, + // resolved by the fetch controller (xCookieSource.ts): "browser" reads the + // operator's everyday browser named by `browserCookies`; "profile" uses the + // session broker's connected profile. Unset (a caller that does not resolve + // it): the profile's jar when it holds a login, else `cookies`, as before the + // choice existed. Platforms without a login ignore both. + cookieSource?: XCookieSource; + // The browser spec the "browser" source reads: the channel's + // cookiesFromBrowser over the global one, REGARDLESS of cookieMode — the + // source, not yt-dlp's mode, governs the X fetchers. + browserCookies?: string; // Soft cap on how many posts to return in one run. Undefined = no cap // beyond the watermark/seen-id stop conditions. limit?: number; @@ -73,7 +85,8 @@ export type SocialFetcherProbe = { // Which config fields this fetcher surfaces in the channel form. Mirrors // TranscriptionApp.fields. export type SocialFetcherFields = { - // Needs a browser cookie spec (cookiesFromBrowser / cookieMode). + // Needs a browser cookie spec (cookiesFromBrowser / cookieMode; for X, the + // login source `social.x.cookieSource`). cookies?: boolean; // Needs an external binary whose path is configurable. binPath?: boolean; diff --git a/common/social/nodeSqlite.ts b/common/social/nodeSqlite.ts @@ -0,0 +1,36 @@ +// node:sqlite, loaded at run time — for reading a browser's cookie store +// (xBrowserLogin.ts) and for the tests' fixture stores. +// +// The specifier is assembled so no bundler tries to resolve it (the same reason +// playwrightRuntime.ts assembles its own): `node:sqlite` exists only with the +// prefix, and Node 22.13+ loads it without a flag (with an ExperimentalWarning, +// once per process). Structural types, because the repo's @types/node predates +// the module. No imports, so a test harness can load it on its own. + +type SqliteStatement = { + all: (...params: unknown[]) => unknown[]; + run: (...params: unknown[]) => unknown; +}; + +export type SqliteDb = { + prepare: (sql: string) => SqliteStatement; + exec: (sql: string) => void; + close: () => void; +}; + +export type SqliteModule = { + DatabaseSync: new (file: string, opts?: Record<string, unknown>) => SqliteDb; +}; + +const SQLITE = ["node", "sqlite"].join(":"); + +export async function loadSqlite(): Promise<SqliteModule> { + const mod = (await import(/* webpackIgnore: true */ SQLITE)) as Partial<SqliteModule> & { + default?: SqliteModule; + }; + const m = mod.DatabaseSync ? (mod as SqliteModule) : mod.default; + if (!m?.DatabaseSync) { + throw new Error("node:sqlite is not available in this Node (22.13 or later loads it without a flag)."); + } + return m; +} diff --git a/common/social/playwrightRuntime.ts b/common/social/playwrightRuntime.ts @@ -47,6 +47,9 @@ export type BrowserContextLike = { newPage: () => Promise<PageLike>; pages: () => PageLike[]; cookies: () => Promise<unknown[]>; + // The X fallback fetcher's browser-login path hands the operator's browser + // cookies to a fresh context (xBrowserLogin.ts). + addCookies: (cookies: ReadonlyArray<Record<string, unknown>>) => Promise<void>; close: () => Promise<void>; on: (event: string, cb: () => void) => void; }; diff --git a/common/social/xBrowser.test.ts b/common/social/xBrowser.test.ts @@ -0,0 +1,162 @@ +// The Connect window's browser and its launch options (release 16 slice XL). +// Pure: nothing here launches a browser. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xBrowser.test.ts + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + buildXBrowserLaunchOptions, + describeXBrowser, + findXBrowser, + readXBrowserRecord, + recordedXBrowser, + writeXBrowserRecord, + X_BROWSER_ENV, +} from "./xBrowser"; + +const TMP = mkdtempSync(path.join(os.tmpdir(), "xl-xbrowser-")); +after(() => rm(TMP, { recursive: true, force: true })); + +// A fake filesystem of executables. +const only = (...files: string[]) => (p: string) => files.includes(p); + +test("the system browser: the first of chromium, google-chrome, google-chrome-stable, chrome on PATH", () => { + const env = { PATH: "/opt/bin:/usr/bin" }; + assert.deepEqual( + findXBrowser({ env, isExecutable: only("/usr/bin/google-chrome-stable", "/usr/bin/chromium") }), + { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + ); + assert.deepEqual( + findXBrowser({ env, isExecutable: only("/usr/bin/chrome", "/opt/bin/google-chrome") }), + { kind: "system", executablePath: "/opt/bin/google-chrome", from: "path" }, + ); +}); + +test("no system browser: Playwright's bundled build", () => { + assert.deepEqual(findXBrowser({ env: { PATH: "/usr/bin" }, isExecutable: only() }), { + kind: "bundled", + }); + assert.deepEqual(findXBrowser({ env: {}, isExecutable: only("/usr/bin/chromium") }), { + kind: "bundled", + }); +}); + +test("ARCHILYZER_X_BROWSER wins, as a path or a name on PATH", () => { + const isExecutable = only("/srv/brave", "/usr/bin/chromium", "/usr/bin/vivaldi"); + assert.deepEqual( + findXBrowser({ env: { [X_BROWSER_ENV]: "/srv/brave", PATH: "/usr/bin" }, isExecutable }), + { kind: "system", executablePath: "/srv/brave", from: "env" }, + ); + assert.deepEqual( + findXBrowser({ env: { [X_BROWSER_ENV]: "vivaldi", PATH: "/usr/bin" }, isExecutable }), + { kind: "system", executablePath: "/usr/bin/vivaldi", from: "env" }, + ); + // A blank override is no override. + assert.deepEqual( + findXBrowser({ env: { [X_BROWSER_ENV]: " ", PATH: "/usr/bin" }, isExecutable }), + { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + ); +}); + +test("an ARCHILYZER_X_BROWSER that is not an executable refuses — it never opens another browser", () => { + const isExecutable = only("/usr/bin/chromium"); + assert.throws( + () => findXBrowser({ env: { [X_BROWSER_ENV]: "/nope/chrome", PATH: "/usr/bin" }, isExecutable }), + /ARCHILYZER_X_BROWSER names "\/nope\/chrome", which is not an executable file\./, + ); + assert.throws( + () => findXBrowser({ env: { [X_BROWSER_ENV]: "edge", PATH: "/usr/bin" }, isExecutable }), + /not an executable file on PATH/, + ); +}); + +test("the Connect window: the system browser, headed, sandboxed, without the automation signals", () => { + const opts = buildXBrowserLaunchOptions({ + browser: { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + headless: false, + sandbox: true, + }); + assert.deepEqual(opts, { + headless: false, + executablePath: "/usr/bin/chromium", + ignoreDefaultArgs: ["--enable-automation"], + args: ["--disable-blink-features=AutomationControlled", "--test-type"], + chromiumSandbox: true, + viewport: { width: 1280, height: 900 }, + }); +}); + +test("the bundled build: no executablePath, the same signals dropped", () => { + const headed = buildXBrowserLaunchOptions({ browser: { kind: "bundled" }, headless: false, sandbox: true }); + assert.equal("executablePath" in headed, false); + assert.deepEqual(headed.ignoreDefaultArgs, ["--enable-automation"]); + assert.deepEqual(headed.args, ["--disable-blink-features=AutomationControlled", "--test-type"]); + assert.equal(headed.chromiumSandbox, true); + + // The headless refresh: no viewport, Playwright's default sandbox setting. + const headless = buildXBrowserLaunchOptions({ browser: { kind: "bundled" }, headless: true }); + assert.deepEqual(headless, { + headless: true, + ignoreDefaultArgs: ["--enable-automation"], + args: ["--disable-blink-features=AutomationControlled", "--test-type"], + }); +}); + +test("ignoreDefaultArgs names ONLY --enable-automation (never `true`: it would drop --password-store=basic)", () => { + for (const browser of [ + { kind: "bundled" as const }, + { kind: "system" as const, executablePath: "/usr/bin/chromium", from: "path" as const }, + ]) { + for (const headless of [true, false]) { + const o = buildXBrowserLaunchOptions({ browser, headless, sandbox: !headless }); + assert.ok(Array.isArray(o.ignoreDefaultArgs)); + assert.deepEqual(o.ignoreDefaultArgs, ["--enable-automation"]); + } + } +}); + +test("describeXBrowser says which browser, and how it was found", () => { + assert.match(describeXBrowser({ kind: "bundled" }), /bundled Chromium/); + assert.equal( + describeXBrowser( + { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + "Chromium 153.0.8010.47 Arch Linux", + ), + "Chromium 153.0.8010.47 Arch Linux at /usr/bin/chromium (found on PATH)", + ); + assert.match( + describeXBrowser({ kind: "system", executablePath: "/srv/brave", from: "env" }), + /^\/srv\/brave \(from ARCHILYZER_X_BROWSER\)$/, + ); +}); + +test("the profile records who wrote it, and the refresh's fallback reads it back", async () => { + const profile = path.join(TMP, "profile"); + assert.equal(await readXBrowserRecord(profile), null); + + await writeXBrowserRecord( + profile, + { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + "Chromium 153", + ); + const rec = await readXBrowserRecord(profile); + assert.equal(rec?.executablePath, "/usr/bin/chromium"); + assert.equal(rec?.version, "Chromium 153"); + assert.deepEqual(recordedXBrowser(rec, only("/usr/bin/chromium")), { + kind: "system", + executablePath: "/usr/bin/chromium", + from: "recorded", + }); + // The recorded executable is gone: no fallback. + assert.equal(recordedXBrowser(rec, only()), undefined); + + await writeXBrowserRecord(profile, { kind: "bundled" }); + const bundled = await readXBrowserRecord(profile); + assert.equal(bundled?.executablePath, null); + assert.equal(recordedXBrowser(bundled, only("/usr/bin/chromium")), undefined); +}); diff --git a/common/social/xBrowser.ts b/common/social/xBrowser.ts @@ -0,0 +1,235 @@ +// THE BROWSER THAT OPENS THE X SESSION PROFILE (release 16 slice XL). +// +// Why this exists: the Connect window used to be Playwright's bundled Chromium +// launched with its automation signals on — `--enable-automation` (which is +// what draws "Chrome is being controlled by automated test software") and +// `navigator.webdriver === true`, which Playwright's debugging pipe turns on by +// itself. Google's sign-in refuses such a browser ("this browser or app may not +// be secure") and X's own login form stalled in it (2026-10-01). +// +// So the Connect window is the operator's own browser when one is installed — +// ARCHILYZER_X_BROWSER, else the first of chromium / google-chrome / +// google-chrome-stable / chrome on PATH, else the bundled build — and every +// launch here drops the signals: +// - `ignoreDefaultArgs: ["--enable-automation"]` — no automation bar. ONLY +// that one: `ignoreDefaultArgs: true` would also drop Playwright's +// `--password-store=basic`, and a system Chromium would then encrypt the +// profile's cookies with the desktop keyring's key, which the bundled +// headless build that refreshes the jar cannot read. +// - `--disable-blink-features=AutomationControlled` — navigator.webdriver is +// false. Measured: without it, it stays true even with +// `--enable-automation` gone (the pipe sets it). +// - `--test-type` and, for the headed window, the sandbox on — a system +// Chrome draws "You are using an unsupported command-line flag" for the +// blink flag above and for Playwright's default `--no-sandbox`; +// `--test-type` (what ChromeDriver passes) suppresses the first, the +// sandbox removes the second. The bundled build draws neither. +// None of this hides the debugging pipe itself; Google's sign-in may still +// refuse an embedded browser, which the Settings section says. +// +// THE PROFILE REMEMBERS WHO WROTE IT. A connect records its executable in the +// profile dir (`archilyzer-browser.json`). The headless refresh keeps the +// bundled build — it never logs in, and its headless shell needs no display — +// and falls back to the recorded executable when the bundled build cannot open +// the profile (a profile written by a newer system browser). Measured +// 2026-10-01: a profile written by system Chromium 153 opens in the bundled +// headless shell 147 with its cookies readable, so the fallback is a guard, +// not the common path. + +import { accessSync, constants, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { writeFileAtomic } from "../lib/jsonFile-server"; + +export const X_BROWSER_ENV = "ARCHILYZER_X_BROWSER"; + +// Looked up on PATH in this order when ARCHILYZER_X_BROWSER is unset. +export const SYSTEM_CHROMIUM_NAMES: readonly string[] = [ + "chromium", + "google-chrome", + "google-chrome-stable", + "chrome", +]; + +export type XBrowserChoice = + | { + kind: "system"; + executablePath: string; + // How it was found: the env override, PATH, or the profile's record. + from: "env" | "path" | "recorded"; + } + | { kind: "bundled" }; + +export function isExecutableFile(p: string): boolean { + try { + if (!statSync(p).isFile()) return false; + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } +} + +function onPath( + name: string, + pathEnv: string | undefined, + isExecutable: (p: string) => boolean, +): string | undefined { + for (const dir of (pathEnv ?? "").split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, name); + if (isExecutable(candidate)) return candidate; + } + return undefined; +} + +// The Connect window's browser. Throws only when ARCHILYZER_X_BROWSER names +// something that is not an executable: an explicit setting that is wrong is +// said, not silently replaced by a different browser. +export function findXBrowser( + opts: { + env?: Record<string, string | undefined>; + isExecutable?: (p: string) => boolean; + } = {}, +): XBrowserChoice { + const env = opts.env ?? process.env; + const isExecutable = opts.isExecutable ?? isExecutableFile; + const override = env.ARCHILYZER_X_BROWSER?.trim(); + if (override) { + const resolved = override.includes("/") + ? isExecutable(override) + ? override + : undefined + : onPath(override, env.PATH, isExecutable); + if (!resolved) { + throw new Error( + `${X_BROWSER_ENV} names "${override}", which is not an executable file` + + (override.includes("/") ? "." : " on PATH."), + ); + } + return { kind: "system", executablePath: resolved, from: "env" }; + } + for (const name of SYSTEM_CHROMIUM_NAMES) { + const hit = onPath(name, env.PATH, isExecutable); + if (hit) return { kind: "system", executablePath: hit, from: "path" }; + } + return { kind: "bundled" }; +} + +// THE LAUNCH OPTIONS, pure. `headless: false` is the Connect window (the +// operator logs in); `headless: true` is the refresh and the Playwright +// fallback fetcher. `sandbox` turns Chromium's sandbox on — the headed window +// asks for it (no unsupported-flag bar) and retries without it when the host +// cannot start one; a headless launch keeps Playwright's default. +export type XBrowserLaunchOptions = { + headless: boolean; + executablePath?: string; + ignoreDefaultArgs: string[]; + args: string[]; + chromiumSandbox?: boolean; + viewport?: { width: number; height: number }; +}; + +export const X_BROWSER_ARGS: readonly string[] = [ + "--disable-blink-features=AutomationControlled", + "--test-type", +]; + +export function buildXBrowserLaunchOptions(opts: { + browser: XBrowserChoice; + headless: boolean; + sandbox?: boolean; +}): XBrowserLaunchOptions { + const out: XBrowserLaunchOptions = { + headless: opts.headless, + ignoreDefaultArgs: ["--enable-automation"], + args: [...X_BROWSER_ARGS], + }; + if (opts.browser.kind === "system") { + out.executablePath = opts.browser.executablePath; + } + if (opts.sandbox) out.chromiumSandbox = true; + if (!opts.headless) out.viewport = { width: 1280, height: 900 }; + return out; +} + +export function describeXBrowser(b: XBrowserChoice, version?: string): string { + if (b.kind === "bundled") { + return "Playwright's bundled Chromium (no system Chromium or Chrome found)"; + } + const how = + b.from === "env" + ? `from ${X_BROWSER_ENV}` + : b.from === "path" + ? "found on PATH" + : "the browser that created the profile"; + return `${version ? `${version} at ` : ""}${b.executablePath} (${how})`; +} + +// --- The profile's record of who wrote it --------------------------------- + +export const X_BROWSER_RECORD = "archilyzer-browser.json"; + +export type XBrowserRecord = { + // The system executable that opened the Connect window; null = the bundled + // build. + executablePath: string | null; + // `<exe> --version`, when it answered. + version?: string; + recordedAt: string; +}; + +export function xBrowserRecordFile(profileDir: string): string { + return path.join(profileDir, X_BROWSER_RECORD); +} + +export async function readXBrowserRecord( + profileDir: string, +): Promise<XBrowserRecord | null> { + try { + const raw = JSON.parse( + await readFile(xBrowserRecordFile(profileDir), "utf8"), + ) as Record<string, unknown>; + if ( + raw.executablePath !== null && + typeof raw.executablePath !== "string" + ) { + return null; + } + return { + executablePath: raw.executablePath as string | null, + ...(typeof raw.version === "string" ? { version: raw.version } : {}), + recordedAt: typeof raw.recordedAt === "string" ? raw.recordedAt : "", + }; + } catch { + return null; + } +} + +export async function writeXBrowserRecord( + profileDir: string, + browser: XBrowserChoice, + version?: string, +): Promise<void> { + const record: XBrowserRecord = { + executablePath: browser.kind === "system" ? browser.executablePath : null, + ...(version ? { version } : {}), + recordedAt: new Date().toISOString(), + }; + await writeFileAtomic( + xBrowserRecordFile(profileDir), + JSON.stringify(record, null, 2) + "\n", + { mkdir: true }, + ); +} + +// The browser the record names, for the refresh's fallback — undefined when it +// names the bundled build, nothing, or an executable that is gone. +export function recordedXBrowser( + record: XBrowserRecord | null, + isExecutable: (p: string) => boolean = isExecutableFile, +): XBrowserChoice | undefined { + if (!record?.executablePath) return undefined; + if (!isExecutable(record.executablePath)) return undefined; + return { kind: "system", executablePath: record.executablePath, from: "recorded" }; +} diff --git a/common/social/xBrowserLogin.test.ts b/common/social/xBrowserLogin.test.ts @@ -0,0 +1,256 @@ +// Reading the operator's browser login for X, and the status the Settings +// section's "Check" shows (release 16 slice XL). Every store here is a fixture +// written through node:sqlite (__fixtures__/firefoxCookieStore.ts) under a temp +// HOME — no test reads a real browser profile. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xBrowserLogin.test.ts + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { mkdir, readdir, rm, stat, utimes, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { + firefoxExpirySeconds, + readFirefoxXCookies, + readXLoginStatus, + xCookiesFromBrowser, +} from "./xBrowserLogin"; +import { xCookieFile, xProfileDir } from "./xSessionBroker"; +import { writeFirefoxCookieStore, type FixtureCookie } from "./__fixtures__/firefoxCookieStore"; + +const TMP = mkdtempSync(path.join(os.tmpdir(), "xl-xbrowserlogin-")); +after(() => rm(TMP, { recursive: true, force: true })); + +let n = 0; +const fresh = (label: string) => path.join(TMP, `${label}-${++n}`); + +const NOW_S = Math.floor(Date.now() / 1000); +const YEAR = 86_400 * 365; +const LAST_USED_US = Date.UTC(2026, 9, 1, 8, 30, 0) * 1000; // 2026-10-01 08:30 UTC + +const X_LOGIN: FixtureCookie[] = [ + { host: ".x.com", name: "auth_token", value: "fixture-auth", lastAccessed: LAST_USED_US }, + { host: ".x.com", name: "ct0", value: "fixture-csrf", isHttpOnly: 0, sameSite: 1 }, + { host: "x.com", name: "lang", value: "en", isSecure: 0, isHttpOnly: 0, sameSite: 0 }, + { host: ".twitter.com", name: "guest_id", value: "v1%3A1", sameSite: 2 }, + // Not X: never selected. + { host: ".example.com", name: "session", value: "not-x" }, + { host: "notx.com", name: "auth_token", value: "lookalike" }, + // Expired: skipped. + { host: ".x.com", name: "old", value: "gone", expiry: NOW_S - 60 }, +]; + +test("reads X's cookies only, mapped to the shape Playwright and cookies.txt take", async () => { + const dir = fresh("profile"); + const { file } = await writeFirefoxCookieStore(dir, X_LOGIN); + const cookies = await readFirefoxXCookies(file, { tmpRoot: TMP }); + const byName = new Map(cookies.map((c) => [c.name, c])); + assert.deepEqual([...byName.keys()].sort(), ["auth_token", "ct0", "guest_id", "lang"]); + const auth = byName.get("auth_token")!; + assert.equal(auth.value, "fixture-auth"); + assert.equal(auth.domain, ".x.com"); + assert.equal(auth.path, "/"); + assert.equal(auth.secure, true); + assert.equal(auth.httpOnly, true); + assert.equal(auth.sameSite, "None"); + assert.equal(auth.lastAccessedMs, LAST_USED_US / 1000); + assert.ok(auth.expires > NOW_S); + assert.equal(byName.get("ct0")!.sameSite, "Lax"); + assert.equal(byName.get("ct0")!.httpOnly, false); + assert.equal(byName.get("guest_id")!.sameSite, "Strict"); + // SameSite=None on a cookie that is not secure is not kept by a browser. + assert.equal(byName.get("lang")!.sameSite, "Lax"); + assert.equal(byName.get("lang")!.domain, "x.com"); +}); + +test("a login still in the WAL of a running Firefox is read", async () => { + const dir = fresh("profile-wal"); + const { file, db } = await writeFirefoxCookieStore(dir, X_LOGIN, { keepOpen: true }); + try { + const listed = await readdir(dir); + assert.ok(listed.includes("cookies.sqlite-wal"), "the fixture leaves its rows in the WAL"); + const cookies = await readFirefoxXCookies(file, { tmpRoot: TMP }); + assert.ok(cookies.some((c) => c.name === "auth_token" && c.value === "fixture-auth")); + } finally { + db?.close(); + } +}); + +test("the browser's store is never written: no file in the profile changes, no temp is left", async () => { + const dir = fresh("profile-ro"); + const { file, db } = await writeFirefoxCookieStore(dir, X_LOGIN, { keepOpen: true }); + try { + const before = await Promise.all( + (await readdir(dir)).sort().map(async (f) => { + const st = await stat(path.join(dir, f)); + return `${f}:${st.size}:${st.mtimeMs}`; + }), + ); + const tmpRoot = fresh("tmproot"); + await mkdir(tmpRoot); + await readFirefoxXCookies(file, { tmpRoot }); + const afterList = await Promise.all( + (await readdir(dir)).sort().map(async (f) => { + const st = await stat(path.join(dir, f)); + return `${f}:${st.size}:${st.mtimeMs}`; + }), + ); + assert.deepEqual(afterList, before); + assert.deepEqual(await readdir(tmpRoot), [], "the private copy is removed"); + } finally { + db?.close(); + } +}); + +test("a Firefox container narrows the read; ::none reads outside every container", async () => { + const dir = fresh("profile-containers"); + const { file } = await writeFirefoxCookieStore(dir, [ + { host: ".x.com", name: "auth_token", value: "default" }, + { host: ".x.com", name: "auth_token", value: "work", originAttributes: "^userContextId=2" }, + { host: ".x.com", name: "auth_token", value: "work-fpd", originAttributes: "^firstPartyDomain=x.com&userContextId=2&x=1" }, + { host: ".x.com", name: "auth_token", value: "personal", originAttributes: "^userContextId=1" }, + ]); + await writeFile( + path.join(dir, "containers.json"), + JSON.stringify({ + version: 5, + identities: [ + { userContextId: 1, public: true, l10nID: "userContextPersonal.label" }, + { userContextId: 2, public: true, name: "Work" }, + ], + }), + ); + const values = async (container?: string) => + (await readFirefoxXCookies(file, { container, tmpRoot: TMP })).map((c) => c.value).sort(); + assert.deepEqual(await values(), ["default", "personal", "work", "work-fpd"]); + assert.deepEqual(await values("Work"), ["work", "work-fpd"]); + assert.deepEqual(await values("personal"), ["personal"]); + assert.deepEqual(await values("none"), ["default"]); + await assert.rejects(values("Shopping"), /No Firefox container named "Shopping"/); +}); + +test("Firefox's expiry reads as seconds, or as milliseconds when too large for seconds", () => { + assert.equal(firefoxExpirySeconds(1_800_000_000), 1_800_000_000); + assert.equal(firefoxExpirySeconds(1_800_000_000_123), 1_800_000_000); + assert.equal(firefoxExpirySeconds(0), -1); + assert.equal(firefoxExpirySeconds(null), -1); +}); + +test("discovery: the most recently used store under Firefox's roots, as gallery-dl picks it", async () => { + const home = fresh("home"); + const root = path.join(home, ".mozilla", "firefox"); + const older = await writeFirefoxCookieStore(path.join(root, "aaa.default"), [ + { host: ".x.com", name: "auth_token", value: "older-profile" }, + ]); + const newer = await writeFirefoxCookieStore(path.join(root, "bbb.default-release"), [ + { host: ".x.com", name: "auth_token", value: "newer-profile" }, + ]); + const t = Date.now() / 1000; + await utimes(older.file, t - 3_600, t - 3_600); + await utimes(newer.file, t, t); + + const read = await xCookiesFromBrowser("firefox", { home, tmpRoot: TMP }); + assert.equal(read.ok, true); + assert.ok(read.ok && read.store === newer.file); + assert.ok(read.ok && read.cookies.some((c) => c.value === "newer-profile")); + + // A profile NAME is looked up under the roots; a PATH is used as given. + const named = await xCookiesFromBrowser("firefox:aaa.default", { home, tmpRoot: TMP }); + assert.ok(named.ok && named.store === older.file); + const byPath = await xCookiesFromBrowser(`firefox:${path.join(root, "aaa.default")}`, { + home, + tmpRoot: TMP, + }); + assert.ok(byPath.ok && byPath.store === older.file); +}); + +test("what xCookiesFromBrowser will not read, it says", async () => { + const home = fresh("home-empty"); + await mkdir(home); + const none = await xCookiesFromBrowser("", { home }); + assert.equal(none.ok, false); + assert.ok(!none.ok && none.reason === "no-spec"); + + const chromium = await xCookiesFromBrowser("chromium:Default", { home }); + assert.ok(!chromium.ok && chromium.reason === "unsupported"); + assert.match(!chromium.ok ? chromium.message : "", /gallery-dl/); + + const missing = await xCookiesFromBrowser("firefox", { home }); + assert.ok(!missing.ok && missing.reason === "not-found"); + assert.match(!missing.ok ? missing.message : "", /\.mozilla\/firefox/); +}); + +// --- The status --------------------------------------------------------------- + +function pathsFor(transcriptsDir: string): Paths { + return { transcriptsDir } as Paths; +} + +async function connectProfile(paths: Paths, authed: boolean) { + await mkdir(xProfileDir(paths), { recursive: true }); + await writeFile( + xCookieFile(paths), + "# Netscape HTTP Cookie File\n" + + (authed ? ".x.com\tTRUE\t/\tTRUE\t1900000000\tauth_token\tjar\n" : ".x.com\tTRUE\t/\tTRUE\t1900000000\tct0\tjar\n"), + ); +} + +test("status — browser source by default: the login is visible, with when the browser last used it", async () => { + const home = fresh("home-status"); + await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), X_LOGIN); + const paths = pathsFor(fresh("transcripts")); + const s = await readXLoginStatus(paths, { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.source, "browser"); + assert.equal(s.chosen, false); + assert.equal(s.label, "Browser login (firefox)"); + assert.equal(s.authTokenVisible, true); + assert.equal(s.lastSeenAt, "2026-10-01T08:30:00.000Z"); + assert.match(s.summary, /^An X login is visible in firefox \(its auth_token last used 2026-10-01 08:30:00 UTC\)\.$/); +}); + +test("status — browser source with no X login in the browser", async () => { + const home = fresh("home-nologin"); + await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), [ + { host: ".x.com", name: "guest_id", value: "g" }, + ]); + const s = await readXLoginStatus(pathsFor(fresh("transcripts")), { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.authTokenVisible, false); + assert.match(s.summary, /No X login in firefox/); +}); + +test("status — a browser that cannot be read here reports null, not false", async () => { + const s = await readXLoginStatus( + pathsFor(fresh("transcripts")), + { cookiesFromBrowser: "chromium", social: { x: { cookieSource: "browser" } } }, + { home: fresh("home-x") }, + ); + assert.equal(s.source, "browser"); + assert.equal(s.chosen, true); + assert.equal(s.authTokenVisible, null); +}); + +test("status — the profile: chosen by default when no browser is set, and when one is connected", async () => { + const paths = pathsFor(fresh("transcripts")); + const none = await readXLoginStatus(paths, { cookiesFromBrowser: "" }); + assert.equal(none.source, "profile"); + assert.equal(none.label, "Connected profile"); + assert.equal(none.authTokenVisible, false); + assert.match(none.summary, /No profile is connected/); + + await connectProfile(paths, false); + const notLoggedIn = await readXLoginStatus(paths, { cookiesFromBrowser: "" }); + assert.equal(notLoggedIn.authTokenVisible, false); + assert.match(notLoggedIn.summary, /not logged in to X/); + + // A connected profile turns the default to "profile" even with a browser set. + await connectProfile(paths, true); + const connected = await readXLoginStatus(paths, { cookiesFromBrowser: "firefox" }, { home: fresh("home-y") }); + assert.equal(connected.source, "profile"); + assert.equal(connected.chosen, false); + assert.equal(connected.authTokenVisible, true); + assert.ok(connected.lastSeenAt); + assert.match(connected.summary, /^The connected profile holds an X login \(cookies exported /); +}); diff --git a/common/social/xBrowserLogin.ts b/common/social/xBrowserLogin.ts @@ -0,0 +1,410 @@ +// THE OPERATOR'S BROWSER LOGIN, read for the X paths that cannot read it +// themselves (release 16 slice XL). +// +// gallery-dl reads a browser's cookie store on its own (`--cookies-from-browser +// <spec>`, xGalleryDlFetcher.ts) — every browser it supports, Chromium's +// encrypted store included. Two things here need the same login without +// gallery-dl: the Playwright fallback fetcher (it hands the cookies to a fresh +// headless context) and the Settings section's "Check" (is an X login visible +// at all, and when was it last used). Both go through `xCookiesFromBrowser`. +// +// FIREFOX ONLY. Its `cookies.sqlite` is plain SQLite with plain values. The +// Chromium family encrypts each value with a key from the desktop keyring +// (libsecret / KWallet) and is left to gallery-dl and yt-dlp, which carry that +// decryption; for such a spec the readers here say so instead of guessing. +// +// READ-ONLY, ALWAYS. The browser's profile is never opened in place and never +// written: `cookies.sqlite` and its `-wal` (Firefox writes ahead, so a fresh +// login may live only in the WAL) are copied into a private temp dir, read +// there, and the dir is removed. Only X's own rows are selected, so no other +// site's cookies leave SQLite. The same discovery gallery-dl and yt-dlp use +// picks the store: a profile path or name from the spec, else the most +// recently modified `cookies.sqlite` under Firefox's profile roots. + +import { copyFile, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { loadSqlite } from "./nodeSqlite"; +import { readXSessionStatus } from "./xSessionBroker"; +import { + parseBrowserSpec, + resolveXCookieSource, + SELF_READ_BROWSERS, + xCookieSourceView, + type BrowserSpec, + type ResolvedXCookieSource, + type XCookieSource, + type XCookieSourceView, +} from "./xCookieSource"; + +// X's session cookie: the one whose presence means "logged in". +export const X_AUTH_COOKIE = "auth_token"; + +// A cookie as read from a browser's store, in the shape Playwright's +// `addCookies` and the broker's cookies.txt writer both take. +export type XBrowserCookie = { + name: string; + value: string; + domain: string; + path: string; + // Seconds since the epoch; -1 for a session cookie. + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite?: "Strict" | "Lax" | "None"; + // When the browser last sent it, ms since the epoch (Firefox's lastAccessed). + lastAccessedMs?: number; +}; + +// --- Finding the store ------------------------------------------------------- + +// Where Firefox keeps its profiles, in the order gallery-dl and yt-dlp search +// them: the XDG location newer releases use, the classic one, Snap, Flatpak, +// macOS. +export function firefoxProfileRoots(home: string): string[] { + return [ + path.join(home, ".config", "mozilla", "firefox"), + path.join(home, ".mozilla", "firefox"), + path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"), + path.join(home, ".var", "app", "org.mozilla.firefox", ".mozilla", "firefox"), + path.join(home, "Library", "Application Support", "Firefox", "Profiles"), + ]; +} + +const COOKIE_DB = "cookies.sqlite"; + +// The newest cookies.sqlite at most `depth` directories below `dir`. A profile +// keeps it at its own top level, so two levels covers a root of profiles and a +// profile given directly, without walking a profile's storage tree. +async function newestCookieDb( + dir: string, + depth: number, +): Promise<{ file: string; mtimeMs: number } | undefined> { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return undefined; + } + let best: { file: string; mtimeMs: number } | undefined; + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.name === COOKIE_DB && e.isFile()) { + const st = await stat(p).catch(() => null); + if (st && (!best || st.mtimeMs > best.mtimeMs)) best = { file: p, mtimeMs: st.mtimeMs }; + } else if (depth > 0 && (e.isDirectory() || e.isSymbolicLink())) { + const hit = await newestCookieDb(p, depth - 1); + if (hit && (!best || hit.mtimeMs > best.mtimeMs)) best = hit; + } + } + return best; +} + +function expandHome(p: string, home: string): string { + return p === "~" ? home : p.startsWith("~/") ? path.join(home, p.slice(2)) : p; +} + +export async function findFirefoxCookieDb( + spec: BrowserSpec, + home: string, +): Promise<{ file?: string; searched: string[] }> { + const profile = spec.profile ? expandHome(spec.profile, home) : undefined; + const searched = profile + ? profile.includes("/") || profile.includes(path.sep) + ? [profile] + : firefoxProfileRoots(home).map((r) => path.join(r, profile)) + : firefoxProfileRoots(home); + let best: { file: string; mtimeMs: number } | undefined; + for (const root of searched) { + const hit = await newestCookieDb(root, 2); + if (hit && (!best || hit.mtimeMs > best.mtimeMs)) best = hit; + } + return { file: best?.file, searched }; +} + +// --- Reading it ---------------------------------------------------------------- + +// A Firefox container (`::NAME` in the spec) is a userContextId, named in +// containers.json beside cookies.sqlite. "none" means cookies outside every +// container. Mirrors yt-dlp's lookup. +async function containerFilter( + dbFile: string, + container: string | undefined, +): Promise<{ where: string; params: unknown[] }> { + if (!container) return { where: "", params: [] }; + if (container.toLowerCase() === "none") { + return { where: "NOT INSTR(originAttributes, 'userContextId=')", params: [] }; + } + let identities: Array<Record<string, unknown>> = []; + try { + const raw = JSON.parse( + await readFile(path.join(path.dirname(dbFile), "containers.json"), "utf8"), + ) as { identities?: unknown }; + if (Array.isArray(raw.identities)) identities = raw.identities as Array<Record<string, unknown>>; + } catch { + /* no containers.json: no container can match */ + } + const want = container.toLowerCase(); + const hit = identities.find((c) => { + const name = typeof c.name === "string" ? c.name.toLowerCase() : undefined; + const l10n = typeof c.l10nID === "string" ? c.l10nID.toLowerCase() : undefined; + return name === want || l10n === `usercontext${want}.label`; + }); + if (typeof hit?.userContextId !== "number") { + throw new Error(`No Firefox container named "${container}" in containers.json`); + } + const id = hit.userContextId; + return { + where: "(originAttributes LIKE ? OR originAttributes LIKE ?)", + params: [`%userContextId=${id}`, `%userContextId=${id}&%`], + }; +} + +const X_HOSTS = + "(host = 'x.com' OR host = '.x.com' OR host LIKE '%.x.com' OR " + + "host = 'twitter.com' OR host = '.twitter.com' OR host LIKE '%.twitter.com')"; + +function num(v: unknown): number | undefined { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "bigint") return Number(v); + return undefined; +} + +// Firefox has stored `expiry` in seconds; a value too large for seconds is read +// as milliseconds, so a store that changes unit still reads right. +export function firefoxExpirySeconds(v: unknown): number { + const n = num(v); + if (!n || n <= 0) return -1; + return Math.floor(n > 1e11 ? n / 1000 : n); +} + +// `lastAccessed` is PRTime: microseconds since the epoch. +function firefoxTimeMs(v: unknown): number | undefined { + const n = num(v); + if (!n || n <= 0) return undefined; + return Math.floor(n > 1e14 ? n / 1000 : n); +} + +function firefoxSameSite(v: unknown, secure: boolean): XBrowserCookie["sameSite"] { + const n = num(v); + if (n === 2) return "Strict"; + if (n === 1) return "Lax"; + // SameSite=None is only kept by a browser on a secure cookie. + if (n === 0) return secure ? "None" : "Lax"; + return undefined; +} + +export async function readFirefoxXCookies( + dbFile: string, + opts: { container?: string; tmpRoot?: string; now?: number } = {}, +): Promise<XBrowserCookie[]> { + const { DatabaseSync } = await loadSqlite(); + const filter = await containerFilter(dbFile, opts.container); + // mkdtemp makes the dir 0700: the copy is readable by this user only. + const tmp = await mkdtemp(path.join(opts.tmpRoot ?? os.tmpdir(), "archilyzer-xcookies-")); + try { + const copy = path.join(tmp, COOKIE_DB); + await copyFile(dbFile, copy); + await copyFile(`${dbFile}-wal`, `${copy}-wal`).catch((err: NodeJS.ErrnoException) => { + if (err.code !== "ENOENT") throw err; + }); + const db = new DatabaseSync(copy); + try { + const where = [X_HOSTS, filter.where].filter(Boolean).join(" AND "); + const rows = db.prepare(`SELECT * FROM moz_cookies WHERE ${where}`).all(...filter.params) as Array< + Record<string, unknown> + >; + const now = (opts.now ?? Date.now()) / 1000; + const out: XBrowserCookie[] = []; + for (const r of rows) { + if (typeof r.name !== "string" || typeof r.host !== "string") continue; + const secure = num(r.isSecure) === 1; + const expires = firefoxExpirySeconds(r.expiry); + if (expires > 0 && expires < now) continue; + const cookie: XBrowserCookie = { + name: r.name, + value: typeof r.value === "string" ? r.value : String(r.value ?? ""), + domain: r.host, + path: typeof r.path === "string" && r.path ? r.path : "/", + expires, + httpOnly: num(r.isHttpOnly) === 1, + secure, + }; + const sameSite = firefoxSameSite(r.sameSite, secure); + if (sameSite) cookie.sameSite = sameSite; + const last = firefoxTimeMs(r.lastAccessed); + if (last) cookie.lastAccessedMs = last; + out.push(cookie); + } + return out; + } finally { + db.close(); + } + } finally { + await rm(tmp, { recursive: true, force: true }); + } +} + +export type BrowserCookieRead = + | { ok: true; cookies: XBrowserCookie[]; browser: string; store: string } + | { + ok: false; + // no-spec: cookiesFromBrowser is empty. unsupported: a browser this repo + // does not read itself (gallery-dl still does). not-found: no store in + // the places searched. unreadable: the store or the spec could not be read. + reason: "no-spec" | "unsupported" | "not-found" | "unreadable"; + message: string; + }; + +// THE SHARED READ: the X cookies of the browser `spec` names, fresh on every +// call. `spec` is the resolved one — a channel's own cookiesFromBrowser over +// the global — which is why this takes the spec and not the settings. +export async function xCookiesFromBrowser( + spec: string | undefined, + opts: { home?: string; tmpRoot?: string; now?: number } = {}, +): Promise<BrowserCookieRead> { + const trimmed = spec?.trim(); + if (!trimmed) { + return { + ok: false, + reason: "no-spec", + message: "cookiesFromBrowser is empty, so there is no browser to read an X login from.", + }; + } + const parsed = parseBrowserSpec(trimmed); + if (!parsed) { + return { ok: false, reason: "unreadable", message: `Could not read the browser spec "${trimmed}".` }; + } + if (!SELF_READ_BROWSERS.includes(parsed.browser)) { + return { + ok: false, + reason: "unsupported", + message: + `${parsed.browser} keeps its cookies encrypted with the desktop keyring; only gallery-dl ` + + "and yt-dlp read them (gallery-dl does, on every X fetch). This check and the " + + "Playwright fallback read Firefox only.", + }; + } + const home = opts.home ?? os.homedir(); + const found = await findFirefoxCookieDb(parsed, home); + if (!found.file) { + return { + ok: false, + reason: "not-found", + message: `No Firefox cookie store found (looked in ${found.searched.join(", ")}).`, + }; + } + try { + const cookies = await readFirefoxXCookies(found.file, { + container: parsed.container, + tmpRoot: opts.tmpRoot, + now: opts.now, + }); + return { ok: true, cookies, browser: parsed.browser, store: found.file }; + } catch (err) { + return { + ok: false, + reason: "unreadable", + message: `Could not read ${found.file}: ${(err as Error).message}`, + }; + } +} + +// --- The source in use, and whether it holds a login -------------------------- + +// The slice of SiteSettings this module reads. Structural, so settings.ts is +// not imported here. +export type XLoginSettings = { + cookiesFromBrowser?: string; + social?: { x?: { cookieSource?: XCookieSource } }; +}; + +// The source for this host now: reads whether the broker's profile is +// connected. `browserSpec` defaults to the global cookiesFromBrowser; the fetch +// controller passes a channel's own when it has one. +export async function resolveXCookieSourceFor( + paths: Paths, + settings: XLoginSettings, + browserSpec: string | undefined = settings.cookiesFromBrowser, +): Promise<ResolvedXCookieSource> { + const profile = await readXSessionStatus(paths); + return resolveXCookieSource({ + stored: settings.social?.x?.cookieSource, + browserSpec, + profileConnected: profile.looksAuthenticated, + }); +} + +export type XLoginStatus = XCookieSourceView & { + // Whether an auth_token cookie for x.com is visible in the source: null when + // the source could not be read (no spec, an unsupported browser, no store). + authTokenVisible: boolean | null; + // When the login was last seen: for the browser source, when the browser + // last sent its auth_token (the store's lastAccessed); for the profile, when + // the jar was last exported. + lastSeenAt?: string; + checkedAt: string; + // One sentence for the Settings section. + summary: string; +}; + +function when(iso: string): string { + return iso.replace("T", " ").replace(/\.\d+Z$/, " UTC"); +} + +export async function readXLoginStatus( + paths: Paths, + settings: XLoginSettings, + opts: { home?: string; tmpRoot?: string; now?: number } = {}, +): Promise<XLoginStatus> { + const checkedAt = new Date(opts.now ?? Date.now()).toISOString(); + const resolved = await resolveXCookieSourceFor(paths, settings); + const base = { ...xCookieSourceView(resolved), checkedAt }; + + if (resolved.source === "profile") { + const profile = await readXSessionStatus(paths); + if (profile.looksAuthenticated) { + return { + ...base, + authTokenVisible: true, + lastSeenAt: profile.cookiesUpdatedAt, + summary: + "The connected profile holds an X login" + + (profile.cookiesUpdatedAt ? ` (cookies exported ${when(profile.cookiesUpdatedAt)}).` : "."), + }; + } + return { + ...base, + authTokenVisible: false, + lastSeenAt: profile.cookiesUpdatedAt, + summary: profile.hasProfile + ? "The profile is not logged in to X (no auth_token in its exported cookies). Connect again, or choose the browser login." + : "No profile is connected. Connect an X account, or choose the browser login.", + }; + } + + const read = await xCookiesFromBrowser(resolved.browserSpec, opts); + if (!read.ok) { + return { ...base, authTokenVisible: null, summary: read.message }; + } + const auth = read.cookies + .filter((c) => c.name === X_AUTH_COOKIE) + .sort((a, b) => (b.lastAccessedMs ?? 0) - (a.lastAccessedMs ?? 0))[0]; + if (!auth) { + return { + ...base, + authTokenVisible: false, + summary: `No X login in ${read.browser}: no auth_token cookie for x.com. Log in to x.com in that browser.`, + }; + } + const lastSeenAt = auth.lastAccessedMs ? new Date(auth.lastAccessedMs).toISOString() : undefined; + return { + ...base, + authTokenVisible: true, + lastSeenAt, + summary: + `An X login is visible in ${read.browser}` + + (lastSeenAt ? ` (its auth_token last used ${when(lastSeenAt)}).` : "."), + }; +} diff --git a/common/social/xCookieSource.test.ts b/common/social/xCookieSource.test.ts @@ -0,0 +1,102 @@ +// The X login source and its read-time default (release 16 slice XL). +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xCookieSource.test.ts + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isXCookieSource, + parseBrowserSpec, + resolveXCookieSource, + sanitizeSocial, + xCookieSourceLabel, +} from "./xCookieSource"; + +test("the default: browser when cookiesFromBrowser is set and no profile is connected", () => { + assert.deepEqual(resolveXCookieSource({ browserSpec: "firefox", profileConnected: false }), { + source: "browser", + chosen: false, + browserSpec: "firefox", + }); +}); + +test("the default: profile when a profile is connected, even with cookiesFromBrowser set", () => { + assert.deepEqual(resolveXCookieSource({ browserSpec: "firefox", profileConnected: true }), { + source: "profile", + chosen: false, + browserSpec: "firefox", + }); +}); + +test("the default: profile when cookiesFromBrowser is empty", () => { + for (const browserSpec of [undefined, "", " "]) { + assert.deepEqual(resolveXCookieSource({ browserSpec, profileConnected: false }), { + source: "profile", + chosen: false, + browserSpec: undefined, + }); + } +}); + +test("a stored choice always wins over the default", () => { + assert.deepEqual( + resolveXCookieSource({ stored: "profile", browserSpec: "firefox", profileConnected: false }), + { source: "profile", chosen: true, browserSpec: "firefox" }, + ); + assert.deepEqual( + resolveXCookieSource({ stored: "browser", browserSpec: "firefox", profileConnected: true }), + { source: "browser", chosen: true, browserSpec: "firefox" }, + ); + // Chosen with nothing to read: still the browser, and the label says why it + // will not log in. + const r = resolveXCookieSource({ stored: "browser", browserSpec: "", profileConnected: true }); + assert.deepEqual(r, { source: "browser", chosen: true, browserSpec: undefined }); + assert.equal(xCookieSourceLabel(r), "Browser login (no cookiesFromBrowser set)"); +}); + +test("the labels", () => { + assert.equal( + xCookieSourceLabel({ source: "browser", chosen: false, browserSpec: "firefox" }), + "Browser login (firefox)", + ); + assert.equal(xCookieSourceLabel({ source: "profile", chosen: true }), "Connected profile"); +}); + +test("isXCookieSource and sanitizeSocial", () => { + assert.equal(isXCookieSource("browser"), true); + assert.equal(isXCookieSource("profile"), true); + assert.equal(isXCookieSource("firefox"), false); + assert.equal(isXCookieSource(undefined), false); + assert.deepEqual(sanitizeSocial(undefined), { x: {} }); + assert.deepEqual(sanitizeSocial({ x: { cookieSource: "profile" } }), { x: { cookieSource: "profile" } }); + assert.deepEqual(sanitizeSocial({ x: { cookieSource: "auto" } }), { x: {} }); +}); + +test("parseBrowserSpec reads yt-dlp's and gallery-dl's syntax", () => { + assert.deepEqual(parseBrowserSpec("firefox"), { browser: "firefox" }); + assert.deepEqual(parseBrowserSpec("Firefox"), { browser: "firefox" }); + assert.deepEqual(parseBrowserSpec("chrome:Default"), { browser: "chrome", profile: "Default" }); + assert.deepEqual(parseBrowserSpec("firefox:/home/u/.mozilla/firefox/abc.default"), { + browser: "firefox", + profile: "/home/u/.mozilla/firefox/abc.default", + }); + assert.deepEqual(parseBrowserSpec("firefox::Work"), { browser: "firefox", container: "Work" }); + assert.deepEqual(parseBrowserSpec("firefox:abc.default::none"), { + browser: "firefox", + profile: "abc.default", + container: "none", + }); + assert.deepEqual(parseBrowserSpec("chromium+gnomekeyring:Profile 1"), { + browser: "chromium", + keyring: "gnomekeyring", + profile: "Profile 1", + }); + // gallery-dl's /DOMAIN. + assert.deepEqual(parseBrowserSpec("firefox/.x.com:default"), { + browser: "firefox", + domain: ".x.com", + profile: "default", + }); + assert.equal(parseBrowserSpec(""), null); + assert.equal(parseBrowserSpec(":profile"), null); +}); diff --git a/common/social/xCookieSource.ts b/common/social/xCookieSource.ts @@ -0,0 +1,131 @@ +// WHERE THE X FETCHERS' LOGIN COMES FROM — one setting, `social.x.cookieSource` +// (release 16 slice XL). +// +// "browser" — the operator's everyday browser, named by `cookiesFromBrowser` +// (the same spec yt-dlp reads). gallery-dl is handed +// `--cookies-from-browser <spec>` and reads the browser's store +// itself on every run; the other X paths read the same store +// through xCookiesFromBrowser (xBrowserLogin.ts). Nothing expires +// while the operator stays logged in to x.com there. +// "profile" — the session broker's persistent profile (xSessionBroker.ts): +// "Connect X account" once, then the jar it exports. +// +// THE DEFAULT IS RESOLVED AT READ TIME, never stored: with no +// `social.x.cookieSource` in settings.json, the source is "browser" when +// `cookiesFromBrowser` is set and no profile is connected (no exported jar +// carrying an auth_token), else "profile". Choosing a source stores it, and a +// stored choice always wins. +// +// Pure and client-safe — no node imports. The settings schema, the fetch +// controller and the editor's Settings section all read it. + +export type XCookieSource = "browser" | "profile"; + +export const X_COOKIE_SOURCES: readonly XCookieSource[] = ["browser", "profile"]; + +export function isXCookieSource(v: unknown): v is XCookieSource { + return v === "browser" || v === "profile"; +} + +// The `social` block of settings.json. Only X has a login to choose today; the +// block is per platform so a second one does not need a second top-level key. +export type XSocialSettings = { + // Absent = the read-time default above. + cookieSource?: XCookieSource; +}; + +export type SocialSettings = { + x: XSocialSettings; +}; + +// Total over `unknown`, as every settings coercion is: anything that is not a +// known source reads as absent (the default), and unknown keys are dropped. +export function sanitizeSocial(value: unknown): SocialSettings { + const r = (value && typeof value === "object" && !Array.isArray(value) + ? value + : {}) as Record<string, unknown>; + const x = (r.x && typeof r.x === "object" && !Array.isArray(r.x) + ? r.x + : {}) as Record<string, unknown>; + return { + x: isXCookieSource(x.cookieSource) ? { cookieSource: x.cookieSource } : {}, + }; +} + +export type ResolvedXCookieSource = { + source: XCookieSource; + // True when settings.json names the source; false when the read-time default + // decided it. + chosen: boolean; + // The browser spec the "browser" source reads (`cookiesFromBrowser`, a + // channel's own when it has one), or undefined when none is set. + browserSpec?: string; +}; + +export function resolveXCookieSource(input: { + stored?: XCookieSource; + browserSpec?: string; + // The session broker's profile holds a login: its exported jar carries an + // auth_token (readXSessionStatus().looksAuthenticated). + profileConnected: boolean; +}): ResolvedXCookieSource { + const browserSpec = input.browserSpec?.trim() || undefined; + if (isXCookieSource(input.stored)) { + return { source: input.stored, chosen: true, browserSpec }; + } + return { + source: browserSpec && !input.profileConnected ? "browser" : "profile", + chosen: false, + browserSpec, + }; +} + +// A browser cookie spec, as `cookiesFromBrowser` holds it. yt-dlp's syntax is +// BROWSER[+KEYRING][:PROFILE][::CONTAINER]; gallery-dl's adds /DOMAIN after the +// browser name. Both programs get the spec verbatim — this parse is only for +// the readers here, which need the browser, the profile and the container. +export type BrowserSpec = { + browser: string; + domain?: string; + keyring?: string; + profile?: string; + container?: string; +}; + +const SPEC_RE = + /^(?<browser>[^/+:]+)(?:\/(?<domain>[^+:]+))?(?:\s*\+\s*(?<keyring>[^:]+))?(?:\s*:\s*(?!:)(?<profile>.+?))?(?:\s*::\s*(?<container>.+))?$/; + +export function parseBrowserSpec(spec: string): BrowserSpec | null { + const m = SPEC_RE.exec(spec.trim()); + if (!m?.groups) return null; + const g = m.groups; + const out: BrowserSpec = { browser: g.browser.trim().toLowerCase() }; + if (g.domain) out.domain = g.domain.trim(); + if (g.keyring) out.keyring = g.keyring.trim(); + if (g.profile) out.profile = g.profile.trim(); + if (g.container) out.container = g.container.trim(); + return out; +} + +// The browsers whose cookie store this repo reads itself (xBrowserLogin.ts). +// Every other browser gallery-dl and yt-dlp support (the Chromium family keeps +// its cookies encrypted with a desktop-keyring key) is read by those programs +// alone; the Playwright fallback and the Settings check say so instead. +export const SELF_READ_BROWSERS: readonly string[] = ["firefox"]; + +// The label the Settings section and the logs use for a source. +export function xCookieSourceLabel(r: ResolvedXCookieSource): string { + if (r.source === "browser") { + return r.browserSpec + ? `Browser login (${r.browserSpec})` + : "Browser login (no cookiesFromBrowser set)"; + } + return "Connected profile"; +} + +// A resolved source with its label: what the Settings section renders. +export type XCookieSourceView = ResolvedXCookieSource & { label: string }; + +export function xCookieSourceView(r: ResolvedXCookieSource): XCookieSourceView { + return { ...r, label: xCookieSourceLabel(r) }; +} diff --git a/common/social/xGalleryDlFetcher.test.ts b/common/social/xGalleryDlFetcher.test.ts @@ -0,0 +1,86 @@ +// gallery-dl's argv per X login source (release 16 slice XL). The rest of +// gallery-dl's argv and parsing is covered in xNormalize.test.ts. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xGalleryDlFetcher.test.ts + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildGalleryDlArgs, galleryDlCookieChoice } from "./xGalleryDlFetcher"; + +const ACCOUNT = "https://x.com/someaccount"; +const JAR = "/corpus/.x-session/cookies.txt"; + +function argvFor(choice: ReturnType<typeof galleryDlCookieChoice>): string[] { + return buildGalleryDlArgs({ + accountUrl: ACCOUNT, + cookies: choice.cookies, + cookieFile: choice.cookieFile, + }); +} + +function flagValue(argv: string[], flag: string): string | undefined { + const i = argv.indexOf(flag); + return i >= 0 ? argv[i + 1] : undefined; +} + +test("browser source: --cookies-from-browser with the spec, never the jar", () => { + const argv = argvFor( + galleryDlCookieChoice({ source: "browser", browserCookies: "firefox", jarFile: JAR }), + ); + assert.equal(flagValue(argv, "--cookies-from-browser"), "firefox"); + assert.equal(argv.includes("--cookies"), false); + assert.equal(argv[argv.length - 1], "https://x.com/someaccount/timeline"); +}); + +test("browser source: the spec is passed verbatim (profile, container, gallery-dl's /DOMAIN)", () => { + for (const spec of ["firefox:abc.default-release", "firefox::Work", "chromium+gnomekeyring:Default", "firefox/.x.com"]) { + const argv = argvFor(galleryDlCookieChoice({ source: "browser", browserCookies: spec })); + assert.equal(flagValue(argv, "--cookies-from-browser"), spec); + } +}); + +test("browser source: passed whatever the cookieMode — the source governs X", () => { + // No "always"-mode spec (cookieMode when-required or defer), still passed. + const choice = galleryDlCookieChoice({ + source: "browser", + browserCookies: "firefox", + alwaysCookies: undefined, + }); + assert.equal(choice.cookies, "firefox"); + assert.match(choice.note, /the browser \(firefox\)/); +}); + +test("browser source with no spec: a guest run that says why", () => { + const choice = galleryDlCookieChoice({ source: "browser", browserCookies: " ", jarFile: JAR }); + const argv = argvFor(choice); + assert.equal(argv.includes("--cookies-from-browser"), false); + assert.equal(argv.includes("--cookies"), false); + assert.match(choice.note, /cookiesFromBrowser is empty/); +}); + +test("profile source: the jar, and no --cookies-from-browser", () => { + const argv = argvFor( + galleryDlCookieChoice({ source: "profile", browserCookies: "firefox", jarFile: JAR, alwaysCookies: "firefox" }), + ); + assert.equal(flagValue(argv, "--cookies"), JAR); + assert.equal(argv.includes("--cookies-from-browser"), false); +}); + +test("profile source with no logged-in jar: the cookieMode \"always\" spec, else a guest", () => { + const always = argvFor(galleryDlCookieChoice({ source: "profile", browserCookies: "firefox", alwaysCookies: "firefox" })); + assert.equal(flagValue(always, "--cookies-from-browser"), "firefox"); + + const guest = galleryDlCookieChoice({ source: "profile", browserCookies: "firefox" }); + const argv = argvFor(guest); + assert.equal(argv.includes("--cookies-from-browser"), false); + assert.equal(argv.includes("--cookies"), false); + assert.match(guest.note, /guest/); +}); + +test("no source resolved (a caller from before the choice): the jar, else the always-mode spec", () => { + assert.equal(flagValue(argvFor(galleryDlCookieChoice({ jarFile: JAR, alwaysCookies: "firefox" })), "--cookies"), JAR); + assert.equal( + flagValue(argvFor(galleryDlCookieChoice({ alwaysCookies: "firefox" })), "--cookies-from-browser"), + "firefox", + ); +}); diff --git a/common/social/xGalleryDlFetcher.ts b/common/social/xGalleryDlFetcher.ts @@ -29,6 +29,7 @@ import { getPaths } from "../lib/paths"; import type { Post } from "../lib/posts"; import { normalizeXTweets, type XTweetRaw } from "./xNormalize"; import { readXSessionStatus, xCookieFile } from "./xSessionBroker"; +import type { XCookieSource } from "./xCookieSource"; import { registerSocialFetcher, type PostFetchInput, @@ -129,6 +130,50 @@ export function buildGalleryDlArgs(opts: { return args; } +// WHICH LOGIN gallery-dl is handed this run (release 16 slice XL), pure so the +// argv per source is testable: +// "browser" — `--cookies-from-browser <spec>`, always (gallery-dl reads the +// operator's browser itself, fresh on every run); never the jar. +// With no spec, a guest run that says why. +// "profile" — the broker's jar when it holds a login, else the cookieMode +// "always" spec, else a guest run. +// unset — the same as "profile": a caller that resolves no source keeps +// the behaviour from before the choice existed. +export function galleryDlCookieChoice(opts: { + source?: XCookieSource; + // The spec the browser source reads (channel over global, any cookieMode). + browserCookies?: string; + // The broker's exported jar, when it carries an auth_token. + jarFile?: string; + // resolveCookiePolicy()'s "always"-mode spec. + alwaysCookies?: string; +}): { cookies?: string; cookieFile?: string; note: string } { + if (opts.source === "browser") { + const spec = opts.browserCookies?.trim(); + if (spec) { + return { cookies: spec, note: `X login: the browser (${spec}), read by gallery-dl.` }; + } + return { + note: + "X login: the browser source is chosen but cookiesFromBrowser is empty — " + + "running as a guest.", + }; + } + if (opts.jarFile) { + return { + cookieFile: opts.jarFile, + note: "X login: the connected profile (the X session broker's exported cookies).", + }; + } + if (opts.alwaysCookies) { + return { + cookies: opts.alwaysCookies, + note: `X login: no connected profile; the cookieMode "always" browser (${opts.alwaysCookies}).`, + }; + } + return { note: "X login: none (no connected profile) — running as a guest." }; +} + // X ids are 64-bit and gallery-dl emits them as UNQUOTED JSON NUMBERS // (`"tweet_id": 2085320225776427457`). That exceeds 2^53, so a plain // JSON.parse silently rounds it — 2085320225776427457 becomes @@ -256,19 +301,25 @@ export const xGalleryDlFetcher: SocialFetcher = { }, async fetch(input: PostFetchInput): Promise<PostFetchResult> { - const { accountUrl, channelSlug, since, seenIds, cookies, limit, signal, onLog } = + const { accountUrl, channelSlug, since, seenIds, limit, signal, onLog } = input; const paths = getPaths(); const bin = paths.galleryDlBin; - // Prefer the session broker's exported jar when one exists — it is kept - // fresh by a live browser profile, so it survives the cookie expiry that - // otherwise breaks gallery-dl within days. + // The login source (galleryDlCookieChoice): the operator's browser, or the + // session broker's exported jar — kept fresh by a live browser profile, so + // it survives the cookie expiry that otherwise breaks gallery-dl within days. const status = await readXSessionStatus(paths); - const cookieFile = - status.hasCookies && status.looksAuthenticated - ? xCookieFile(paths) - : undefined; - if (cookieFile) onLog?.("Using the X session broker's exported cookies."); + const choice = galleryDlCookieChoice({ + source: input.cookieSource, + browserCookies: input.browserCookies, + jarFile: + status.hasCookies && status.looksAuthenticated + ? xCookieFile(paths) + : undefined, + alwaysCookies: input.cookies, + }); + onLog?.(choice.note); + const { cookies, cookieFile } = choice; const args = buildGalleryDlArgs({ accountUrl, cookies, cookieFile, limit }); onLog?.(`Running ${bin} for ${accountUrl}`); diff --git a/common/social/xPlaywrightFetcher.ts b/common/social/xPlaywrightFetcher.ts @@ -11,7 +11,8 @@ // // Known costs, accepted deliberately: // - Playwright Chromium's JA3/TLS fingerprint matches no real Chrome release -// (plus navigator.webdriver and CDP artifacts), so X CAN detect it. +// (plus the HeadlessChrome user agent and CDP artifacts; navigator.webdriver +// is off since release 16 slice XL, xBrowser.ts), so X CAN detect it. // - Ban risk on the logged-in account is higher than an offline cookie read. // - A browser process per fetch is slow and RAM-hungry. // - It will NOT run in the minimal Docker build container — post fetching @@ -31,9 +32,15 @@ import type { Post } from "../lib/posts"; import { normalizeXTweets, type XTweetRaw } from "./xNormalize"; -import { xProfileDir } from "./xSessionBroker"; +import { launchXProfile } from "./xSessionBroker"; import { getPaths } from "../lib/paths"; -import { importPlaywright } from "./playwrightRuntime"; +import { + importPlaywright, + type BrowserContextLike, + type BrowserLike, +} from "./playwrightRuntime"; +import { buildXBrowserLaunchOptions } from "./xBrowser"; +import { X_AUTH_COOKIE, xCookiesFromBrowser } from "./xBrowserLogin"; import { registerSocialFetcher, type PostFetchInput, @@ -151,11 +158,35 @@ export const xPlaywrightFetcher: SocialFetcher = { const { accountUrl, channelSlug, since, seenIds, limit, signal, onLog } = input; const paths = getPaths(); - const { chromium } = await importPlaywright(); - onLog?.("Launching the authenticated browser profile (fallback path)."); - const context = await chromium.launchPersistentContext(xProfileDir(paths), { - headless: true, - }); + // The login source (xCookieSource.ts). "browser": a fresh headless + // context carrying the operator's browser cookies, read now — the browser + // store this repo reads itself is Firefox's (xBrowserLogin.ts); any other + // is refused by name rather than run logged out. Otherwise the broker's + // persistent profile, as before. + let context: BrowserContextLike; + let browser: BrowserLike | undefined; + if (input.cookieSource === "browser") { + const read = await xCookiesFromBrowser(input.browserCookies); + if (!read.ok) { + throw new Error(`The X login source is the browser, and it cannot be read here: ${read.message}`); + } + const hasAuth = read.cookies.some((c) => c.name === X_AUTH_COOKIE); + onLog?.( + `Launching a headless browser with ${read.cookies.length} X cookie(s) from ${read.browser} ` + + `(fallback path)${hasAuth ? "" : " — WARNING: no auth_token, the browser is not logged in to X"}.`, + ); + const { chromium } = await importPlaywright(); + browser = await chromium.launch( + buildXBrowserLaunchOptions({ browser: { kind: "bundled" }, headless: true }), + ); + context = await browser.newContext({}); + await context.addCookies( + read.cookies.map(({ lastAccessedMs: _unused, ...c }) => c), + ); + } else { + onLog?.("Launching the authenticated browser profile (fallback path)."); + context = await launchXProfile(paths, { onLog }); + } const captured: XTweetRaw[] = []; try { @@ -201,6 +232,7 @@ export const xPlaywrightFetcher: SocialFetcher = { onLog?.(`Captured ${captured.length} tweet record(s) from the timeline.`); } finally { await context.close().catch(() => {}); + await browser?.close().catch(() => {}); } // From here on it is identical to the gallery-dl path — same normalizer, diff --git a/common/social/xSessionBroker.ts b/common/social/xSessionBroker.ts @@ -18,12 +18,27 @@ // installed, so this adds NO new dependency. It will NOT run in the minimal // Docker build container — post fetching is an editor-host concern, never a // build-time one. +// +// Release 16 slice XL: the Connect window is the operator's own browser +// without the automation signals (xBrowser.ts), and the profile is one of two +// login sources — the other is the operator's everyday browser, read directly +// (`social.x.cookieSource`, xCookieSource.ts / xBrowserLogin.ts). import path from "node:path"; import { mkdir, readFile, rm, stat } from "node:fs/promises"; +import { execa } from "execa"; import { writeFileAtomic } from "../lib/jsonFile-server"; import type { Paths } from "../lib/paths"; -import { importPlaywright } from "./playwrightRuntime"; +import { importPlaywright, type BrowserContextLike } from "./playwrightRuntime"; +import { + buildXBrowserLaunchOptions, + describeXBrowser, + findXBrowser, + readXBrowserRecord, + recordedXBrowser, + writeXBrowserRecord, + type XBrowserChoice, +} from "./xBrowser"; // Where the persistent browser profile lives. One profile per instance: a // single X identity is all the archive needs. @@ -129,26 +144,71 @@ export function isXCookie(c: BrowserCookie): boolean { return d === "x.com" || d === "twitter.com" || d.endsWith(".x.com") || d.endsWith(".twitter.com"); } +// `<exe> --version` ("Chromium 153.0.8010.47 Arch Linux"), for the log and the +// profile's record. Best effort: a browser that does not answer in 5 s is +// simply not versioned. +async function browserVersion(b: XBrowserChoice): Promise<string | undefined> { + if (b.kind !== "system") return undefined; + try { + const res = await execa(b.executablePath, ["--version"], { reject: false, timeout: 5_000 }); + const line = `${res.stdout ?? ""}`.trim().split("\n")[0]; + return res.exitCode === 0 && line ? line : undefined; + } catch { + return undefined; + } +} + +function firstLine(err: unknown): string { + return ((err as Error)?.message ?? String(err)).split("\n")[0]; +} + // Launch a HEADED browser against the persistent profile so a human can log in // (password, 2FA, captcha — all of it). Resolves once the caller closes the // window, then exports the cookie jar. // +// The window is the operator's own browser without the automation signals +// (xBrowser.ts says which, and why Google's sign-in refused the old one). The +// executable is recorded in the profile, so a refresh that cannot open the +// profile with the bundled build can use the one that wrote it. +// // Playwright is imported dynamically: this module must stay importable on a // host with no browsers installed (the Docker build image), where only the // status/read helpers above are ever called. export async function connectXAccount( paths: Paths, - opts: { onLog?: (line: string) => void; timeoutMs?: number } = {}, -): Promise<XSessionStatus> { + opts: { + onLog?: (line: string) => void; + timeoutMs?: number; + env?: Record<string, string | undefined>; + } = {}, +): Promise<XSessionStatus & { browser: string }> { const log = opts.onLog ?? (() => {}); const profileDir = xProfileDir(paths); await mkdir(profileDir, { recursive: true }); + const browser = findXBrowser({ env: opts.env }); + const version = await browserVersion(browser); + const label = describeXBrowser(browser, version); const { chromium } = await importPlaywright(); - log("Opening a browser window. Log in to X, then close the window."); - const context = await chromium.launchPersistentContext(profileDir, { - headless: false, - viewport: { width: 1280, height: 900 }, + log(`Opening ${label}. Log in to X, then close the window.`); + let context: BrowserContextLike; + try { + context = await chromium.launchPersistentContext( + profileDir, + buildXBrowserLaunchOptions({ browser, headless: false, sandbox: true }), + ); + } catch (err) { + // A host that cannot start Chromium's sandbox (no unprivileged user + // namespaces, an AppArmor rule) still gets its window — with the + // unsupported-flag bar a system Chrome draws for `--no-sandbox`. + log(`The sandboxed launch failed (${firstLine(err)}); opening without the sandbox.`); + context = await chromium.launchPersistentContext( + profileDir, + buildXBrowserLaunchOptions({ browser, headless: false, sandbox: false }), + ); + } + await writeXBrowserRecord(profileDir, browser, version).catch((err) => { + log(`Could not record the browser in the profile: ${firstLine(err)}`); }); try { const page = context.pages()[0] ?? (await context.newPage()); @@ -170,7 +230,42 @@ export async function connectXAccount( } finally { await context.close().catch(() => {}); } - return readXSessionStatus(paths); + return { ...(await readXSessionStatus(paths)), browser: label }; +} + +// THE PROFILE, OPENED HEADLESS — the refresh below and the Playwright fallback +// fetcher (profile source) both come through here. The bundled build first +// (it never logs in, and its headless shell needs no display), without the +// automation signals; when it cannot open the profile and the profile records +// a system executable, that one, headless. +export async function launchXProfile( + paths: Paths, + opts: { onLog?: (line: string) => void } = {}, +): Promise<BrowserContextLike> { + const log = opts.onLog ?? (() => {}); + const profileDir = xProfileDir(paths); + const { chromium } = await importPlaywright(); + const bundled: XBrowserChoice = { kind: "bundled" }; + try { + const context = await chromium.launchPersistentContext( + profileDir, + buildXBrowserLaunchOptions({ browser: bundled, headless: true }), + ); + log("Opened the X session profile with Playwright's bundled Chromium (headless)."); + return context; + } catch (err) { + const record = await readXBrowserRecord(profileDir); + const recorded = recordedXBrowser(record); + if (!recorded) throw err; + log( + `Playwright's bundled Chromium could not open the profile (${firstLine(err)}); ` + + `using ${describeXBrowser(recorded, record?.version)}, headless.`, + ); + return chromium.launchPersistentContext( + profileDir, + buildXBrowserLaunchOptions({ browser: recorded, headless: true }), + ); + } } // Re-export cookies from the stored profile WITHOUT any human interaction — @@ -186,10 +281,7 @@ export async function refreshXCookies( "No X session profile yet — run the headed 'Connect X account' flow first.", ); } - const { chromium } = await importPlaywright(); - const context = await chromium.launchPersistentContext(profileDir, { - headless: true, - }); + const context = await launchXProfile(paths, { onLog: log }); try { // Touching the site lets X rotate/renew the session cookies before we read // them, which is the whole point of keeping a live profile. diff --git a/settings.json.example b/settings.json.example @@ -5,6 +5,9 @@ "transcriptionApps": {}, "cookiesFromBrowser": "", "cookieMode": "when-required", + "social": { + "x": {} + }, "sleepBetweenDownloadsSeconds": 10, "downloadFormat": "auto", "minFreeDiskGB": 5,