pi-lean-portal 0.1.0

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.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Level 1: HTTP Fetch Backend (Decoupled)
3
+ *
4
+ * Uses plain fetch() with configurable User-Agent, HTML parsing via
5
+ * node-html-parser, and Markdown conversion via turndown.
6
+ * No JavaScript execution — fastest path for static content.
7
+ *
8
+ * When called directly via the `webFetch()` entry point, this backend:
9
+ * - Validates the URL (blocks localhost, private IPs, dangerous schemes)
10
+ * - Fetches and converts HTML → Markdown
11
+ * - Runs JS-shell detection inline
12
+ * - Runs bot-detection heuristics inline
13
+ * - Caps content for inline display + spills to temp files when large
14
+ *
15
+ */
16
+
17
+ import { writeFileSync, rmSync } from "node:fs";
18
+ import { createHash } from "node:crypto";
19
+ import {
20
+ BROWSER_TEMP_DIR,
21
+ safeTaskId,
22
+ ensureBrowserTempDir,
23
+ } from "./shared/paths.js";
24
+ import TurndownService from "turndown";
25
+ import { parse as parseHtml } from "node-html-parser";
26
+ import { validateUrl } from "./shared/url-safety.js";
27
+ import { checkPage } from "./shared/bot-detection.js";
28
+
29
+ const DEFAULT_USER_AGENT =
30
+ "Mozilla/5.0 (compatible; PiBrowser/1.0; +https://pi.ai)";
31
+
32
+ const turndown = new TurndownService({
33
+ headingStyle: "atx",
34
+ codeBlockStyle: "fenced",
35
+ emDelimiter: "*",
36
+ });
37
+
38
+ /**
39
+ * Detect whether a page is a JS-only shell (empty <div id="root">,
40
+ * mostly <noscript> content, etc.)
41
+ */
42
+ function detectNeedsJavaScript(root: ReturnType<typeof parseHtml>): boolean {
43
+ // Check for common JS-app shell patterns
44
+ const rootDiv = root.querySelector("#root, #__next, #app, #__nuxt");
45
+ if (rootDiv) {
46
+ const text = rootDiv.textContent?.trim() || "";
47
+ // If the root div has little or no text content, JS likely hasn't rendered
48
+ if (text.length < 100) return true;
49
+ }
50
+
51
+ // Check if most content is in <noscript> tags
52
+ const noscripts = root.querySelectorAll("noscript");
53
+ if (noscripts.length > 0) {
54
+ const bodyText = root.textContent?.trim() || "";
55
+ const noscriptText = noscripts
56
+ .map((n) => n.textContent?.trim() || "")
57
+ .join("");
58
+ if (
59
+ noscriptText.length > 0 &&
60
+ noscriptText.length > bodyText.length * 0.5
61
+ ) {
62
+ return true;
63
+ }
64
+ }
65
+
66
+ // Check for SPA meta tags
67
+ const metaApp = root.querySelector('meta[name="application-name"]');
68
+ if (metaApp?.getAttribute("content")?.toLowerCase().includes("react")) {
69
+ return true;
70
+ }
71
+
72
+ return false;
73
+ }
74
+
75
+ function extractTitle(root: ReturnType<typeof parseHtml>): string {
76
+ const titleTag = root.querySelector("title");
77
+ return titleTag?.textContent?.trim() || "";
78
+ }
79
+
80
+ /**
81
+ * Perform the raw HTTP fetch, HTML parsing, and Markdown conversion.
82
+ * Used internally by `webFetch()` to perform the raw HTTP request.
83
+ * Returns the parsed root alongside raw HTML so callers can avoid re-parsing.
84
+ */
85
+ async function performFetch(
86
+ url: string,
87
+ timeoutMs: number = 30_000,
88
+ signal?: AbortSignal,
89
+ ): Promise<{
90
+ html: string;
91
+ title: string;
92
+ needsJavaScript: boolean;
93
+ root: ReturnType<typeof parseHtml>;
94
+ }> {
95
+ const controller = new AbortController();
96
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
97
+
98
+ // Wire up external signal
99
+ if (signal) {
100
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
101
+ }
102
+
103
+ try {
104
+ const response = await fetch(url, {
105
+ signal: controller.signal,
106
+ headers: {
107
+ "User-Agent": DEFAULT_USER_AGENT,
108
+ Accept:
109
+ "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
110
+ "Accept-Language": "en-US,en;q=0.5",
111
+ },
112
+ redirect: "follow",
113
+ });
114
+
115
+ clearTimeout(timeoutId);
116
+
117
+ if (!response.ok) {
118
+ throw new Error(`HTTP ${response.status} ${response.statusText ?? ""}`);
119
+ }
120
+
121
+ const html = await response.text();
122
+ if (!html) {
123
+ throw new Error("Empty body");
124
+ }
125
+
126
+ const root = parseHtml(html);
127
+ const title = extractTitle(root);
128
+ const needsJavaScript = detectNeedsJavaScript(root);
129
+
130
+ return { html, title, needsJavaScript, root };
131
+ } finally {
132
+ clearTimeout(timeoutId);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Parse HTML → clean up DOM → convert to Markdown.
138
+ * Accepts a pre-parsed HTMLElement root to avoid re-parsing.
139
+ */
140
+ function htmlToMarkdown(root: ReturnType<typeof parseHtml>): string {
141
+ // Remove script, style, noscript tags for cleaner markdown.
142
+ root.querySelectorAll("script, style, noscript").forEach((el) => el.remove());
143
+ // Convert SVGs to descriptive placeholders instead of stripping
144
+ root.querySelectorAll("svg").forEach((el) => {
145
+ const ariaLabel =
146
+ el.getAttribute("aria-label") || el.getAttribute("title") || "";
147
+ const role = el.getAttribute("role") || "";
148
+ const img = el.querySelector("image") || el.querySelector("img");
149
+ const alt =
150
+ img?.getAttribute("aria-label") || img?.getAttribute("alt") || "";
151
+ const label = ariaLabel || alt || role || "";
152
+ if (label) {
153
+ el.replaceWith(`[SVG: ${label.trim()}]`);
154
+ } else {
155
+ const textEls = el.querySelectorAll("text");
156
+ const texts = textEls.map((t) => t.textContent?.trim()).filter(Boolean);
157
+ if (texts.length > 0) {
158
+ el.replaceWith(`[SVG with text: ${texts.join("; ").slice(0, 120)}]`);
159
+ } else {
160
+ el.replaceWith(`[SVG graphic]`);
161
+ }
162
+ }
163
+ });
164
+
165
+ // Compress data URI images (defense-in-depth)
166
+ root.querySelectorAll("img").forEach((el) => {
167
+ const src = el.getAttribute("src") || "";
168
+ if (src.startsWith("data:")) {
169
+ const alt = el.getAttribute("alt") || "image";
170
+ el.replaceWith(`[Image: data URI - ${alt}]`);
171
+ }
172
+ });
173
+
174
+ // Compress large code blocks (>500 chars of text content)
175
+ root.querySelectorAll("pre").forEach((el) => {
176
+ const rawText = el.textContent || "";
177
+ if (rawText.length > 500) {
178
+ let lang = "";
179
+ const match = (el.innerHTML || "").match(
180
+ /<code[^>]*class="[^"]*\blanguage-([a-zA-Z0-9_-]+)"/,
181
+ );
182
+ if (match) {
183
+ lang = match[1] ?? "";
184
+ }
185
+ const lineCount = rawText.split("\n").length;
186
+ el.replaceWith(
187
+ `[${lang || "code"} code (${lineCount} lines, ~${rawText.length} chars)]`,
188
+ );
189
+ }
190
+ });
191
+
192
+ return turndown.turndown(root.innerHTML || root.textContent || "").trim();
193
+ }
194
+
195
+ // ─── Decoupled entry point: webFetch() ────────────────────────────────
196
+
197
+ export interface WebFetchOptions {
198
+ url: string;
199
+ timeout?: number; // seconds, default 30, max 120
200
+ signal?: AbortSignal;
201
+ /** Conversation-scoped taskId; defaults to "web-fetch-default" when not provided. */
202
+ taskId?: string;
203
+ }
204
+
205
+ export interface WebFetchResult {
206
+ success: boolean;
207
+ url: string;
208
+ title: string;
209
+ content: string; // Truncated inline Markdown
210
+ backendUsed: "fetch"; // Always "fetch"
211
+ needsJavaScript?: boolean; // True if page appears to need JS
212
+ botDetected?: boolean; // True if bot-detection signals found in content
213
+ statusCode?: number;
214
+ error?: string;
215
+ /** Path to temp file with full content (only when content > spill threshold) */
216
+ filePath?: string;
217
+ /** Total character count before truncation */
218
+ totalChars?: number;
219
+ }
220
+
221
+ // ─── Fetch truncation constants ────────────────────────────────────────
222
+
223
+ /** Maximum inline content length for fetch result Markdown. */
224
+ const COMPACT_FETCH_LIMIT = 4000;
225
+
226
+ /** Only spill fetch content to a temp file when it exceeds this threshold. */
227
+ const FETCH_SPILL_THRESHOLD = 5000;
228
+
229
+ /** Tracks active fetch temp files per task so stale ones can be cleaned up. */
230
+ const activeFetchFiles = new Map<string, string[]>();
231
+
232
+ // ─── Temp file management ──────────────────────────────────────────────
233
+
234
+ function formatBytes(bytes: number): string {
235
+ if (bytes < 1024) return `${bytes}B`;
236
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
237
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
238
+ }
239
+
240
+ function writeFetchTempFile(content: string, taskId: string): string {
241
+ ensureBrowserTempDir();
242
+
243
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 8);
244
+ const safe = safeTaskId(taskId);
245
+ const filePath = `${BROWSER_TEMP_DIR}/fetch-${safe}-${hash}.md`;
246
+
247
+ writeFileSync(filePath, content, "utf-8");
248
+ return filePath;
249
+ }
250
+
251
+ function trackFetchFile(taskId: string, filePath: string): void {
252
+ const existing = activeFetchFiles.get(taskId) ?? [];
253
+ for (const oldPath of existing) {
254
+ try {
255
+ rmSync(oldPath, { force: true });
256
+ } catch {
257
+ /* best-effort */
258
+ }
259
+ }
260
+ activeFetchFiles.set(taskId, [filePath]);
261
+ }
262
+
263
+ interface CappedFetchContent {
264
+ inline: string;
265
+ filePath: string | undefined;
266
+ totalChars: number;
267
+ }
268
+
269
+ function capFetchContent(content: string, taskId: string): CappedFetchContent {
270
+ const totalChars = content.length;
271
+
272
+ if (totalChars <= FETCH_SPILL_THRESHOLD) {
273
+ return { inline: content, filePath: undefined, totalChars };
274
+ }
275
+
276
+ const filePath = writeFetchTempFile(content, taskId);
277
+ trackFetchFile(taskId, filePath);
278
+
279
+ let cut = content.lastIndexOf("\n", COMPACT_FETCH_LIMIT);
280
+ if (cut < COMPACT_FETCH_LIMIT / 2) cut = COMPACT_FETCH_LIMIT;
281
+
282
+ const inline =
283
+ content.slice(0, cut) +
284
+ `\n\n… ${totalChars - cut} more chars. Full content in ${filePath}`;
285
+
286
+ return { inline, filePath, totalChars };
287
+ }
288
+
289
+ /**
290
+ * Remove all fetch temp files.
291
+ * If taskId is provided, only removes files for that task.
292
+ */
293
+ export function cleanupFetchTempFiles(taskId?: string): void {
294
+ if (taskId) {
295
+ const paths = activeFetchFiles.get(taskId) ?? [];
296
+ for (const p of paths) {
297
+ try {
298
+ rmSync(p, { force: true });
299
+ } catch {
300
+ /* best-effort */
301
+ }
302
+ }
303
+ activeFetchFiles.delete(taskId);
304
+ } else {
305
+ for (const [, paths] of activeFetchFiles) {
306
+ for (const p of paths) {
307
+ try {
308
+ rmSync(p, { force: true });
309
+ } catch {
310
+ /* best-effort */
311
+ }
312
+ }
313
+ }
314
+ activeFetchFiles.clear();
315
+ try {
316
+ rmSync(BROWSER_TEMP_DIR, { recursive: true, force: true });
317
+ } catch {
318
+ /* best-effort */
319
+ }
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Decoupled web fetch entry point.
325
+ *
326
+ * Pipeline: URL safety → fetch → JS detection → bot detection → content capping
327
+ *
328
+ * This function is the new recommended way to perform a stateless HTTP fetch.
329
+ * It replaces the router-level fetch dispatch that used to live in `router.ts navigate()`.
330
+ */
331
+ export async function webFetch(
332
+ options: WebFetchOptions,
333
+ ): Promise<WebFetchResult> {
334
+ const timeout = (options.timeout ?? 30) * 1000;
335
+
336
+ // Step 1: URL safety validation
337
+ let url: string;
338
+ try {
339
+ url = new URL(options.url).href;
340
+ } catch {
341
+ return {
342
+ success: false,
343
+ url: options.url,
344
+ title: "",
345
+ content: `Invalid URL: ${options.url}`,
346
+ backendUsed: "fetch",
347
+ error: "Invalid URL",
348
+ };
349
+ }
350
+
351
+ const safety = validateUrl(url);
352
+ if (!safety.safe) {
353
+ return {
354
+ success: false,
355
+ url,
356
+ title: "",
357
+ content: safety.reason || "URL blocked",
358
+ backendUsed: "fetch",
359
+ error: `URL blocked: ${safety.reason}`,
360
+ };
361
+ }
362
+
363
+ // Step 2: Perform fetch
364
+ let result: {
365
+ html: string;
366
+ title: string;
367
+ needsJavaScript: boolean;
368
+ root: ReturnType<typeof parseHtml>;
369
+ };
370
+ let statusCode: number | undefined;
371
+
372
+ try {
373
+ const fetchResult = await performFetch(url, timeout, options.signal);
374
+ result = fetchResult;
375
+ statusCode = 200;
376
+ } catch (err: unknown) {
377
+ if (err instanceof DOMException && err.name === "AbortError") {
378
+ return {
379
+ success: false,
380
+ url,
381
+ title: "",
382
+ content: "Request timed out or was cancelled",
383
+ backendUsed: "fetch",
384
+ error: "timeout",
385
+ };
386
+ }
387
+
388
+ const msg = err instanceof Error ? err.message : String(err);
389
+ const isHttpError = typeof msg === "string" && /^HTTP \d+/.test(msg);
390
+
391
+ if (isHttpError) {
392
+ const match = msg.match(/HTTP (\d+)/);
393
+ statusCode = match ? parseInt(match[1]!, 10) : undefined;
394
+ return {
395
+ success: false,
396
+ url,
397
+ title: "",
398
+ content: msg,
399
+ backendUsed: "fetch",
400
+ error: msg,
401
+ ...(statusCode !== undefined ? { statusCode } : {}),
402
+ };
403
+ }
404
+
405
+ return {
406
+ success: false,
407
+ url,
408
+ title: "",
409
+ content: `Fetch error: ${msg}`,
410
+ backendUsed: "fetch",
411
+ error: msg,
412
+ };
413
+ }
414
+
415
+ // Step 3: Bot detection via shared utility (uses un-mutated parsed root)
416
+ let botDetected: boolean | undefined;
417
+ try {
418
+ const bodyText = result.root.textContent?.trim() || "";
419
+ const detection = checkPage(result.title, bodyText);
420
+ if (detection.isBlocked) botDetected = true;
421
+ } catch {
422
+ /* best-effort — don't fail on bot detection errors */
423
+ }
424
+
425
+ // Step 4: Convert to Markdown (uses pre-parsed root from performFetch)
426
+ // NOTE: htmlToMarkdown mutates the tree — run after bot detection
427
+ const markdown = htmlToMarkdown(result.root);
428
+
429
+ // Step 5: Cap content
430
+ const tid = options.taskId ?? "web-fetch-default";
431
+ const { inline, filePath, totalChars } = capFetchContent(markdown, tid);
432
+
433
+ // Assemble result
434
+ const lines: string[] = [];
435
+ if (result.title) lines.push(`Title: ${result.title}`);
436
+ lines.push(`URL: ${url}`);
437
+ lines.push(
438
+ result.needsJavaScript
439
+ ? "⚠ This page appears to need JavaScript for full rendering."
440
+ : "",
441
+ );
442
+ if (botDetected)
443
+ lines.push(
444
+ "⚠ Bot detection triggered — the page may be blocking automation. Try browser-navigate with a stealth backend instead if one is configured.",
445
+ );
446
+ lines.push(statusCode ? `HTTP ${statusCode}` : "");
447
+ lines.push("");
448
+
449
+ const headerLines = lines.filter(Boolean).join("\n");
450
+ const content = filePath
451
+ ? `📄 Full content saved to ${filePath} (${formatBytes(totalChars)}). Use read with offset/limit to access specific sections — do not read the entire file at once.\n\n${headerLines}\n\n${inline}`
452
+ : `${headerLines}\n\n${inline}`;
453
+
454
+ return {
455
+ success: true,
456
+ url,
457
+ title: result.title,
458
+ content,
459
+ backendUsed: "fetch",
460
+ ...(result.needsJavaScript ? { needsJavaScript: true } : {}),
461
+ ...(botDetected ? { botDetected: true } : {}),
462
+ ...(statusCode !== undefined ? { statusCode } : {}),
463
+ ...(filePath ? { filePath } : {}),
464
+ totalChars,
465
+ };
466
+ }