import readline from "node:readline"; import { createLogger, sleep, type Platform } from "@src/shared"; import { SocialBrowserSession } from "./session.ts"; const log = createLogger("login"); export const LOGIN_URLS: Record = { youtube: "https://www.youtube.com/", reddit: "https://www.reddit.com/login", facebook: "https://www.facebook.com/", instagram: "https://www.instagram.com/", tiktok: "https://www.tiktok.com/login", x: "https://x.com/login", linkedin: "https://www.linkedin.com/login", threads: "https://www.threads.net/login", }; /** Session cookies whose presence means "the human is logged in" (read-only check; values are never logged). */ export const AUTH_COOKIES: Record = { youtube: /^(SAPISID|LOGIN_INFO|__Secure-3PAPISID)$/, reddit: /^(reddit_session|token_v2)$/, facebook: /^c_user$/, instagram: /^(sessionid|ds_user_id)$/, tiktok: /^(sessionid|sid_tt)$/, x: /^(auth_token)$/, linkedin: /^(li_at)$/, threads: /^(sessionid|ds_user_id)$/, }; export async function isAuthenticated(session: SocialBrowserSession, platform: Platform): Promise { const cookies = await session.getContext().cookies().catch(() => []); return cookies.some((c) => AUTH_COOKIES[platform].test(c.name) && c.value.length > 0); } /** * Human-in-the-loop authentication (§7): open a headed browser on the platform and wait until the operator * has logged in — detected by the platform's session cookie, or by when a TTY is attached. * No credentials are read, typed or stored by the crawler; the persistent profile is the only state kept. */ export async function interactiveLogin(opts: { platform: Platform; accountAlias: string; profilesDir: string; channel?: string; timeoutMin?: number }): Promise { const session = new SocialBrowserSession({ ...opts, headless: false }); await session.start(); await session.navigate(LOGIN_URLS[opts.platform]); if (await isAuthenticated(session, opts.platform)) { log.info("profile already authenticated", { platform: opts.platform, alias: opts.accountAlias }); await sleep(1500); await session.stop(); return true; } log.info(`Browser is open on ${opts.platform}. Log in manually (and pass any 2FA yourself). I will detect the session automatically.`); const deadline = Date.now() + (opts.timeoutMin ?? 15) * 60_000; let enterPressed = false; const rl = process.stdin.isTTY ? readline.createInterface({ input: process.stdin, output: process.stdout }) : undefined; rl?.question("…or press once you are logged in. ", () => (enterPressed = true)); let ok = false; while (Date.now() < deadline) { if (session.getInfo().health === "crashed") break; if (await isAuthenticated(session, opts.platform)) { ok = true; break; } if (enterPressed) { ok = await isAuthenticated(session, opts.platform); break; } await sleep(2000); } rl?.close(); if (ok) { // Let the app finish writing its storage, then land on the home page so the profile starts in a clean state. await sleep(4000); await session.navigate(LOGIN_URLS[opts.platform].replace(/\/login.*$/, "/")).catch(() => {}); await sleep(2500); } const page = session.getPage(); log.info(ok ? "profile saved — authenticated" : "no authenticated session detected (timeout)", { platform: opts.platform, alias: opts.accountAlias, url: page.url(), profile: session.profilePath }); await session.stop(); return ok; }