pi-unsloth-webtools 0.7.6 → 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 +151 -84
- package/entities.ts +2 -2
- package/html-to-md.ts +24 -9
- package/index.ts +96 -166
- package/lightpanda.ts +276 -0
- package/package.json +13 -5
- package/proxy.ts +2 -1
- package/scripts/install-lightpanda.sh +206 -0
- package/settings.ts +57 -11
- package/tls-fetch.ts +205 -0
- package/web-access.ts +4 -0
- package/web-fetch.ts +175 -51
- package/config-ui.ts +0 -105
- package/config.ts +0 -207
- package/web-render.ts +0 -100
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",
|
package/proxy.ts
CHANGED
|
@@ -3,6 +3,7 @@ import https from "node:https";
|
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
import tls from "node:tls";
|
|
5
5
|
import type { Duplex } from "node:stream";
|
|
6
|
+
import { stripIpv6Brackets } from "./web-access.ts";
|
|
6
7
|
|
|
7
8
|
export interface SocksProxy {
|
|
8
9
|
host: string;
|
|
@@ -124,7 +125,7 @@ export function socksProxyForUrl(url: URL): SocksProxy | null {
|
|
|
124
125
|
const proxy = parseSocksProxy(firstEnv(url.protocol === "https:" ? HTTPS_PROXY_VARS : HTTP_PROXY_VARS));
|
|
125
126
|
if (!proxy) return null;
|
|
126
127
|
const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80;
|
|
127
|
-
if (bypassesProxy(url.hostname, port)) return null;
|
|
128
|
+
if (bypassesProxy(stripIpv6Brackets(url.hostname), port)) return null;
|
|
128
129
|
return proxy;
|
|
129
130
|
}
|
|
130
131
|
|
|
@@ -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
|
}
|
package/tls-fetch.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { MAX_SIGNAL_TIMEOUT_MS } from "./web-access.ts";
|
|
2
|
+
|
|
3
|
+
const MODULE_NAME = "wreq-js";
|
|
4
|
+
const DEFAULT_PROFILE = "chrome_145";
|
|
5
|
+
const DEFAULT_OS = "windows";
|
|
6
|
+
const TRANSPORT_CACHE_LIMIT = 8;
|
|
7
|
+
|
|
8
|
+
const HOP_HEADER_EXCLUSIONS = new Set(["host", "user-agent", "accept-encoding", "content-length"]);
|
|
9
|
+
const PDF_MAGIC = Buffer.from("%PDF-");
|
|
10
|
+
const MAGIC_HEAD_BYTES = 1024;
|
|
11
|
+
const SKIPPED_MAGIC_PREFIX_BYTES = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20]);
|
|
12
|
+
const BOM_PREFIXES = [
|
|
13
|
+
Buffer.from([0xff, 0xfe, 0x00, 0x00]),
|
|
14
|
+
Buffer.from([0x00, 0x00, 0xfe, 0xff]),
|
|
15
|
+
Buffer.from([0xff, 0xfe]),
|
|
16
|
+
Buffer.from([0xfe, 0xff]),
|
|
17
|
+
Buffer.from([0xef, 0xbb, 0xbf]),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function looksLikePdf(head: Buffer): boolean {
|
|
21
|
+
let start = 0;
|
|
22
|
+
while (start < head.length && SKIPPED_MAGIC_PREFIX_BYTES.has(head[start])) start++;
|
|
23
|
+
for (const bom of BOM_PREFIXES) {
|
|
24
|
+
if (head.subarray(start).length >= bom.length && head.subarray(start, start + bom.length).equals(bom)) {
|
|
25
|
+
start += bom.length;
|
|
26
|
+
while (start < head.length && SKIPPED_MAGIC_PREFIX_BYTES.has(head[start])) start++;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const magic = head.subarray(start);
|
|
31
|
+
return magic.length >= PDF_MAGIC.length && magic.subarray(0, PDF_MAGIC.length).equals(PDF_MAGIC);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ImpersonationResponse {
|
|
35
|
+
status: number;
|
|
36
|
+
url: string;
|
|
37
|
+
headers: { get(name: string): string | null };
|
|
38
|
+
body: AsyncIterable<Uint8Array> | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ImpersonationTransport {
|
|
42
|
+
close?(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ImpersonationModule {
|
|
46
|
+
createTransport(options: Record<string, unknown>): Promise<ImpersonationTransport>;
|
|
47
|
+
fetch(input: string, init: Record<string, unknown>): Promise<ImpersonationResponse>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ImpersonationLoader = () => Promise<ImpersonationModule | null>;
|
|
51
|
+
|
|
52
|
+
export interface TlsHopOptions {
|
|
53
|
+
url: URL;
|
|
54
|
+
pinnedIp: string;
|
|
55
|
+
family: number;
|
|
56
|
+
timeoutMs: number;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
maxBytes: number;
|
|
59
|
+
maxPdfBytes?: number;
|
|
60
|
+
profile?: string;
|
|
61
|
+
extraHeaders?: Record<string, string>;
|
|
62
|
+
loader?: ImpersonationLoader;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TlsHopResponse {
|
|
66
|
+
status: number;
|
|
67
|
+
headers: Record<string, string>;
|
|
68
|
+
body: Buffer;
|
|
69
|
+
truncated: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let cachedModule: Promise<ImpersonationModule | null> | null = null;
|
|
73
|
+
|
|
74
|
+
async function loadImpersonationModule(): Promise<ImpersonationModule | null> {
|
|
75
|
+
cachedModule ??= (async () => {
|
|
76
|
+
try {
|
|
77
|
+
return (await import(MODULE_NAME)) as unknown as ImpersonationModule;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
return cachedModule;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const transports = new Map<string, Promise<ImpersonationTransport>>();
|
|
86
|
+
|
|
87
|
+
function transportKey(options: TlsHopOptions): string {
|
|
88
|
+
return `${options.profile ?? DEFAULT_PROFILE}|${options.url.hostname}|${options.pinnedIp}|${options.family}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function evictTransports(): void {
|
|
92
|
+
while (transports.size > TRANSPORT_CACHE_LIMIT) {
|
|
93
|
+
const oldestKey = transports.keys().next().value as string | undefined;
|
|
94
|
+
if (oldestKey === undefined) return;
|
|
95
|
+
const oldest = transports.get(oldestKey);
|
|
96
|
+
transports.delete(oldestKey);
|
|
97
|
+
void oldest
|
|
98
|
+
?.then((transport) => transport.close?.())
|
|
99
|
+
.catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function transportFor(
|
|
104
|
+
module: ImpersonationModule,
|
|
105
|
+
options: TlsHopOptions,
|
|
106
|
+
): Promise<ImpersonationTransport> {
|
|
107
|
+
const key = transportKey(options);
|
|
108
|
+
const existing = transports.get(key);
|
|
109
|
+
if (existing) return existing;
|
|
110
|
+
const created = module
|
|
111
|
+
.createTransport({
|
|
112
|
+
resolve: { [options.url.hostname]: options.pinnedIp },
|
|
113
|
+
browser: options.profile ?? DEFAULT_PROFILE,
|
|
114
|
+
os: DEFAULT_OS,
|
|
115
|
+
})
|
|
116
|
+
.catch((error: unknown) => {
|
|
117
|
+
transports.delete(key);
|
|
118
|
+
throw error;
|
|
119
|
+
});
|
|
120
|
+
transports.set(key, created);
|
|
121
|
+
evictTransports();
|
|
122
|
+
return created;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readCapped(
|
|
126
|
+
body: AsyncIterable<Uint8Array> | null,
|
|
127
|
+
maxBytes: number,
|
|
128
|
+
maxPdfBytes: number,
|
|
129
|
+
): Promise<{ buffer: Buffer; truncated: boolean }> {
|
|
130
|
+
if (!body) return { buffer: Buffer.alloc(0), truncated: false };
|
|
131
|
+
const chunks: Buffer[] = [];
|
|
132
|
+
let total = 0;
|
|
133
|
+
let truncated = false;
|
|
134
|
+
let head = Buffer.alloc(0);
|
|
135
|
+
let limit = maxBytes;
|
|
136
|
+
for await (const chunk of body) {
|
|
137
|
+
const buffer = Buffer.from(chunk);
|
|
138
|
+
if (head.length < MAGIC_HEAD_BYTES) {
|
|
139
|
+
head = Buffer.concat([head, buffer.subarray(0, MAGIC_HEAD_BYTES - head.length)]);
|
|
140
|
+
if (limit === maxBytes && looksLikePdf(head)) limit = maxPdfBytes;
|
|
141
|
+
}
|
|
142
|
+
if (total + buffer.length > limit) {
|
|
143
|
+
const take = buffer.subarray(0, limit - total);
|
|
144
|
+
chunks.push(take);
|
|
145
|
+
total += take.length;
|
|
146
|
+
truncated = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
chunks.push(buffer);
|
|
150
|
+
total += buffer.length;
|
|
151
|
+
}
|
|
152
|
+
return { buffer: Buffer.concat(chunks), truncated };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function failureMessage(error: unknown, caller: AbortSignal | undefined, attempt: AbortSignal): string {
|
|
156
|
+
if (caller?.aborted) return "cancelled";
|
|
157
|
+
if (attempt.aborted) return "timed out";
|
|
158
|
+
if (error instanceof Error && error.name === "TimeoutError") return "timed out";
|
|
159
|
+
if (error instanceof Error && error.name === "AbortError") return "cancelled";
|
|
160
|
+
return error instanceof Error ? error.message : String(error);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function hopHeaders(extraHeaders: Record<string, string> | undefined): Record<string, string> {
|
|
164
|
+
const headers: Record<string, string> = {};
|
|
165
|
+
for (const [key, value] of Object.entries(extraHeaders ?? {})) {
|
|
166
|
+
if (HOP_HEADER_EXCLUSIONS.has(key.toLowerCase())) continue;
|
|
167
|
+
headers[key] = value;
|
|
168
|
+
}
|
|
169
|
+
return headers;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function impersonatedRequest(options: TlsHopOptions): Promise<TlsHopResponse | null> {
|
|
173
|
+
const load = options.loader ?? loadImpersonationModule;
|
|
174
|
+
const module = await load();
|
|
175
|
+
if (!module) return null;
|
|
176
|
+
const transport = await transportFor(module, options);
|
|
177
|
+
const timeoutMs = Math.min(MAX_SIGNAL_TIMEOUT_MS, Math.max(1, Math.floor(options.timeoutMs)));
|
|
178
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
179
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
180
|
+
let response: ImpersonationResponse;
|
|
181
|
+
try {
|
|
182
|
+
response = await module.fetch(options.url.toString(), {
|
|
183
|
+
transport,
|
|
184
|
+
redirect: "manual",
|
|
185
|
+
signal,
|
|
186
|
+
headers: hopHeaders(options.extraHeaders),
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw new Error(failureMessage(error, options.signal, signal));
|
|
190
|
+
}
|
|
191
|
+
if (response.status < 200) return null;
|
|
192
|
+
const { buffer, truncated } = await readCapped(
|
|
193
|
+
response.body,
|
|
194
|
+
options.maxBytes,
|
|
195
|
+
options.maxPdfBytes ?? options.maxBytes,
|
|
196
|
+
);
|
|
197
|
+
const headers: Record<string, string> = {};
|
|
198
|
+
const contentType = response.headers.get("content-type");
|
|
199
|
+
if (contentType) headers["content-type"] = contentType;
|
|
200
|
+
if (response.status >= 300 && response.status < 400) {
|
|
201
|
+
const location = response.headers.get("location");
|
|
202
|
+
if (location) headers.location = location;
|
|
203
|
+
}
|
|
204
|
+
return { status: response.status, headers, body: buffer, truncated };
|
|
205
|
+
}
|
package/web-access.ts
CHANGED
|
@@ -70,6 +70,10 @@ export function normalizeDomain(value: unknown): string {
|
|
|
70
70
|
return asciiDomain;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export function stripIpv6Brackets(hostname: string): string {
|
|
74
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
75
|
+
}
|
|
76
|
+
|
|
73
77
|
function compressIpv6(ip: string): string {
|
|
74
78
|
const segments = ip.toLowerCase().split("::");
|
|
75
79
|
if (segments.length > 2) throw new Error("invalid ipv6");
|