@nijaru/pi-search 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ import { SafeFetchError } from "./fetch-errors";
2
+ import { validateRemoteUrl, type Lookup } from "./ssrf";
3
+ import { extractXDom, type XDomDocument } from "./x-dom.mjs";
4
+ import { parseXUrl, type XUrl } from "./x-public";
5
+ import type { XBrowserConnection } from "./x-cdp";
6
+
7
+ export interface XBrowserDocument extends XDomDocument { readonly bytesRead: number }
8
+ const HOSTS = new Set(["x.com", "www.x.com", "twitter.com", "api.x.com", "api.twitter.com", "abs.twimg.com", "pbs.twimg.com", "video.twimg.com", "ton.twitter.com"]);
9
+ const READ_OPERATIONS = new Set([
10
+ "TweetDetail", "TweetResultByRestId", "TweetResultsByRestIds", "TweetResultById",
11
+ "ConversationTimeline", "UserByScreenName", "UserResultByScreenName", "Viewer",
12
+ // The shared X application shell needs these reads before rendering conversations.
13
+ "DataSaverMode", "CreatorStudioTabBarItemQuery", "useFetchProductSubscriptionsQuery",
14
+ "getAltTextPromptPreference", "ExploreSidebar",
15
+ ]);
16
+ const PAGE_POLICY = "frame-src 'none'; worker-src 'none'; object-src 'none'; media-src 'none'; img-src 'none'; font-src 'none'; form-action 'none'; base-uri 'none'";
17
+
18
+ export function browserRequestAllowed(value: string, method: string): boolean {
19
+ try {
20
+ const url = new URL(value);
21
+ if (url.protocol !== "https:" || url.username || url.password || url.port || !HOSTS.has(url.hostname)) return false;
22
+ if (method === "GET" || method === "HEAD") {
23
+ if (/^\/i\/api\/graphql\//.test(url.pathname)) return READ_OPERATIONS.has(url.pathname.split("/").at(-1)!);
24
+ return !/^\/(?:logout|i\/flow\/logout)(?:\/|$)/.test(url.pathname);
25
+ }
26
+ return method === "POST" && ["x.com", "api.x.com", "api.twitter.com"].includes(url.hostname) && /^\/i\/api\/graphql\//.test(url.pathname) && READ_OPERATIONS.has(url.pathname.split("/").at(-1)!);
27
+ } catch { return false; }
28
+ }
29
+
30
+ interface TargetInfo { targetId: string; type: string; url: string; browserContextId?: string }
31
+ interface Snapshot { url: string; document: XDomDocument | null }
32
+ function canceled(): SafeFetchError { return new SafeFetchError({ kind: "canceled", message: "X browser read canceled" }); }
33
+ function delay(signal: AbortSignal): Promise<void> {
34
+ return new Promise((resolve, reject) => {
35
+ const finish = (): void => { signal.removeEventListener("abort", abort); resolve(); };
36
+ const timer = setTimeout(finish, 150);
37
+ const abort = (): void => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(canceled()); };
38
+ signal.addEventListener("abort", abort, { once: true });
39
+ if (signal.aborted) abort();
40
+ });
41
+ }
42
+
43
+ /** A background tab in the user's existing profile, never an existing tab or shared context. */
44
+ export async function readXBrowserPage(connection: XBrowserConnection, target: XUrl, signal: AbortSignal, maxBytes: number, lookup?: Lookup): Promise<XBrowserDocument> {
45
+ let targetId: string | undefined;
46
+ let sessionId: string | undefined;
47
+ let closeTask: Promise<unknown> | undefined;
48
+ let failure: Error | undefined;
49
+ let bytesRead = 0;
50
+ let lastActivity = Date.now();
51
+ let previousHtml: string | undefined;
52
+ let documentChangedAt = Date.now();
53
+ const loading = new Set<string>();
54
+ const completed = new Set<string>();
55
+ const removeHandlers: (() => void)[] = [];
56
+ const pendingGuards = new Set<Promise<void>>();
57
+ const resolvedHosts = new Map<string, Promise<unknown>>();
58
+ const close = (): Promise<unknown> => {
59
+ if (!targetId) return Promise.resolve();
60
+ return closeTask ??= connection.send("Target.closeTarget", { targetId }).catch(() => undefined);
61
+ };
62
+ const fail = (error: Error): void => { failure ??= error; void close(); };
63
+ const check = (): void => { if (failure) throw failure; if (signal.aborted) throw canceled(); };
64
+ const abort = (): void => { void close(); };
65
+ signal.addEventListener("abort", abort, { once: true });
66
+ try {
67
+ check();
68
+ // Inspect only target metadata to find an unambiguous X profile. Never attach to or read other tabs.
69
+ const { targetInfos } = await connection.send<{ targetInfos: TargetInfo[] }>("Target.getTargets");
70
+ const contexts = new Set(targetInfos.filter((info) => info.type === "page" && /^https:\/\/(?:www\.)?(?:x|twitter)\.com\//.test(info.url)).map((info) => info.browserContextId));
71
+ if (contexts.size > 1) throw new SafeFetchError({ kind: "extraction", message: "Multiple X browser profiles are open; close the other X profiles before connecting" });
72
+ const browserContextId = contexts.values().next().value;
73
+ check();
74
+ // Keep creation awaited even after cancellation so a late-created tab is still closed in finally.
75
+ const created = await connection.send<{ targetId: string }>("Target.createTarget", { url: "about:blank", background: true, ...(browserContextId ? { browserContextId } : {}) }, undefined, {
76
+ onLateResult: ({ targetId }) => { void connection.send("Target.closeTarget", { targetId }).catch(() => undefined); },
77
+ });
78
+ targetId = created.targetId;
79
+ check();
80
+ ({ sessionId } = await connection.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true }));
81
+ const send = <T = Record<string, never>>(method: string, params: object = {}): Promise<T> => connection.send<T>(method, params, sessionId);
82
+ const guard = async (event: any): Promise<void> => {
83
+ const request = event.request;
84
+ const blocked = !browserRequestAllowed(request.url, request.method) || ["Image", "Media", "Font", "WebSocket"].includes(event.resourceType);
85
+ if (blocked || signal.aborted || failure) { await send("Fetch.failRequest", { requestId: event.requestId, errorReason: "BlockedByClient" }); return; }
86
+ if (event.responseStatusCode === undefined && event.networkId) loading.add(event.networkId);
87
+ const hostname = new URL(request.url).hostname;
88
+ let resolved = resolvedHosts.get(hostname);
89
+ // Unlike direct HTTP, CDP cannot pin Chromium to these validated DNS answers.
90
+ if (!resolved) { resolved = validateRemoteUrl(request.url, { signal, lookup }); resolvedHosts.set(hostname, resolved); }
91
+ await resolved;
92
+ if (event.responseStatusCode !== undefined) {
93
+ const headers = event.responseHeaders as { name: string; value: string }[] ?? [];
94
+ const header = (name: string): string | undefined => headers.find((h) => h.name.toLowerCase() === name)?.value;
95
+ if (event.responseStatusCode >= 300 && event.responseStatusCode < 400 && header("location")) {
96
+ const redirect = new URL(header("location")!, request.url);
97
+ if (!browserRequestAllowed(redirect.href, "GET")) {
98
+ const error = new SafeFetchError({ kind: "redirect", message: "X browser redirect left the allowed HTTPS hosts" });
99
+ failure ??= error;
100
+ await send("Fetch.failRequest", { requestId: event.requestId, errorReason: "BlockedByClient" });
101
+ throw error;
102
+ }
103
+ await validateRemoteUrl(redirect, { signal, lookup });
104
+ }
105
+ const declared = Number(header("content-length"));
106
+ if (Number.isFinite(declared) && declared > maxBytes - bytesRead) throw new SafeFetchError({ kind: "responseTooLarge", message: "X browser response exceeds its byte limit" });
107
+ if (event.resourceType === "Document") {
108
+ if (/attachment/i.test(header("content-disposition") ?? "")) throw new Error("X browser refused a download");
109
+ await send("Fetch.continueResponse", { requestId: event.requestId, responseCode: event.responseStatusCode, responseHeaders: [...headers, { name: "Content-Security-Policy", value: PAGE_POLICY }] });
110
+ return;
111
+ }
112
+ await send("Fetch.continueResponse", { requestId: event.requestId });
113
+ } else await send("Fetch.continueRequest", { requestId: event.requestId });
114
+ };
115
+ removeHandlers.push(connection.on("Fetch.requestPaused", (event, id) => {
116
+ if (id !== sessionId) return;
117
+ const task = guard(event).catch((error: Error) => {
118
+ // X may cancel a request between interception and our continuation.
119
+ // A browser-confirmed completion makes that interception obsolete.
120
+ if (!event.networkId || !completed.has(event.networkId)) fail(error);
121
+ }).finally(() => pendingGuards.delete(task));
122
+ pendingGuards.add(task);
123
+ }));
124
+ for (const event of ["Network.loadingFinished", "Network.loadingFailed"]) {
125
+ removeHandlers.push(connection.on(event, (event, id) => {
126
+ if (id !== sessionId) return;
127
+ loading.delete(event.requestId);
128
+ completed.add(event.requestId);
129
+ lastActivity = Date.now();
130
+ }));
131
+ }
132
+ removeHandlers.push(connection.on("Network.dataReceived", (event, id) => {
133
+ if (id !== sessionId) return;
134
+ lastActivity = Date.now();
135
+ bytesRead += event.dataLength;
136
+ if (bytesRead > maxBytes) fail(new SafeFetchError({ kind: "responseTooLarge", message: "X browser exceeded its observed response-byte limit" }));
137
+ }));
138
+ // X defers conversation rendering in hidden tabs. Emulate focus only in our
139
+ // owned tab, without activating a window or changing the user's selected tab.
140
+ await send("Emulation.setFocusEmulationEnabled", { enabled: true });
141
+ await send("Network.enable");
142
+ await send("Network.setBypassServiceWorker", { bypass: true });
143
+ await send("Network.setBlockedURLs", { urls: ["ws://*", "wss://*"] });
144
+ await send("Page.enable");
145
+ await send("Page.addScriptToEvaluateOnNewDocument", { source: "Object.defineProperties(window, {open: {value: () => null, configurable: false, writable: false}, WebSocket: {value: undefined, configurable: false, writable: false}});" });
146
+ await send("Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }, { urlPattern: "*", requestStage: "Response" }] });
147
+ check();
148
+ const navigation = await send<{ errorText?: string }>("Page.navigate", { url: target.url });
149
+ if (navigation.errorText) { check(); throw new Error("X browser navigation failed"); }
150
+ const expression = `(() => ({url: location.href, document: (${extractXDom.toString()})(document, ${JSON.stringify(target)}, ${Math.min(maxBytes, 1_000_000)})}))()`;
151
+ const evaluate = async <T>(source: string): Promise<T | undefined> => {
152
+ const reply = await send<{ result: { value?: T }; exceptionDetails?: unknown }>("Runtime.evaluate", { expression: source, returnByValue: true, timeout: 1_000 });
153
+ if (reply.exceptionDetails) throw new Error("X page could not be extracted");
154
+ return reply.result.value;
155
+ };
156
+ for (;;) {
157
+ check();
158
+ const snapshot = await evaluate<Snapshot>(expression);
159
+ check();
160
+ if (snapshot?.document) {
161
+ const actual = parseXUrl(snapshot.url);
162
+ if (!actual || actual.postId !== target.postId || actual.articleId !== target.articleId) throw new Error("X navigated away from the requested content");
163
+ if (snapshot.document.html !== previousHtml) {
164
+ previousHtml = snapshot.document.html;
165
+ documentChangedAt = Date.now();
166
+ }
167
+ if (snapshot.document.collapsedPostIds?.length) {
168
+ // Only expand text controls inside selected author posts. Never click links or account actions.
169
+ await evaluate(`(() => { const ids = ${JSON.stringify(snapshot.document.collapsedPostIds)}; for (const el of document.querySelectorAll('article[data-testid="tweet"]')) { const href = el.querySelector('time')?.closest('a')?.getAttribute('href'); if (href && ids.some(id => new RegExp('/status/' + id + '(?:[/?]|$)').test(href))) el.querySelector('[data-testid="tweet-text-show-more-link"]')?.click(); } })()`);
170
+ } else if (!loading.size && Date.now() - Math.max(lastActivity, documentChangedAt) >= 750) {
171
+ // The server-rendered focal post can appear before the conversation hydrates.
172
+ // Wait for bounded network/DOM quiescence rather than returning that preview.
173
+ if (!snapshot.document.html || new TextEncoder().encode(snapshot.document.html).byteLength > maxBytes) throw new SafeFetchError({ kind: "responseTooLarge", message: "X browser extracted content exceeds its byte limit" });
174
+ return { ...snapshot.document, bytesRead };
175
+ }
176
+ }
177
+ if (snapshot && /\/i\/flow\/login|\/login(?:[/?]|$)/.test(snapshot.url)) throw new Error("X is not signed in in this browser profile");
178
+ await delay(signal);
179
+ }
180
+ } catch (error) {
181
+ if (failure) throw failure;
182
+ if (signal.aborted) throw canceled();
183
+ throw error;
184
+ } finally {
185
+ signal.removeEventListener("abort", abort);
186
+ // Leave interception installed until the owned tab has stopped all requests.
187
+ await close();
188
+ for (const remove of removeHandlers) remove();
189
+ await Promise.allSettled(pendingGuards);
190
+ }
191
+ }
@@ -0,0 +1,181 @@
1
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { SafeFetchError } from "./fetch-errors";
6
+ import type { Lookup } from "./ssrf";
7
+ import type { XReadMode } from "./contracts";
8
+ import type { XUrl } from "./x-public";
9
+ import { browserEndpoint, XBrowserConnection } from "./x-cdp";
10
+ import { readXBrowserPage, type XBrowserDocument } from "./x-browser-page";
11
+ export type { XBrowserDocument } from "./x-browser-page";
12
+
13
+ export type XBrowserName = "brave" | "chrome" | "chromium";
14
+ interface Preferences { mode: Exclude<XReadMode, "browser">; browser?: XBrowserName }
15
+ export interface XBrowserSelection {
16
+ readonly mode: XReadMode;
17
+ read(target: XUrl, signal: AbortSignal, maxBytes: number, lookup?: Lookup): Promise<XBrowserDocument | undefined>;
18
+ }
19
+ export interface XBrowserReader {
20
+ select(signal: AbortSignal): Promise<XBrowserSelection>;
21
+ }
22
+ export function browserDirectories(): Record<XBrowserName, string> {
23
+ const home = homedir();
24
+ if (process.platform === "darwin") {
25
+ const root = join(home, "Library", "Application Support");
26
+ return { brave: join(root, "BraveSoftware", "Brave-Browser"), chrome: join(root, "Google", "Chrome"), chromium: join(root, "Chromium") };
27
+ }
28
+ if (process.platform === "win32") {
29
+ const root = process.env.LOCALAPPDATA ?? join(home, "AppData", "Local");
30
+ return { brave: join(root, "BraveSoftware", "Brave-Browser", "User Data"), chrome: join(root, "Google", "Chrome", "User Data"), chromium: join(root, "Chromium", "User Data") };
31
+ }
32
+ const root = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
33
+ return { brave: join(root, "BraveSoftware", "Brave-Browser"), chrome: join(root, "google-chrome"), chromium: join(root, "chromium") };
34
+ }
35
+ function canceled(): SafeFetchError { return new SafeFetchError({ kind: "canceled", message: "X browser operation canceled" }); }
36
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
37
+ if (signal.aborted) return Promise.reject(canceled());
38
+ return new Promise((resolve, reject) => {
39
+ const abort = (): void => reject(canceled());
40
+ signal.addEventListener("abort", abort, { once: true });
41
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
42
+ });
43
+ }
44
+
45
+ /** Explicit authorization for an existing browser. Owns only its connection and temporary X tabs. */
46
+ export class XBrowserAccess implements XBrowserReader {
47
+ private queue: Promise<unknown> = Promise.resolve();
48
+ private connection?: XBrowserConnection;
49
+ private connectedBrowser?: XBrowserName;
50
+ private active?: AbortController;
51
+ private stopped = false;
52
+ private revision = 0;
53
+ constructor(private readonly directory: string, private readonly browsers = browserDirectories()) {}
54
+
55
+ private async preferences(): Promise<Preferences> {
56
+ let value: unknown;
57
+ try { value = JSON.parse(await readFile(join(this.directory, "preferences.json"), "utf8")); }
58
+ catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { mode: "auto" };
60
+ throw new SafeFetchError({ kind: "extraction", message: "Could not read X access preferences", cause: error });
61
+ }
62
+ const p = value as Partial<Preferences> | null;
63
+ if (!p || !["auto", "public", "direct"].includes(p.mode ?? "") || p.browser !== undefined && !["brave", "chrome", "chromium"].includes(p.browser)) throw new SafeFetchError({ kind: "extraction", message: "Invalid X access preferences" });
64
+ return { mode: p.mode!, ...(p.browser ? { browser: p.browser } : {}) };
65
+ }
66
+ private async save(preferences: Preferences, signal?: AbortSignal): Promise<void> {
67
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
68
+ await chmod(this.directory, 0o700);
69
+ const temporary = join(this.directory, `preferences-${randomUUID()}.tmp`);
70
+ try {
71
+ await writeFile(temporary, JSON.stringify(preferences), { mode: 0o600, flag: "wx" });
72
+ if (signal?.aborted) throw canceled();
73
+ await rename(temporary, join(this.directory, "preferences.json"));
74
+ } finally { await rm(temporary, { force: true }); }
75
+ }
76
+ select(signal: AbortSignal): Promise<XBrowserSelection> {
77
+ const revision = this.revision;
78
+ return this.serialized(async () => {
79
+ const preferences = await this.preferences();
80
+ return {
81
+ mode: preferences.mode,
82
+ // Bind the read to this policy, so revocation between routing and reading
83
+ // cannot turn an authorized request into a public fallback.
84
+ read: (target, signal, maxBytes, lookup) => this.read(preferences, revision, target, signal, maxBytes, lookup),
85
+ };
86
+ }, signal, revision);
87
+ }
88
+ async status(): Promise<string> {
89
+ const p = await this.preferences();
90
+ return `X access: ${p.mode}; ${p.browser ? `${p.browser} authorized${this.connection?.connected ? " and connected" : " (browser approval may be needed)"}` : "browser not authorized"}. ${p.mode === "direct" ? "Only direct X HTTP is allowed." : "Public extraction uses free FxTwitter."} No paid retrieval or separate browser profile.`;
91
+ }
92
+ private serialized<T>(task: () => Promise<T>, signal: AbortSignal, revision = this.revision): Promise<T> {
93
+ const check = (): void => { if (signal.aborted || this.stopped || revision !== this.revision) throw canceled(); };
94
+ const pending = this.queue.catch(() => undefined).then(async () => {
95
+ check();
96
+ const result = await task();
97
+ check();
98
+ return result;
99
+ });
100
+ this.queue = pending;
101
+ return abortable(pending, signal);
102
+ }
103
+ private async attach(browser: XBrowserName, signal: AbortSignal): Promise<XBrowserConnection> {
104
+ if (this.connection?.connected && this.connectedBrowser === browser) return this.connection;
105
+ this.connection?.close();
106
+ const endpoint = await browserEndpoint(this.browsers[browser]);
107
+ this.connection = await XBrowserConnection.open(endpoint, signal);
108
+ this.connectedBrowser = browser;
109
+ return this.connection;
110
+ }
111
+ async connect(confirm: () => Promise<boolean>, browser: XBrowserName = "brave"): Promise<boolean> {
112
+ const controller = new AbortController();
113
+ const timeout = setTimeout(() => controller.abort(), 300_000);
114
+ try {
115
+ return await this.serialized(async () => {
116
+ this.active = controller;
117
+ let authorized = false;
118
+ try {
119
+ if (!(await abortable(confirm(), controller.signal))) return false;
120
+ await this.attach(browser, controller.signal);
121
+ await this.save({ mode: "auto", browser }, controller.signal);
122
+ authorized = true;
123
+ return true;
124
+ } finally {
125
+ if (!authorized) this.detach();
126
+ this.active = undefined;
127
+ }
128
+ }, controller.signal);
129
+ } finally { clearTimeout(timeout); }
130
+ }
131
+ private async read(preferences: Preferences, revision: number, target: XUrl, signal: AbortSignal, maxBytes: number, lookup?: Lookup): Promise<XBrowserDocument | undefined> {
132
+ return this.serialized(async () => {
133
+ const controller = new AbortController();
134
+ this.active = controller;
135
+ const abort = (): void => controller.abort();
136
+ signal.addEventListener("abort", abort, { once: true });
137
+ if (signal.aborted) abort();
138
+ try {
139
+ if (controller.signal.aborted) throw canceled();
140
+ if (!preferences.browser) return undefined;
141
+ if (preferences.mode === "direct") throw canceled();
142
+ const connection = await this.attach(preferences.browser, controller.signal);
143
+ const document = await readXBrowserPage(connection, target, controller.signal, maxBytes, lookup);
144
+ if (controller.signal.aborted) throw canceled();
145
+ return document;
146
+ } catch (error) {
147
+ if (controller.signal.aborted) throw canceled();
148
+ if (error instanceof SafeFetchError) throw error;
149
+ throw new SafeFetchError({ kind: "extraction", message: "Authenticated X read failed. Check that X is signed in in your connected browser. Use public mode only if you intend to disclose this post ID to FxTwitter", cause: error });
150
+ } finally { signal.removeEventListener("abort", abort); this.active = undefined; }
151
+ }, signal, revision);
152
+ }
153
+ private change(task: () => Promise<void>): Promise<void> {
154
+ this.revision++;
155
+ this.active?.abort();
156
+ const pending = this.queue.catch(() => undefined).then(task);
157
+ this.queue = pending;
158
+ return pending;
159
+ }
160
+ private detach(): void {
161
+ this.connection?.close();
162
+ this.connection = undefined;
163
+ this.connectedBrowser = undefined;
164
+ }
165
+ async setMode(mode: Preferences["mode"]): Promise<void> {
166
+ await this.change(async () => {
167
+ if (mode !== "auto") this.detach();
168
+ await this.save({ ...await this.preferences(), mode });
169
+ });
170
+ }
171
+ async disconnect(): Promise<void> {
172
+ await this.change(async () => {
173
+ this.detach();
174
+ await this.save({ mode: (await this.preferences()).mode });
175
+ });
176
+ }
177
+ async shutdown(): Promise<void> {
178
+ this.stopped = true;
179
+ await this.change(async () => this.detach());
180
+ }
181
+ }
package/src/x-cdp.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { SafeFetchError } from "./fetch-errors";
4
+
5
+ /** Native discovery only. Never scans ports, launches a browser, or reads profile secrets. */
6
+ export async function browserEndpoint(directory: string): Promise<string> {
7
+ let text: string;
8
+ try { text = await readFile(join(directory, "DevToolsActivePort"), "utf8"); }
9
+ catch { throw new SafeFetchError({ kind: "extraction", message: "No browser connection. Enable remote debugging in your running browser's inspect page, then run /search-x connect" }); }
10
+ const [port, path, ...rest] = text.trim().split(/\r?\n/);
11
+ if (text.length > 1024 || rest.length || !/^\d{1,5}$/.test(port ?? "") || Number(port) < 1 || Number(port) > 65535 || !/^\/devtools\/browser\/[A-Za-z0-9-]+$/.test(path ?? "")) {
12
+ throw new SafeFetchError({ kind: "extraction", message: "Invalid browser connection metadata" });
13
+ }
14
+ return `ws://127.0.0.1:${Number(port)}${path}`;
15
+ }
16
+
17
+ type EventHandler = (params: any, sessionId?: string) => void;
18
+ interface CommandOptions<T> { timeoutMs?: number; onLateResult?: (value: T) => void }
19
+ interface Pending { method: string; resolve(value: any): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout>; expired?: boolean; onLateResult?: (value: any) => void }
20
+
21
+ /** Target-scoped CDP: unlike general browser drivers, never auto-attaches to existing tabs. */
22
+ export class XBrowserConnection {
23
+ private nextId = 0;
24
+ private pending = new Map<number, Pending>();
25
+ private handlers = new Map<string, Set<EventHandler>>();
26
+ private constructor(private readonly socket: WebSocket) {
27
+ socket.addEventListener("message", (event) => {
28
+ if (typeof event.data !== "string" || event.data.length > 8_000_000) { this.close(); return; }
29
+ let message: any;
30
+ try { message = JSON.parse(event.data); } catch { this.close(); return; }
31
+ if (!message || typeof message !== "object") { this.close(); return; }
32
+ const pending = this.pending.get(message.id);
33
+ if (pending) {
34
+ this.pending.delete(message.id);
35
+ clearTimeout(pending.timer);
36
+ // Protocol errors can contain session details. Never echo raw messages or endpoints.
37
+ if (message.error) pending.reject(new Error(`Browser command ${pending.method} failed`));
38
+ else if (pending.expired) pending.onLateResult?.(message.result);
39
+ else pending.resolve(message.result);
40
+ } else if (typeof message.method === "string") {
41
+ for (const handler of this.handlers.get(message.method) ?? []) handler(message.params, message.sessionId);
42
+ }
43
+ });
44
+ socket.addEventListener("close", () => this.rejectPending());
45
+ socket.addEventListener("error", () => this.rejectPending());
46
+ }
47
+ static async open(endpoint: string, signal: AbortSignal): Promise<XBrowserConnection> {
48
+ if (signal.aborted) throw new SafeFetchError({ kind: "canceled", message: "Browser connection canceled" });
49
+ const socket = new WebSocket(endpoint);
50
+ const connection = new XBrowserConnection(socket);
51
+ await new Promise<void>((resolve, reject) => {
52
+ const cleanup = (): void => {
53
+ socket.removeEventListener("open", opened);
54
+ socket.removeEventListener("error", failed);
55
+ socket.removeEventListener("close", failed);
56
+ signal.removeEventListener("abort", abort);
57
+ };
58
+ const opened = (): void => { cleanup(); resolve(); };
59
+ const failed = (): void => { cleanup(); connection.close(); reject(new SafeFetchError({ kind: "extraction", message: "Browser connection refused or denied. Enable remote debugging and approve the browser's connection prompt" })); };
60
+ const abort = (): void => { cleanup(); connection.close(); reject(new SafeFetchError({ kind: "canceled", message: "Browser connection canceled" })); };
61
+ socket.addEventListener("open", opened, { once: true });
62
+ socket.addEventListener("error", failed, { once: true });
63
+ socket.addEventListener("close", failed, { once: true });
64
+ signal.addEventListener("abort", abort, { once: true });
65
+ if (signal.aborted) abort();
66
+ });
67
+ return connection;
68
+ }
69
+ get connected(): boolean { return this.socket.readyState === WebSocket.OPEN; }
70
+ send<T = Record<string, never>>(method: string, params: object = {}, sessionId?: string, options: CommandOptions<T> = {}): Promise<T> {
71
+ if (!this.connected) return Promise.reject(new Error("Browser disconnected"));
72
+ const id = ++this.nextId;
73
+ return new Promise<T>((resolve, reject) => {
74
+ const timer = setTimeout(() => {
75
+ const pending = this.pending.get(id);
76
+ // Resource creation may complete after a timeout. Retain only its cleanup callback.
77
+ if (pending?.onLateResult) pending.expired = true;
78
+ else this.pending.delete(id);
79
+ reject(new Error(`Browser command ${method} timed out`));
80
+ }, options.timeoutMs ?? 10_000);
81
+ this.pending.set(id, { method, resolve, reject, timer, onLateResult: options.onLateResult });
82
+ try { this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
83
+ catch { this.pending.delete(id); clearTimeout(timer); reject(new Error("Browser disconnected")); }
84
+ });
85
+ }
86
+ on(method: string, handler: EventHandler): () => void {
87
+ let handlers = this.handlers.get(method);
88
+ if (!handlers) { handlers = new Set(); this.handlers.set(method, handlers); }
89
+ handlers.add(handler);
90
+ return () => { handlers.delete(handler); if (!handlers.size) this.handlers.delete(method); };
91
+ }
92
+ private rejectPending(): void {
93
+ for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(new Error("Browser disconnected")); }
94
+ this.pending.clear();
95
+ }
96
+ close(): void {
97
+ // Closing the transport is NOT Browser.close or Target.closeTarget on a user tab.
98
+ this.socket.close();
99
+ this.rejectPending();
100
+ }
101
+ }
@@ -0,0 +1,7 @@
1
+ export interface XDomDocument {
2
+ readonly html: string;
3
+ readonly title: string;
4
+ readonly postCount: number;
5
+ readonly collapsedPostIds?: readonly string[];
6
+ }
7
+ export function extractXDom(document: unknown, target: { readonly url: string; readonly postId?: string }, maxCharacters?: number): XDomDocument | null;
package/src/x-dom.mjs ADDED
@@ -0,0 +1,77 @@
1
+ /** Fixed DOM reader shared by local HTML extraction and the opt-in browser. */
2
+ export function extractXDom(document, target, maxCharacters = 1_000_000) {
3
+ const clean = (element) => {
4
+ const clone = element.cloneNode(true);
5
+ clone.querySelectorAll("script,style,iframe,object,embed,form,button,video,audio").forEach((el) => el.remove());
6
+ // X's rich-text headings contain layout divs; preserve inline markup without
7
+ // turning a heading into an empty Markdown heading followed by a paragraph.
8
+ for (const block of clone.querySelectorAll('h1 div,h1 p,h2 div,h2 p,h3 div,h3 p,h4 div,h4 p,h5 div,h5 p,h6 div,h6 p')) block.replaceWith(...block.childNodes);
9
+ for (const el of [clone, ...clone.querySelectorAll("*")]) {
10
+ for (const attr of [...el.attributes]) {
11
+ if (!['href', 'src', 'alt', 'title', 'class'].includes(attr.name)) el.removeAttribute(attr.name);
12
+ }
13
+ for (const attr of ['href', 'src']) {
14
+ const value = el.getAttribute(attr);
15
+ if (!value) continue;
16
+ try {
17
+ const url = new URL(value, target.url);
18
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) el.removeAttribute(attr);
19
+ else el.setAttribute(attr, url.href);
20
+ } catch { el.removeAttribute(attr); }
21
+ }
22
+ }
23
+ return clone.outerHTML;
24
+ };
25
+ const bodies = document.querySelectorAll('.x-article-body, [data-testid="twitterArticleRichTextView"]');
26
+ if (/\/article\//.test(new URL(target.url).pathname) && bodies.length === 1 && bodies[0].textContent.trim()) {
27
+ const html = clean(bodies[0]);
28
+ if (html.length > maxCharacters) throw new Error('X article exceeds the extraction limit');
29
+ return { html, title: ((document.querySelector('[data-testid="twitter-article-title"]') || document.querySelector('h1'))?.textContent?.trim() || document.title || 'X Article').slice(0, 512), postCount: 1 };
30
+ }
31
+ if (!target.postId) return null;
32
+ const posts = [];
33
+ const collapsedPostIds = [];
34
+ let author;
35
+ let found = false;
36
+ for (const element of document.querySelectorAll('article[data-testid="tweet"]')) {
37
+ const stamp = element.querySelector('time');
38
+ const href = stamp?.closest('a')?.getAttribute('href');
39
+ if (!href) continue;
40
+ const permalink = new URL(href, target.url);
41
+ if (!['x.com', 'www.x.com', 'twitter.com', 'www.twitter.com'].includes(permalink.hostname) || permalink.username || permalink.password || permalink.port) continue;
42
+ const match = permalink.pathname.match(/^\/([A-Za-z0-9_]{1,15})\/status\/(\d{2,20})(?:\/|$)/);
43
+ if (!match) continue;
44
+ if (!found) {
45
+ if (match[2] !== target.postId) continue;
46
+ found = true;
47
+ author = match[1].toLowerCase();
48
+ } else if (match[1].toLowerCase() !== author) break;
49
+ const text = element.querySelector('[data-testid="tweetText"]');
50
+ const photos = element.querySelectorAll('[data-testid="tweetPhoto"] img');
51
+ const video = element.querySelector('video, [data-testid="videoPlayer"]');
52
+ if (!text?.textContent?.trim() && !photos.length && !video) {
53
+ if (!posts.length) return null;
54
+ break;
55
+ }
56
+ if (element.querySelector('[data-testid="tweet-text-show-more-link"]')) collapsedPostIds.push(match[2]);
57
+ const url = `https://x.com/${match[1]}/status/${match[2]}`;
58
+ const part = document.createElement('section');
59
+ const source = document.createElement('a');
60
+ source.setAttribute('href', url);
61
+ source.textContent = `@${match[1]} · ${stamp.getAttribute('datetime') || match[2]}`;
62
+ part.appendChild(source);
63
+ if (text) part.appendChild(text.cloneNode(true));
64
+ for (const photo of photos) part.appendChild(photo.cloneNode(true));
65
+ if (video) {
66
+ const notice = document.createElement('p');
67
+ notice.textContent = 'Video attached; media was not downloaded or transcribed.';
68
+ part.appendChild(notice);
69
+ }
70
+ posts.push(clean(part));
71
+ if (posts.length >= 100) break;
72
+ }
73
+ if (!posts.length) return null;
74
+ const html = posts.join('\n<hr>\n');
75
+ if (html.length > maxCharacters) throw new Error('X thread exceeds the extraction limit');
76
+ return { html, title: `@${author} on X`, postCount: posts.length, ...(collapsedPostIds.length ? { collapsedPostIds } : {}) };
77
+ }