@nijaru/pi-search 0.0.7 → 0.0.8

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,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
+ }