426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import { mkdir } from "node:fs/promises";
|
|
import net from "node:net";
|
|
import process from "node:process";
|
|
|
|
import { read_cookie_file, write_cookie_file } from "./cookie-file.js";
|
|
import { resolveXToMarkdownCookiePath } from "./paths.js";
|
|
import { X_COOKIE_NAMES, X_REQUIRED_COOKIES, X_LOGIN_URL, X_USER_DATA_DIR } from "./constants.js";
|
|
import type { CookieLike } from "./types.js";
|
|
|
|
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function fetchWithTimeout(
|
|
url: string,
|
|
init: RequestInit & { timeoutMs?: number } = {}
|
|
): Promise<Response> {
|
|
const { timeoutMs, ...rest } = init;
|
|
if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest);
|
|
|
|
const ctl = new AbortController();
|
|
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
try {
|
|
return await fetch(url, { ...rest, signal: ctl.signal });
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
}
|
|
|
|
class CdpConnection {
|
|
private ws: WebSocket;
|
|
private nextId = 0;
|
|
private pending = new Map<
|
|
number,
|
|
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
|
>();
|
|
|
|
private constructor(ws: WebSocket) {
|
|
this.ws = ws;
|
|
this.ws.addEventListener("message", (event) => {
|
|
try {
|
|
const data =
|
|
typeof event.data === "string"
|
|
? event.data
|
|
: new TextDecoder().decode(event.data as ArrayBuffer);
|
|
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
|
if (msg.id) {
|
|
const p = this.pending.get(msg.id);
|
|
if (p) {
|
|
this.pending.delete(msg.id);
|
|
if (p.timer) clearTimeout(p.timer);
|
|
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
|
else p.resolve(msg.result);
|
|
}
|
|
}
|
|
} catch {}
|
|
});
|
|
this.ws.addEventListener("close", () => {
|
|
for (const [id, p] of this.pending.entries()) {
|
|
this.pending.delete(id);
|
|
if (p.timer) clearTimeout(p.timer);
|
|
p.reject(new Error("CDP connection closed."));
|
|
}
|
|
});
|
|
}
|
|
|
|
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
|
const ws = new WebSocket(url);
|
|
await new Promise<void>((resolve, reject) => {
|
|
const t = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
|
ws.addEventListener("open", () => {
|
|
clearTimeout(t);
|
|
resolve();
|
|
});
|
|
ws.addEventListener("error", () => {
|
|
clearTimeout(t);
|
|
reject(new Error("CDP connection failed."));
|
|
});
|
|
});
|
|
return new CdpConnection(ws);
|
|
}
|
|
|
|
async send<T = unknown>(
|
|
method: string,
|
|
params?: Record<string, unknown>,
|
|
opts?: CdpSendOptions
|
|
): Promise<T> {
|
|
const id = ++this.nextId;
|
|
const msg: Record<string, unknown> = { id, method };
|
|
if (params) msg.params = params;
|
|
if (opts?.sessionId) msg.sessionId = opts.sessionId;
|
|
|
|
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
|
const out = await new Promise<unknown>((resolve, reject) => {
|
|
const t =
|
|
timeoutMs > 0
|
|
? setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`CDP timeout: ${method}`));
|
|
}, timeoutMs)
|
|
: null;
|
|
this.pending.set(id, { resolve, reject, timer: t });
|
|
this.ws.send(JSON.stringify(msg));
|
|
});
|
|
return out as T;
|
|
}
|
|
|
|
close(): void {
|
|
try {
|
|
this.ws.close();
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
async function getFreePort(): Promise<number> {
|
|
const fixed = parseInt(process.env.X_DEBUG_PORT || "", 10);
|
|
if (fixed > 0) return fixed;
|
|
return await new Promise((resolve, reject) => {
|
|
const srv = net.createServer();
|
|
srv.unref();
|
|
srv.on("error", reject);
|
|
srv.listen(0, "127.0.0.1", () => {
|
|
const addr = srv.address();
|
|
if (!addr || typeof addr === "string") {
|
|
srv.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
|
return;
|
|
}
|
|
const port = addr.port;
|
|
srv.close((err) => (err ? reject(err) : resolve(port)));
|
|
});
|
|
});
|
|
}
|
|
|
|
function findChromeExecutable(): string | null {
|
|
const override = process.env.X_CHROME_PATH?.trim();
|
|
if (override && fs.existsSync(override)) return override;
|
|
|
|
const candidates: string[] = [];
|
|
switch (process.platform) {
|
|
case "darwin":
|
|
candidates.push(
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
|
);
|
|
break;
|
|
case "win32":
|
|
candidates.push(
|
|
"C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe",
|
|
"C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe",
|
|
"C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe",
|
|
"C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe"
|
|
);
|
|
break;
|
|
default:
|
|
candidates.push(
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/google-chrome-stable",
|
|
"/usr/bin/chromium",
|
|
"/usr/bin/chromium-browser",
|
|
"/snap/bin/chromium",
|
|
"/usr/bin/microsoft-edge"
|
|
);
|
|
break;
|
|
}
|
|
|
|
for (const p of candidates) {
|
|
if (fs.existsSync(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 });
|
|
if (!res.ok) throw new Error(`status=${res.status}`);
|
|
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
|
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
|
} catch {}
|
|
await sleep(200);
|
|
}
|
|
throw new Error("Chrome debug port not ready");
|
|
}
|
|
|
|
async function launchChrome(profileDir: string, port: number): Promise<ChildProcess> {
|
|
const chrome = findChromeExecutable();
|
|
if (!chrome) throw new Error("Chrome executable not found.");
|
|
|
|
const args = [
|
|
`--remote-debugging-port=${port}`,
|
|
`--user-data-dir=${profileDir}`,
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--disable-popup-blocking",
|
|
X_LOGIN_URL,
|
|
];
|
|
|
|
return spawn(chrome, args, { stdio: "ignore" });
|
|
}
|
|
|
|
async function fetchXCookiesViaCdp(
|
|
profileDir: string,
|
|
timeoutMs: number,
|
|
verbose: boolean,
|
|
log?: (message: string) => void
|
|
): Promise<Record<string, string>> {
|
|
await mkdir(profileDir, { recursive: true });
|
|
|
|
const port = await getFreePort();
|
|
const chrome = await launchChrome(profileDir, port);
|
|
|
|
let cdp: CdpConnection | null = null;
|
|
try {
|
|
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
|
cdp = await CdpConnection.connect(wsUrl, 15_000);
|
|
|
|
const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", {
|
|
url: X_LOGIN_URL,
|
|
newWindow: true,
|
|
});
|
|
const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true });
|
|
await cdp.send("Network.enable", {}, { sessionId });
|
|
|
|
if (verbose) {
|
|
log?.("[x-cookies] Chrome opened. If needed, complete X login in the window. Waiting for cookies...");
|
|
}
|
|
|
|
const start = Date.now();
|
|
let last: Record<string, string> = {};
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
const { cookies } = await cdp.send<{ cookies: CookieLike[] }>(
|
|
"Network.getCookies",
|
|
{ urls: ["https://x.com/", "https://twitter.com/"] },
|
|
{ sessionId, timeoutMs: 10_000 }
|
|
);
|
|
|
|
const m = buildXCookieMap((cookies ?? []).filter(Boolean));
|
|
last = m;
|
|
if (hasRequiredXCookies(m)) {
|
|
return m;
|
|
}
|
|
|
|
await sleep(1000);
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for X cookies. Last keys: ${Object.keys(last).join(", ")}`);
|
|
} finally {
|
|
if (cdp) {
|
|
try {
|
|
await cdp.send("Browser.close", {}, { timeoutMs: 5_000 });
|
|
} catch {}
|
|
cdp.close();
|
|
}
|
|
|
|
try {
|
|
chrome.kill("SIGTERM");
|
|
} catch {}
|
|
setTimeout(() => {
|
|
if (!chrome.killed) {
|
|
try {
|
|
chrome.kill("SIGKILL");
|
|
} catch {}
|
|
}
|
|
}, 2_000).unref?.();
|
|
}
|
|
}
|
|
|
|
function resolveCookieDomain(cookie: CookieLike): string | null {
|
|
const rawDomain = cookie.domain?.trim();
|
|
if (rawDomain) {
|
|
return rawDomain.startsWith(".") ? rawDomain.slice(1) : rawDomain;
|
|
}
|
|
const rawUrl = cookie.url?.trim();
|
|
if (rawUrl) {
|
|
try {
|
|
return new URL(rawUrl).hostname;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function pickCookieValue<T extends CookieLike>(cookies: T[], name: string): string | undefined {
|
|
const matches = cookies.filter((cookie) => cookie.name === name && typeof cookie.value === "string");
|
|
if (matches.length === 0) return undefined;
|
|
|
|
const preferred = matches.find((cookie) => {
|
|
const domain = resolveCookieDomain(cookie);
|
|
return domain === "x.com" && (cookie.path ?? "/") === "/";
|
|
});
|
|
const xDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? "").endsWith("x.com"));
|
|
const twitterDomain = matches.find((cookie) => (resolveCookieDomain(cookie) ?? "").endsWith("twitter.com"));
|
|
return (preferred ?? xDomain ?? twitterDomain ?? matches[0])?.value;
|
|
}
|
|
|
|
function buildXCookieMap<T extends CookieLike>(cookies: T[]): Record<string, string> {
|
|
const cookieMap: Record<string, string> = {};
|
|
for (const name of X_COOKIE_NAMES) {
|
|
const value = pickCookieValue(cookies, name);
|
|
if (value) cookieMap[name] = value;
|
|
}
|
|
return cookieMap;
|
|
}
|
|
|
|
export function hasRequiredXCookies(cookieMap: Record<string, string>): boolean {
|
|
return X_REQUIRED_COOKIES.every((name) => Boolean(cookieMap[name]));
|
|
}
|
|
|
|
function filterXCookieMap(cookieMap: Record<string, string>): Record<string, string> {
|
|
const filtered: Record<string, string> = {};
|
|
for (const name of X_COOKIE_NAMES) {
|
|
const value = cookieMap[name];
|
|
if (value) filtered[name] = value;
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function buildInlineCookiesFromEnv(): CookieLike[] {
|
|
const cookies: CookieLike[] = [];
|
|
const authToken = process.env.X_AUTH_TOKEN?.trim();
|
|
const ct0 = process.env.X_CT0?.trim();
|
|
const gt = process.env.X_GUEST_TOKEN?.trim();
|
|
const twid = process.env.X_TWID?.trim();
|
|
|
|
if (authToken) {
|
|
cookies.push({ name: "auth_token", value: authToken, domain: "x.com", path: "/" });
|
|
}
|
|
if (ct0) {
|
|
cookies.push({ name: "ct0", value: ct0, domain: "x.com", path: "/" });
|
|
}
|
|
if (gt) {
|
|
cookies.push({ name: "gt", value: gt, domain: "x.com", path: "/" });
|
|
}
|
|
if (twid) {
|
|
cookies.push({ name: "twid", value: twid, domain: "x.com", path: "/" });
|
|
}
|
|
|
|
return cookies;
|
|
}
|
|
|
|
async function loadXCookiesFromInline(log?: (message: string) => void): Promise<Record<string, string>> {
|
|
const inline = buildInlineCookiesFromEnv();
|
|
if (inline.length === 0) return {};
|
|
|
|
const cookieMap = buildXCookieMap(
|
|
inline.filter((cookie): cookie is CookieLike => Boolean(cookie?.name && typeof cookie.value === "string"))
|
|
);
|
|
|
|
if (Object.keys(cookieMap).length > 0) {
|
|
log?.(`[x-cookies] Loaded X cookies from env: ${Object.keys(cookieMap).length} cookie(s).`);
|
|
} else {
|
|
log?.("[x-cookies] Env cookies provided but no X cookies matched.");
|
|
}
|
|
|
|
return cookieMap;
|
|
}
|
|
|
|
async function loadXCookiesFromFile(log?: (message: string) => void): Promise<Record<string, string>> {
|
|
const cookiePath = resolveXToMarkdownCookiePath();
|
|
const fileMap = filterXCookieMap((await read_cookie_file(cookiePath)) ?? {});
|
|
if (Object.keys(fileMap).length > 0) {
|
|
log?.(`[x-cookies] Loaded X cookies from file: ${cookiePath} (${Object.keys(fileMap).length} cookie(s))`);
|
|
}
|
|
return fileMap;
|
|
}
|
|
|
|
async function loadXCookiesFromCdp(log?: (message: string) => void): Promise<Record<string, string>> {
|
|
try {
|
|
const cookieMap = await fetchXCookiesViaCdp(X_USER_DATA_DIR, 5 * 60 * 1000, true, log);
|
|
if (!hasRequiredXCookies(cookieMap)) return cookieMap;
|
|
|
|
const cookiePath = resolveXToMarkdownCookiePath();
|
|
try {
|
|
await write_cookie_file(cookieMap, cookiePath, "cdp");
|
|
log?.(`[x-cookies] Cookies saved to ${cookiePath}`);
|
|
} catch (error) {
|
|
log?.(
|
|
`[x-cookies] Failed to write cookie file (${cookiePath}): ${
|
|
error instanceof Error ? error.message : String(error ?? "")
|
|
}`
|
|
);
|
|
}
|
|
if (cookieMap.auth_token) log?.(`[x-cookies] auth_token: ${cookieMap.auth_token.slice(0, 20)}...`);
|
|
if (cookieMap.ct0) log?.(`[x-cookies] ct0: ${cookieMap.ct0.slice(0, 20)}...`);
|
|
return cookieMap;
|
|
} catch (error) {
|
|
log?.(
|
|
`[x-cookies] Failed to load cookies via Chrome DevTools Protocol: ${
|
|
error instanceof Error ? error.message : String(error ?? "")
|
|
}`
|
|
);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
export async function loadXCookies(log?: (message: string) => void): Promise<Record<string, string>> {
|
|
const inlineMap = await loadXCookiesFromInline(log);
|
|
const fileMap = await loadXCookiesFromFile(log);
|
|
const combined = { ...fileMap, ...inlineMap };
|
|
|
|
if (hasRequiredXCookies(combined)) return combined;
|
|
|
|
const cdpMap = await loadXCookiesFromCdp(log);
|
|
return { ...fileMap, ...cdpMap, ...inlineMap };
|
|
}
|
|
|
|
export async function refreshXCookies(log?: (message: string) => void): Promise<Record<string, string>> {
|
|
return loadXCookiesFromCdp(log);
|
|
}
|
|
|
|
export function buildCookieHeader(cookieMap: Record<string, string>): string | undefined {
|
|
const entries = Object.entries(cookieMap).filter(([, value]) => value);
|
|
if (entries.length === 0) return undefined;
|
|
return entries.map(([key, value]) => `${key}=${value}`).join("; ");
|
|
}
|