pi-unsloth-webtools 0.8.0 → 0.9.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/README.md +141 -75
- package/html-to-md.ts +19 -4
- package/index.ts +52 -70
- package/lightpanda.ts +276 -0
- package/package.json +13 -5
- package/scripts/install-lightpanda.sh +206 -0
- package/settings.ts +57 -11
- package/tls-fetch.ts +205 -0
- package/web-fetch.ts +72 -26
- package/config-ui.ts +0 -105
- package/config.ts +0 -207
- package/web-render.ts +0 -100
package/lightpanda.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { spawn as spawnProcess } from "node:child_process";
|
|
2
|
+
import { visibleChars } from "./html-to-md.ts";
|
|
3
|
+
import {
|
|
4
|
+
checkUrlAccess,
|
|
5
|
+
MAX_SIGNAL_TIMEOUT_MS,
|
|
6
|
+
normalizeUrlScheme,
|
|
7
|
+
type WebsitePolicy,
|
|
8
|
+
} from "./web-access.ts";
|
|
9
|
+
import {
|
|
10
|
+
pagePrefixedMarkdown,
|
|
11
|
+
parseLocalPath,
|
|
12
|
+
resolveAndValidateHost,
|
|
13
|
+
TRUNCATED_BODY_NOTICE,
|
|
14
|
+
truncatePageText,
|
|
15
|
+
type ResolvedHost,
|
|
16
|
+
} from "./web-fetch.ts";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BINARY = "lightpanda";
|
|
19
|
+
const DEFAULT_WAIT_UNTIL = "networkidle";
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
21
|
+
const MAX_DUMP_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
23
|
+
const METADATA_LINE_RE = /^(?:Title|Author|Date|Site|URL|Rendered via): /;
|
|
24
|
+
const PROVENANCE = "Rendered via Lightpanda (local browser; nothing sent to a third party).";
|
|
25
|
+
const CANCELLED_MESSAGE = "Failed to render URL: cancelled.";
|
|
26
|
+
const TIMED_OUT_MESSAGE = "Failed to render URL: timed out.";
|
|
27
|
+
const LOCAL_FILE_MESSAGE = "Blocked: the local renderer cannot fetch local files.";
|
|
28
|
+
const NO_TEXT_MESSAGE = "(page returned no readable text)";
|
|
29
|
+
|
|
30
|
+
export type LightpandaDump = "html" | "markdown";
|
|
31
|
+
export type LightpandaWaitUntil = "networkidle" | "load" | "domcontentloaded" | "done";
|
|
32
|
+
|
|
33
|
+
export interface LightpandaSettings {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
binaryPath: string | null;
|
|
36
|
+
command: string[] | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface LightpandaProcess {
|
|
40
|
+
stdout: AsyncIterable<Buffer> | null;
|
|
41
|
+
stderr: AsyncIterable<Buffer> | null;
|
|
42
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
43
|
+
on(event: "close", listener: (code: number | null) => void): unknown;
|
|
44
|
+
kill(signal?: string): unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type LightpandaSpawn = (
|
|
48
|
+
binary: string,
|
|
49
|
+
args: string[],
|
|
50
|
+
options: { stdio: ["ignore", "pipe", "pipe"] },
|
|
51
|
+
) => LightpandaProcess;
|
|
52
|
+
|
|
53
|
+
export interface LightpandaRenderOptions {
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
maxChars?: number;
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
websitePolicy?: WebsitePolicy | null;
|
|
58
|
+
settings?: LightpandaSettings | null;
|
|
59
|
+
binaryPath?: string | null;
|
|
60
|
+
command?: string[] | null;
|
|
61
|
+
dump?: LightpandaDump;
|
|
62
|
+
waitUntil?: LightpandaWaitUntil;
|
|
63
|
+
spawn?: LightpandaSpawn;
|
|
64
|
+
resolve?: (hostname: string, signal?: AbortSignal, allowPrivateAddresses?: boolean) => Promise<ResolvedHost>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RunResult {
|
|
68
|
+
code: number | null;
|
|
69
|
+
stdout: string;
|
|
70
|
+
stderr: string;
|
|
71
|
+
timedOut: boolean;
|
|
72
|
+
cancelled: boolean;
|
|
73
|
+
truncated: boolean;
|
|
74
|
+
missing: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const availability = new Map<string, Promise<boolean>>();
|
|
78
|
+
|
|
79
|
+
function binaryAvailable(launch: string[], spawn: LightpandaSpawn): Promise<boolean> {
|
|
80
|
+
const key = launch.join(" ");
|
|
81
|
+
const cached = availability.get(key);
|
|
82
|
+
if (cached) return cached;
|
|
83
|
+
const probe = new Promise<boolean>((resolve) => {
|
|
84
|
+
let settled = false;
|
|
85
|
+
const finish = (value: boolean) => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
resolve(value);
|
|
90
|
+
};
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
try {
|
|
93
|
+
child.kill("SIGKILL");
|
|
94
|
+
} catch {}
|
|
95
|
+
finish(false);
|
|
96
|
+
}, PROBE_TIMEOUT_MS);
|
|
97
|
+
let child: LightpandaProcess;
|
|
98
|
+
try {
|
|
99
|
+
child = spawn(launch[0], [...launch.slice(1), "version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
100
|
+
} catch {
|
|
101
|
+
finish(false);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
child.on("error", () => finish(false));
|
|
105
|
+
child.on("close", (code) => finish(code === 0));
|
|
106
|
+
});
|
|
107
|
+
availability.set(key, probe);
|
|
108
|
+
return probe;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runLightpanda(
|
|
112
|
+
launch: string[],
|
|
113
|
+
args: string[],
|
|
114
|
+
options: { timeoutMs: number; signal?: AbortSignal; spawn: LightpandaSpawn },
|
|
115
|
+
): Promise<RunResult> {
|
|
116
|
+
return new Promise<RunResult>((resolve) => {
|
|
117
|
+
const state = {
|
|
118
|
+
code: null as number | null,
|
|
119
|
+
stdout: [] as Buffer[],
|
|
120
|
+
stderr: [] as string[],
|
|
121
|
+
timedOut: false,
|
|
122
|
+
cancelled: false,
|
|
123
|
+
truncated: false,
|
|
124
|
+
missing: false,
|
|
125
|
+
bytes: 0,
|
|
126
|
+
};
|
|
127
|
+
let settled = false;
|
|
128
|
+
const finish = (error: Error | null) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
133
|
+
if (error && (error as NodeJS.ErrnoException).code === "ENOENT") state.missing = true;
|
|
134
|
+
const stdout = Buffer.concat(state.stdout).toString("utf-8");
|
|
135
|
+
resolve({ ...state, stdout, stderr: state.stderr.join("") });
|
|
136
|
+
};
|
|
137
|
+
const kill = () => {
|
|
138
|
+
try {
|
|
139
|
+
child.kill("SIGKILL");
|
|
140
|
+
} catch {}
|
|
141
|
+
};
|
|
142
|
+
const onAbort = () => {
|
|
143
|
+
state.cancelled = true;
|
|
144
|
+
kill();
|
|
145
|
+
};
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
state.timedOut = true;
|
|
148
|
+
kill();
|
|
149
|
+
}, options.timeoutMs);
|
|
150
|
+
let child: LightpandaProcess;
|
|
151
|
+
try {
|
|
152
|
+
child = options.spawn(launch[0], [...launch.slice(1), ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const collect = async (stream: AsyncIterable<Buffer> | null, push: (chunk: Buffer) => void): Promise<void> => {
|
|
158
|
+
if (!stream) return;
|
|
159
|
+
try {
|
|
160
|
+
for await (const chunk of stream) push(chunk);
|
|
161
|
+
} catch {}
|
|
162
|
+
};
|
|
163
|
+
const stdoutDone = collect(child.stdout, (chunk) => {
|
|
164
|
+
if (state.bytes >= MAX_DUMP_BYTES) {
|
|
165
|
+
state.truncated = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
state.bytes += chunk.length;
|
|
169
|
+
if (state.bytes > MAX_DUMP_BYTES) {
|
|
170
|
+
state.truncated = true;
|
|
171
|
+
state.stdout.push(chunk.subarray(0, chunk.length - (state.bytes - MAX_DUMP_BYTES)));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
state.stdout.push(chunk);
|
|
175
|
+
});
|
|
176
|
+
const stderrDone = collect(child.stderr, (chunk) => state.stderr.push(chunk.toString("utf-8")));
|
|
177
|
+
child.on("error", (error) => finish(error));
|
|
178
|
+
child.on("close", (code) => {
|
|
179
|
+
state.code = code;
|
|
180
|
+
void Promise.all([stdoutDone, stderrDone]).then(() => finish(null));
|
|
181
|
+
});
|
|
182
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
if (options.signal?.aborted) onAbort();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function bodyChars(text: string): number {
|
|
188
|
+
const body = text
|
|
189
|
+
.split("\n")
|
|
190
|
+
.filter((line) => !METADATA_LINE_RE.test(line))
|
|
191
|
+
.join("\n");
|
|
192
|
+
return visibleChars(body);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function withProvenance(prefixed: string, url: string): string {
|
|
196
|
+
const lines = prefixed.split("\n");
|
|
197
|
+
let index = 0;
|
|
198
|
+
while (index < lines.length && METADATA_LINE_RE.test(lines[index])) index++;
|
|
199
|
+
const header = [`URL: ${url}`, PROVENANCE];
|
|
200
|
+
if (index === 0) return `${header.join("\n")}\n\n${prefixed}`;
|
|
201
|
+
return [...lines.slice(0, index), ...header, ...lines.slice(index)].join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function lightpandaBinary(options: LightpandaRenderOptions = {}): string {
|
|
205
|
+
return (
|
|
206
|
+
options.binaryPath ??
|
|
207
|
+
options.settings?.binaryPath ??
|
|
208
|
+
process.env.PI_LIGHTPANDA_BIN?.trim() ??
|
|
209
|
+
DEFAULT_BINARY
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function lightpandaLaunch(options: LightpandaRenderOptions = {}): string[] {
|
|
214
|
+
const command = options.command ?? options.settings?.command ?? null;
|
|
215
|
+
if (command && command.length) return command;
|
|
216
|
+
return [lightpandaBinary(options)];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function renderPageWithLightpanda(
|
|
220
|
+
url: string,
|
|
221
|
+
options: LightpandaRenderOptions = {},
|
|
222
|
+
): Promise<string | null> {
|
|
223
|
+
if (options.settings && options.settings.enabled === false) return null;
|
|
224
|
+
const target = typeof url === "string" ? url.trim() : "";
|
|
225
|
+
const policy = options.websitePolicy ?? null;
|
|
226
|
+
if (!target) return checkUrlAccess("", policy)[1];
|
|
227
|
+
if (parseLocalPath(target) !== null) return LOCAL_FILE_MESSAGE;
|
|
228
|
+
const normalized = normalizeUrlScheme(target);
|
|
229
|
+
const [allowed, reason, hostname] = checkUrlAccess(normalized, policy);
|
|
230
|
+
if (!allowed) return reason;
|
|
231
|
+
const launch = lightpandaLaunch(options);
|
|
232
|
+
const spawn = options.spawn ?? (spawnProcess as unknown as LightpandaSpawn);
|
|
233
|
+
if (!(await binaryAvailable(launch, spawn))) return null;
|
|
234
|
+
const timeoutMs = Math.min(
|
|
235
|
+
MAX_SIGNAL_TIMEOUT_MS,
|
|
236
|
+
Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)),
|
|
237
|
+
);
|
|
238
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
239
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
240
|
+
const resolve = options.resolve ?? resolveAndValidateHost;
|
|
241
|
+
const resolved = await resolve(hostname, signal, false);
|
|
242
|
+
if (options.signal?.aborted) return CANCELLED_MESSAGE;
|
|
243
|
+
if (timeoutSignal.aborted) return TIMED_OUT_MESSAGE;
|
|
244
|
+
if (!resolved.ok) {
|
|
245
|
+
return resolved.reason.startsWith("Blocked:") ? resolved.reason : `Failed to render URL: ${resolved.reason}`;
|
|
246
|
+
}
|
|
247
|
+
const dump = options.dump ?? "html";
|
|
248
|
+
const result = await runLightpanda(
|
|
249
|
+
launch,
|
|
250
|
+
[
|
|
251
|
+
"fetch",
|
|
252
|
+
"--dump",
|
|
253
|
+
dump,
|
|
254
|
+
"--wait-until",
|
|
255
|
+
options.waitUntil ?? DEFAULT_WAIT_UNTIL,
|
|
256
|
+
"--block-private-networks",
|
|
257
|
+
"--http-timeout",
|
|
258
|
+
String(timeoutMs),
|
|
259
|
+
normalized,
|
|
260
|
+
],
|
|
261
|
+
{ timeoutMs, signal: options.signal, spawn },
|
|
262
|
+
);
|
|
263
|
+
if (result.missing) return null;
|
|
264
|
+
if (result.cancelled) return CANCELLED_MESSAGE;
|
|
265
|
+
if (result.timedOut) return TIMED_OUT_MESSAGE;
|
|
266
|
+
if (result.code !== 0) {
|
|
267
|
+
return `Failed to render URL: Lightpanda exited with code ${result.code ?? "unknown"}.`;
|
|
268
|
+
}
|
|
269
|
+
const raw = result.stdout;
|
|
270
|
+
const rendered =
|
|
271
|
+
dump === "html" ? pagePrefixedMarkdown(result.truncated ? raw + TRUNCATED_BODY_NOTICE : raw) : raw.trim();
|
|
272
|
+
if (bodyChars(rendered) === 0) return NO_TEXT_MESSAGE;
|
|
273
|
+
const withHeader =
|
|
274
|
+
dump === "html" || !result.truncated ? withProvenance(rendered, normalized) : withProvenance(rendered + TRUNCATED_BODY_NOTICE, normalized);
|
|
275
|
+
return truncatePageText(withHeader, options.maxChars);
|
|
276
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-unsloth-webtools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension: web_search and web_fetch tools that began as a port of the Unsloth Studio codebase and now diverge from it (multi-engine search, opt-in SSRF guard, HTML-to-Markdown extraction)",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -23,11 +23,10 @@
|
|
|
23
23
|
"license": "AGPL-3.0",
|
|
24
24
|
"files": [
|
|
25
25
|
"index.ts",
|
|
26
|
-
"config.ts",
|
|
27
|
-
"config-ui.ts",
|
|
28
26
|
"web-search.ts",
|
|
29
27
|
"web-fetch.ts",
|
|
30
|
-
"
|
|
28
|
+
"tls-fetch.ts",
|
|
29
|
+
"lightpanda.ts",
|
|
31
30
|
"web-access.ts",
|
|
32
31
|
"html-to-md.ts",
|
|
33
32
|
"engines.ts",
|
|
@@ -39,7 +38,8 @@
|
|
|
39
38
|
"settings.ts",
|
|
40
39
|
"agent-dir.ts",
|
|
41
40
|
"README.md",
|
|
42
|
-
"LICENSE"
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"scripts/install-lightpanda.sh"
|
|
43
43
|
],
|
|
44
44
|
"pi": {
|
|
45
45
|
"extensions": [
|
|
@@ -58,6 +58,11 @@
|
|
|
58
58
|
"test": "vitest run",
|
|
59
59
|
"test:unit": "vitest run --exclude **/smoke.test.ts",
|
|
60
60
|
"test:smoke": "vitest run test/smoke.test.ts",
|
|
61
|
+
"test:lightpanda": "vitest run test/lightpanda-live.test.ts",
|
|
62
|
+
"compare:fetch": "node scripts/fetch-compare.ts",
|
|
63
|
+
"compare:browsers": "node scripts/compare-browsers.ts",
|
|
64
|
+
"camoufox:warmup": "node scripts/camoufox-warmup.ts",
|
|
65
|
+
"stealth:matrix": "node scripts/stealth-matrix.ts",
|
|
61
66
|
"typecheck": "tsc --noEmit",
|
|
62
67
|
"check:package": "node -e \"const fs=require('fs');const pkg=require('./package.json');const listed=new Set(pkg.files);const bad=[];for(const f of pkg.files){if(!fs.existsSync(f))bad.push('missing file: '+f)}for(const f of fs.readdirSync('.').filter(f=>f.endsWith('.ts'))){if(!listed.has(f))bad.push('unlisted source: '+f)}if(bad.length){console.error(bad.join('\\n'));process.exit(1)}\"",
|
|
63
68
|
"prepublishOnly": "npm run typecheck && npm run lint && npm run test:unit && npm run check:package && npm run check:publint",
|
|
@@ -65,6 +70,9 @@
|
|
|
65
70
|
"check:publint": "publint",
|
|
66
71
|
"check:attw": "attw --pack"
|
|
67
72
|
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"wreq-js": "^3.2.0"
|
|
75
|
+
},
|
|
68
76
|
"devDependencies": {
|
|
69
77
|
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
70
78
|
"@earendil-works/pi-tui": "^0.87.0",
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
REPO="lightpanda-io/browser"
|
|
5
|
+
DEFAULT_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/pi-unsloth-webtools/lightpanda"
|
|
6
|
+
PINNED_FALLBACK_VERSION="1.0.0"
|
|
7
|
+
|
|
8
|
+
DIR="$DEFAULT_DIR"
|
|
9
|
+
VERSION=""
|
|
10
|
+
FORCE=0
|
|
11
|
+
ALLOW_SHIM=1
|
|
12
|
+
PRINT_PATH=0
|
|
13
|
+
|
|
14
|
+
usage() {
|
|
15
|
+
cat <<'EOF'
|
|
16
|
+
Install the Lightpanda browser used by this package's local rendering tier.
|
|
17
|
+
|
|
18
|
+
Usage: bash scripts/install-lightpanda.sh [options]
|
|
19
|
+
|
|
20
|
+
--version=X Release tag to install (default: the newest stable release, e.g. 1.0.0;
|
|
21
|
+
pass --version=nightly for the nightly build)
|
|
22
|
+
--dir=PATH Install directory (default: $XDG_DATA_HOME/pi-unsloth-webtools/lightpanda)
|
|
23
|
+
--force Reinstall even when a working binary is already present
|
|
24
|
+
--no-shim Fail instead of bundling a newer libc when the system glibc is too old
|
|
25
|
+
--print-path Print only the launcher path (for scripts and CI)
|
|
26
|
+
-h, --help Show this message
|
|
27
|
+
|
|
28
|
+
On Linux, if the release needs a newer glibc than the system provides, the script
|
|
29
|
+
downloads Debian's libc6 for the current stable suite, extracts it into the install
|
|
30
|
+
directory, and writes a launcher shim that runs the binary with that libc. Point
|
|
31
|
+
`webRender.lightpandaPath` (settings.json) or `PI_LIGHTPANDA_BIN` at the printed path.
|
|
32
|
+
EOF
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for arg in "$@"; do
|
|
36
|
+
case "$arg" in
|
|
37
|
+
--version=*) VERSION="${arg#*=}" ;;
|
|
38
|
+
--dir=*) DIR="${arg#*=}" ;;
|
|
39
|
+
--force) FORCE=1 ;;
|
|
40
|
+
--no-shim) ALLOW_SHIM=0 ;;
|
|
41
|
+
--print-path) PRINT_PATH=1 ;;
|
|
42
|
+
-h|--help) usage; exit 0 ;;
|
|
43
|
+
*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
|
|
44
|
+
esac
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
need() {
|
|
48
|
+
if ! command -v "$1" >/dev/null 2>&1; then
|
|
49
|
+
echo "missing required command: $1" >&2
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
need curl
|
|
55
|
+
need awk
|
|
56
|
+
need sed
|
|
57
|
+
|
|
58
|
+
os="$(uname -s)"
|
|
59
|
+
arch="$(uname -m)"
|
|
60
|
+
deb_arch=""
|
|
61
|
+
case "$os/$arch" in
|
|
62
|
+
Linux/x86_64) asset="lightpanda-x86_64-linux"; deb_arch="amd64" ;;
|
|
63
|
+
Linux/aarch64|Linux/arm64) asset="lightpanda-aarch64-linux"; deb_arch="arm64" ;;
|
|
64
|
+
Darwin/x86_64) asset="lightpanda-x86_64-macos" ;;
|
|
65
|
+
Darwin/arm64) asset="lightpanda-aarch64-macos" ;;
|
|
66
|
+
MINGW*|MSYS*|CYGWIN*)
|
|
67
|
+
cat >&2 <<'EOF'
|
|
68
|
+
Lightpanda ships no native Windows build. Run it inside WSL2 instead:
|
|
69
|
+
|
|
70
|
+
1. in WSL: bash scripts/install-lightpanda.sh
|
|
71
|
+
(it prints a path such as /home/<you>/.local/share/pi-unsloth-webtools/lightpanda/lightpanda-run)
|
|
72
|
+
|
|
73
|
+
2. in Windows settings.json, drive that binary through wsl.exe:
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
"webRender": {
|
|
77
|
+
"lightpandaCommand": ["wsl.exe", "-e", "/home/<you>/.local/share/pi-unsloth-webtools/lightpanda/lightpanda-run"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Everything else in this package (search, the browser-fingerprint transport) runs natively on Windows.
|
|
82
|
+
EOF
|
|
83
|
+
exit 1 ;;
|
|
84
|
+
*) echo "unsupported platform: $os/$arch" >&2; exit 1 ;;
|
|
85
|
+
esac
|
|
86
|
+
|
|
87
|
+
if [ -z "$VERSION" ]; then
|
|
88
|
+
VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=30" 2>/dev/null \
|
|
89
|
+
| grep -o '"tag_name":[[:space:]]*"[^"]*"' \
|
|
90
|
+
| sed -n 's/.*"\([^"]*\)"$/\1/p' \
|
|
91
|
+
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
|
|
92
|
+
| head -1 || true)"
|
|
93
|
+
if [ -z "$VERSION" ]; then
|
|
94
|
+
echo "could not resolve the newest release; falling back to $PINNED_FALLBACK_VERSION" >&2
|
|
95
|
+
VERSION="$PINNED_FALLBACK_VERSION"
|
|
96
|
+
fi
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
mkdir -p "$DIR"
|
|
100
|
+
binary="$DIR/lightpanda"
|
|
101
|
+
shim="$DIR/lightpanda-run"
|
|
102
|
+
|
|
103
|
+
report_path() {
|
|
104
|
+
if [ "$PRINT_PATH" = 1 ]; then
|
|
105
|
+
printf '%s\n' "$1"
|
|
106
|
+
else
|
|
107
|
+
echo "already installed and working: $1" >&2
|
|
108
|
+
fi
|
|
109
|
+
exit 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if [ "$FORCE" = 0 ] && [ -x "$shim" ] && "$shim" version >/dev/null 2>&1; then
|
|
113
|
+
report_path "$shim"
|
|
114
|
+
fi
|
|
115
|
+
if [ "$FORCE" = 0 ] && [ -x "$binary" ] && "$binary" version >/dev/null 2>&1; then
|
|
116
|
+
report_path "$binary"
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
url="https://github.com/$REPO/releases/download/$VERSION/$asset"
|
|
120
|
+
if [ "$PRINT_PATH" = 1 ]; then
|
|
121
|
+
echo "downloading $VERSION ($asset)" >&2
|
|
122
|
+
curl -fL --silent --show-error "$url" -o "$binary.part"
|
|
123
|
+
else
|
|
124
|
+
echo "downloading $VERSION ($asset)"
|
|
125
|
+
curl -fL --progress-bar "$url" -o "$binary.part"
|
|
126
|
+
fi
|
|
127
|
+
chmod +x "$binary.part"
|
|
128
|
+
mv -f "$binary.part" "$binary"
|
|
129
|
+
rm -f "$shim"
|
|
130
|
+
|
|
131
|
+
run_path="$binary"
|
|
132
|
+
if ! "$binary" version >/dev/null 2>&1; then
|
|
133
|
+
run_path=""
|
|
134
|
+
if [ "$ALLOW_SHIM" = 0 ] || [ "$os" != "Linux" ]; then
|
|
135
|
+
echo "the $VERSION binary cannot run on this system:" >&2
|
|
136
|
+
"$binary" version 2>&1 | head -3 >&2
|
|
137
|
+
exit 1
|
|
138
|
+
fi
|
|
139
|
+
required="$({ "$binary" version 2>&1 || true; } | sed -n "s/.*GLIBC_\([0-9][0-9.]*\).*/\1/p" | sort -V | tail -1)"
|
|
140
|
+
if [ -z "$required" ]; then
|
|
141
|
+
echo "the $VERSION binary failed to start and the cause is not a glibc version:" >&2
|
|
142
|
+
"$binary" version 2>&1 | head -3 >&2
|
|
143
|
+
exit 1
|
|
144
|
+
fi
|
|
145
|
+
system_glibc="$(ldd --version 2>/dev/null | head -1 | sed -n 's/.* \([0-9][0-9.]*\)$/\1/p')"
|
|
146
|
+
echo "binary needs glibc $required, system has ${system_glibc:-unknown}: bundling a newer libc"
|
|
147
|
+
need dpkg-deb
|
|
148
|
+
glibc_dir="$DIR/glibc"
|
|
149
|
+
rm -rf "$glibc_dir"
|
|
150
|
+
for suite in trixie stable sid; do
|
|
151
|
+
index="https://deb.debian.org/debian/dists/$suite/main/binary-$deb_arch/Packages.gz"
|
|
152
|
+
deb_path="$(curl -fsSL "$index" 2>/dev/null | gzip -dc 2>/dev/null \
|
|
153
|
+
| awk '/^Package: libc6$/{found=1} found && /^Filename:/{print $2; exit}' || true)"
|
|
154
|
+
[ -n "$deb_path" ] || continue
|
|
155
|
+
echo "fetching libc6 from debian/$suite"
|
|
156
|
+
curl -fsSL "https://deb.debian.org/debian/$deb_path" -o "$DIR/libc6.deb" || continue
|
|
157
|
+
mkdir -p "$glibc_dir"
|
|
158
|
+
dpkg-deb -x "$DIR/libc6.deb" "$glibc_dir"
|
|
159
|
+
rm -f "$DIR/libc6.deb"
|
|
160
|
+
loader="$(find "$glibc_dir" -path "*-linux-gnu/ld-linux-*.so.1" | head -1)"
|
|
161
|
+
[ -n "$loader" ] || continue
|
|
162
|
+
bundled_glibc="$("$loader" --version 2>/dev/null | head -1 | sed -n 's/.* \([0-9][0-9.]*\)$/\1/p')"
|
|
163
|
+
if [ -z "$bundled_glibc" ]; then
|
|
164
|
+
rm -rf "$glibc_dir"
|
|
165
|
+
continue
|
|
166
|
+
fi
|
|
167
|
+
newest="$(printf '%s\n%s\n' "$required" "$bundled_glibc" | sort -V | tail -1)"
|
|
168
|
+
if [ "$newest" != "$bundled_glibc" ]; then
|
|
169
|
+
echo "bundled glibc $bundled_glibc is older than required $required, trying the next suite" >&2
|
|
170
|
+
rm -rf "$glibc_dir"
|
|
171
|
+
continue
|
|
172
|
+
fi
|
|
173
|
+
libdir="$(dirname "$loader")"
|
|
174
|
+
{
|
|
175
|
+
echo "#!/bin/sh"
|
|
176
|
+
echo "exec \"$loader\" --library-path \"$libdir\" \"$binary\" \"\$@\""
|
|
177
|
+
} > "$shim"
|
|
178
|
+
chmod +x "$shim"
|
|
179
|
+
if "$shim" version >/dev/null 2>&1; then
|
|
180
|
+
run_path="$shim"
|
|
181
|
+
echo "bundled glibc $bundled_glibc at $glibc_dir"
|
|
182
|
+
break
|
|
183
|
+
fi
|
|
184
|
+
rm -rf "$glibc_dir" "$shim"
|
|
185
|
+
done
|
|
186
|
+
if [ -z "$run_path" ]; then
|
|
187
|
+
echo "could not make $VERSION runnable on this system." >&2
|
|
188
|
+
echo "Options: upgrade the distribution, run Lightpanda in a container, or install an older" >&2
|
|
189
|
+
echo "release that targets this glibc (for example 0.2.9)." >&2
|
|
190
|
+
exit 1
|
|
191
|
+
fi
|
|
192
|
+
fi
|
|
193
|
+
|
|
194
|
+
installed_version="$("$run_path" version 2>/dev/null | head -1)"
|
|
195
|
+
if [ "$PRINT_PATH" = 1 ]; then
|
|
196
|
+
printf '%s\n' "$run_path"
|
|
197
|
+
exit 0
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
echo
|
|
201
|
+
echo "Lightpanda $installed_version installed"
|
|
202
|
+
echo " run this: $run_path"
|
|
203
|
+
echo " configure one of:"
|
|
204
|
+
echo " export PI_LIGHTPANDA_BIN=$run_path"
|
|
205
|
+
echo " settings.json: { \"webRender\": { \"lightpandaPath\": \"$run_path\" } }"
|
|
206
|
+
echo " verify: $run_path fetch --dump markdown --wait-until networkidle https://example.com/"
|
package/settings.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { agentDir } from "./agent-dir.ts";
|
|
4
|
+
import type { FetchTransport } from "./web-fetch.ts";
|
|
4
5
|
|
|
5
6
|
async function readJson(file: string): Promise<Record<string, unknown> | undefined> {
|
|
6
7
|
try {
|
|
@@ -49,6 +50,23 @@ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean
|
|
|
49
50
|
return undefined;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
function pickStringArray(data: Record<string, unknown>, paths: string[][]): string[] | undefined {
|
|
54
|
+
for (const path of paths) {
|
|
55
|
+
let cur: unknown = data;
|
|
56
|
+
for (const key of path) {
|
|
57
|
+
if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
|
|
58
|
+
else {
|
|
59
|
+
cur = undefined;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(cur)) continue;
|
|
64
|
+
const values = cur.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
|
65
|
+
if (values.length) return values;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
function pickString(data: Record<string, unknown>, paths: string[][]): string | undefined {
|
|
53
71
|
for (const path of paths) {
|
|
54
72
|
let cur: unknown = data;
|
|
@@ -94,11 +112,27 @@ const ALLOW_LOCAL_FILES_PATHS: string[][] = [
|
|
|
94
112
|
["webFetch", "allowLocalFiles"],
|
|
95
113
|
];
|
|
96
114
|
|
|
97
|
-
const
|
|
98
|
-
["unslothWebTools", "
|
|
99
|
-
["
|
|
115
|
+
const TRANSPORT_PATHS: string[][] = [
|
|
116
|
+
["unslothWebTools", "transport"],
|
|
117
|
+
["webFetch", "transport"],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const TRANSPORT_VALUES: ReadonlySet<string> = new Set(["tls-first", "direct-first", "off"]);
|
|
121
|
+
|
|
122
|
+
const LIGHTPANDA_ENABLED_PATHS: string[][] = [
|
|
123
|
+
["unslothWebTools", "lightpandaEnabled"],
|
|
124
|
+
["webRender", "lightpandaEnabled"],
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const LIGHTPANDA_PATH_PATHS: string[][] = [
|
|
128
|
+
["unslothWebTools", "lightpandaPath"],
|
|
129
|
+
["webRender", "lightpandaPath"],
|
|
100
130
|
];
|
|
101
131
|
|
|
132
|
+
const LIGHTPANDA_COMMAND_PATHS: string[][] = [
|
|
133
|
+
["unslothWebTools", "lightpandaCommand"],
|
|
134
|
+
["webRender", "lightpandaCommand"],
|
|
135
|
+
];
|
|
102
136
|
function clampMaxResults(value: number): number {
|
|
103
137
|
return Math.min(20, Math.max(1, value));
|
|
104
138
|
}
|
|
@@ -151,11 +185,13 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
|
|
|
151
185
|
timeoutMs?: number;
|
|
152
186
|
allowPrivateAddresses: boolean;
|
|
153
187
|
allowLocalFiles: boolean;
|
|
188
|
+
transport: FetchTransport;
|
|
154
189
|
}> {
|
|
155
190
|
let maxChars: number | undefined;
|
|
156
191
|
let timeoutMs: number | undefined;
|
|
157
192
|
let allowPrivateAddresses = true;
|
|
158
193
|
let allowLocalFiles = true;
|
|
194
|
+
let transport: FetchTransport = "tls-first";
|
|
159
195
|
for (const data of await settingsEntries(cwd)) {
|
|
160
196
|
const c = pickNumber(data, FETCH_MAX_CHARS_PATHS);
|
|
161
197
|
if (c !== undefined && c > 0) maxChars = c;
|
|
@@ -165,17 +201,27 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
|
|
|
165
201
|
if (p !== undefined) allowPrivateAddresses = p;
|
|
166
202
|
const l = pickBoolean(data, ALLOW_LOCAL_FILES_PATHS);
|
|
167
203
|
if (l !== undefined) allowLocalFiles = l;
|
|
204
|
+
const mode = pickString(data, TRANSPORT_PATHS);
|
|
205
|
+
if (mode !== undefined && TRANSPORT_VALUES.has(mode)) transport = mode as FetchTransport;
|
|
168
206
|
}
|
|
169
|
-
return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles };
|
|
207
|
+
return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles, transport };
|
|
170
208
|
}
|
|
171
209
|
|
|
172
|
-
export async function
|
|
173
|
-
|
|
210
|
+
export async function loadLightpandaSettings(cwd?: string): Promise<{
|
|
211
|
+
enabled: boolean;
|
|
212
|
+
binaryPath: string | null;
|
|
213
|
+
command: string[] | null;
|
|
214
|
+
}> {
|
|
215
|
+
let enabled = true;
|
|
216
|
+
let binaryPath: string | null = null;
|
|
217
|
+
let command: string[] | null = null;
|
|
174
218
|
for (const data of await settingsEntries(cwd)) {
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
219
|
+
const e = pickBoolean(data, LIGHTPANDA_ENABLED_PATHS);
|
|
220
|
+
if (e !== undefined) enabled = e;
|
|
221
|
+
const p = pickString(data, LIGHTPANDA_PATH_PATHS);
|
|
222
|
+
if (p !== undefined) binaryPath = p;
|
|
223
|
+
const c = pickStringArray(data, LIGHTPANDA_COMMAND_PATHS);
|
|
224
|
+
if (c !== undefined) command = c;
|
|
177
225
|
}
|
|
178
|
-
|
|
179
|
-
const env = process.env.JINA_API_KEY?.trim();
|
|
180
|
-
return env ? env : undefined;
|
|
226
|
+
return { enabled, binaryPath, command };
|
|
181
227
|
}
|