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.
- package/LICENSE +661 -0
- package/README.md +608 -0
- package/backends/chromium/index.ts +50 -0
- package/backends/chromium-py/bridge.py +67 -0
- package/backends/firefox/index.ts +60 -0
- package/backends/firefox-py/bridge.py +64 -0
- package/backends/playwright-base/playwright-plugin.ts +1294 -0
- package/backends/python-adapter.ts +1141 -0
- package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
- package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
- package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
- package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
- package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
- package/backends/python-base/pi_browser_bridge/transport.py +167 -0
- package/backends/python-base/pyproject.toml +15 -0
- package/browser-cookies.ts +88 -0
- package/browser-profile.ts +260 -0
- package/browser-status.ts +84 -0
- package/browser-toggle.ts +527 -0
- package/core/fetch-backend.ts +466 -0
- package/core/guides.ts +467 -0
- package/core/plugin-api.ts +302 -0
- package/core/plugin-config.ts +388 -0
- package/core/plugin-registry.ts +263 -0
- package/core/router.ts +1186 -0
- package/core/shared/accessibility-tree.ts +408 -0
- package/core/shared/bot-detection.ts +187 -0
- package/core/shared/browser-events.ts +111 -0
- package/core/shared/dom-extractor.ts +550 -0
- package/core/shared/nav-settle.ts +187 -0
- package/core/shared/paths.ts +56 -0
- package/core/shared/session-manager.ts +258 -0
- package/core/shared/settings-reader.ts +63 -0
- package/core/shared/snapshot-cache.ts +231 -0
- package/core/shared/storage-state.ts +560 -0
- package/core/shared/task-id.ts +77 -0
- package/core/shared/url-safety.ts +164 -0
- package/index.ts +253 -0
- package/package.json +63 -0
- package/ship-manifest.test.ts +12 -0
- package/tools/browser-back.ts +50 -0
- package/tools/browser-click.ts +74 -0
- package/tools/browser-console.ts +160 -0
- package/tools/browser-inspect.ts +136 -0
- package/tools/browser-navigate.ts +254 -0
- package/tools/browser-press.ts +80 -0
- package/tools/browser-scroll.ts +56 -0
- package/tools/browser-snapshot.ts +90 -0
- package/tools/browser-type.ts +60 -0
- package/tools/index.ts +19 -0
- package/tools/utils.ts +157 -0
- package/tools/web-fetch.ts +147 -0
- package/tools/web-guide.ts +55 -0
- package/tools/web-learn.ts +128 -0
- 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
|
+
}
|