dsh-read-url 1.8.1 → 1.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/CHANGELOG.md +15 -0
- package/README.md +29 -1
- package/README.zh.md +29 -1
- package/cordis.patch.yml +1 -0
- package/index.js +154 -45
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.0] - 2026-09-19
|
|
4
|
+
|
|
5
|
+
### 新增
|
|
6
|
+
|
|
7
|
+
- 新增仅 profile 可配置的 `directFetchOrigins`,默认空列表。按协议、域名和端口精确匹配,命中后直接读取,显式跳过宿主 provider 及其 SSRF 检查;未命中的初始 URL 维持原有行为,不因任意 provider 错误自动直连。
|
|
8
|
+
- 例外链的 HTTP 重定向、meta-refresh、分页及爬取子页面均受完整列表约束;HTTP 跳转逐跳校验,每次尝试最多 5 跳并检测循环。例外不进入 curl 代理竞速或 SPA 渲染,失败边界/静态读取提示保留到工具输出。
|
|
9
|
+
- 成功和失败缓存按规范化后的完整配置隔离;非法配置明确报错,工具参数不允许扩大直连范围。双语 README 增补配置方法及信任边界。
|
|
10
|
+
|
|
11
|
+
### 验证
|
|
12
|
+
|
|
13
|
+
- 322 个零依赖单元断言、15 个真实 Chromium SPA 断言、53 项对抗探针通过;新增 22 条直连策略检查与 3 条 SPA 限制检查。
|
|
14
|
+
- 受控 HTTP 服务与 provider 桩覆盖默认拒绝、显式直连、重定向、分页、四工具、缓存隔离、取消、超时及正文上限。
|
|
15
|
+
- 152 站实网回归:105 OK / 23 THIN+EMPTY / 24 ERR / 0 THREW,OK 站零噪声。Vue、React、酷狗三个已知 NOISE 站同输入 v1.8.1 对照的 text/Markdown 全文与 800 字符窗口逐字一致;ERR 均为网络、反爬、无效地址或预期不支持的类型。
|
|
16
|
+
- 四工具 description 共 1,159 字符,参数 schema 共 1,876 字符;零运行时依赖,新增正则有界。
|
|
17
|
+
|
|
3
18
|
## [1.8.1] - 2026-09-19
|
|
4
19
|
|
|
5
20
|
### 修复
|
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ DSH agents can search (getting links and snippets) but lack the step of "reading
|
|
|
47
47
|
|
|
48
48
|
Implemented per official docs (`docs/capability-seams.md`, `docs/cordis-primer.md`, `docs/tool-execution-pipeline.md`):
|
|
49
49
|
|
|
50
|
-
1. **Web access via the `ctx.web` capability seam** — use `ctx.web.fetch({ url }, signal)` and its decoded `body.kind/content` result for reads, redirects and crawl requests; fall back to direct retrieval when the service or provider is unavailable. Provider HTTP errors and policy decisions remain errors;
|
|
50
|
+
1. **Web access via the `ctx.web` capability seam** — use `ctx.web.fetch({ url }, signal)` and its decoded `body.kind/content` result for reads, redirects and crawl requests; fall back to direct retrieval when the service or provider is unavailable. Provider HTTP errors and policy decisions remain errors. The optional `directFetchOrigins` profile setting selects explicit direct retrieval for trusted origins;
|
|
51
51
|
2. **Reversible side effects** — the session cache is registered under `ctx.effect`, auto-cleared on plugin unload (temporal composability);
|
|
52
52
|
3. **Cooperative tool-call timeout** — `ToolDefinition.timeoutMs` declares the budget, `execute(args, exec)` forwards `exec.signal` to fetch; the timeout policy is enforced by the pipeline, never exposed to the model;
|
|
53
53
|
4. **Model-facing simplicity** — render emits compact text (`title:` header + body); the model consumes it directly with no JSON parsing. Defaults are the most token-efficient; structured output is opt-in;
|
|
@@ -181,6 +181,27 @@ Override via the profile's `cordis.patch.yml` (defaults in the plugin's own `cor
|
|
|
181
181
|
|
|
182
182
|
Values are coerced and clamped to sane ranges at load — quoted numbers in YAML work, garbage falls back to defaults.
|
|
183
183
|
|
|
184
|
+
#### Explicit direct origins (v1.9.0)
|
|
185
|
+
|
|
186
|
+
`directFetchOrigins` defaults to `[]`. For a trusted local reverse proxy (for example, a GitHub hostname mapped by your hosts file), explicitly list the origins in the plugin's **profile configuration**, then reload the plugin/profile:
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
- id: dsh-read-url
|
|
190
|
+
config:
|
|
191
|
+
directFetchOrigins:
|
|
192
|
+
- https://github.com
|
|
193
|
+
- https://api.github.com
|
|
194
|
+
- https://raw.githubusercontent.com
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Listed origins use direct fetch immediately, bypassing the host web provider **and its SSRF checks**. Add only origins you trust; this also permits their DNS/hosts mappings to local or private addresses. The option is not a tool argument. Unlisted initial URLs keep the existing provider selection and fallback behavior; a provider refusal does not enable this exception automatically.
|
|
198
|
+
|
|
199
|
+
Matching is exact after URL normalization: scheme, hostname and port must match; subdomains are separate. At most 32 entries of 2,048 characters each are accepted. Only an HTTP(S) origin with an optional trailing `/` is valid: credentials, paths, queries, fragments, wildcards and ambiguous syntax cause a configuration error. Unlike numeric options, invalid entries do not fall back to defaults.
|
|
200
|
+
|
|
201
|
+
Once a request selects explicit direct retrieval, every subsequent HTTP redirect, meta-refresh, pagination request and descendant crawl stays within the list. Redirects handled internally by the provider remain on the provider path. Direct HTTP requests follow at most 5 redirects per attempt and stop loops early. An outside target is stopped before requesting it; refresh/pagination blocked by this policy retain the readable static result with a hint. Explicit direct reads use neither the plugin's curl proxy race nor SPA rendering, preventing those mechanisms from widening the exception. When `spaRender` is enabled and `read_url` or `read_url_links` would otherwise render a script-only page, they return a static-reading hint. Cancellation, response-size limits and one bounded Retry-After retry still apply. All four tools share the policy; success and failure caches are isolated by the complete normalized list, including after changing or clearing it.
|
|
202
|
+
|
|
203
|
+
Controlled HTTP servers and provider fixtures cover default refusals, explicit routing, redirects, pagination, all four tools, cache isolation, cancellation, timeouts and response-size limits.
|
|
204
|
+
|
|
184
205
|
### Output (compact)
|
|
185
206
|
|
|
186
207
|
```json
|
|
@@ -287,6 +308,13 @@ Plus a **15-item DSH acceptance round** (a real agent driving every read_url too
|
|
|
287
308
|
- SPA cancellation closes the active page; plugin disposal awaits browser shutdown. Valid success-cache entries take precedence over concurrent failures. Crawl filtering uses path boundaries. Charset and Markdown attributes use bounded parsing.
|
|
288
309
|
- Fixed model input cost remains **1,159 description characters / 1,876 schema characters**, within the 1,250 / 2,000 budgets.
|
|
289
310
|
|
|
311
|
+
### v1.9.0 validation (2026-09-19)
|
|
312
|
+
|
|
313
|
+
- **322 zero-dependency unit assertions, 15 real Chromium SPA assertions and 53 adversarial probes** passed. The suite includes 22 direct-origin policy checks and 3 SPA restriction checks.
|
|
314
|
+
- The 152-site sweep returned **105 OK / 23 THIN+EMPTY / 24 ERR / 0 THREW**, with no noise in OK results.
|
|
315
|
+
- Vue, React and Kugou's known NOISE cases matched v1.8.1 exactly using identical HTML and optional dependencies, in both text/Markdown and full/800-character outputs. Errors were network, anti-bot, invalid-address or expected content-type boundaries.
|
|
316
|
+
- All four tools enforce the explicit origin policy, including redirect/crawl boundaries, cache isolation and visible static-reading hints. Model input remains **1,159 description characters / 1,876 schema characters**, within the 1,250 / 2,000 budgets.
|
|
317
|
+
|
|
290
318
|
## Roadmap
|
|
291
319
|
|
|
292
320
|
- [x] Single-page continuation (`offset` parameter)
|
package/README.zh.md
CHANGED
|
@@ -47,7 +47,7 @@ DSH 的 Agent 能搜索(返回链接和片段),但缺"把 URL 读成干净
|
|
|
47
47
|
|
|
48
48
|
按官方文档实现(`docs/capability-seams.md`、`docs/cordis-primer.md`、`docs/tool-execution-pipeline.md`):
|
|
49
49
|
|
|
50
|
-
1. **网络访问走 `ctx.web` 能力缝**——读取、跳转与爬取使用 `ctx.web.fetch({ url }, signal)` 及解码后的 `body.kind/content`;服务或提供方不可用时回退直连。提供方 HTTP
|
|
50
|
+
1. **网络访问走 `ctx.web` 能力缝**——读取、跳转与爬取使用 `ctx.web.fetch({ url }, signal)` 及解码后的 `body.kind/content`;服务或提供方不可用时回退直连。提供方 HTTP 错误与策略拒绝保留为错误。可选的 profile 配置 `directFetchOrigins` 为可信来源选择显式直连;
|
|
51
51
|
2. **可逆副作用**——会话缓存注册在 `ctx.effect` 下,插件卸载即自动清理(时间可组合性);
|
|
52
52
|
3. **协作式工具调用超时**——`ToolDefinition.timeoutMs` 声明预算,`execute(args, exec)` 把 `exec.signal` 转发给 fetch,超时策略由管线强制执行,不把超时暴露给模型;
|
|
53
53
|
4. **模型视角精简**——render 输出紧凑文本(`title:` 头部 + 正文),模型直接消费,无需解析 JSON;默认参数最省 token,结构化能力按需开启;
|
|
@@ -181,6 +181,27 @@ chars 800+800/12398 · cached
|
|
|
181
181
|
|
|
182
182
|
配置在加载时统一强转数字并钳制到合理范围——YAML 里加引号的数字也能用,非法值回退默认。
|
|
183
183
|
|
|
184
|
+
#### 显式直连来源(v1.9.0)
|
|
185
|
+
|
|
186
|
+
`directFetchOrigins` 默认为 `[]`。对于可信的本地反向代理(例如通过 hosts 文件映射 GitHub 域名),在插件的 **profile 配置**中显式列出来源,再重载插件/profile:
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
- id: dsh-read-url
|
|
190
|
+
config:
|
|
191
|
+
directFetchOrigins:
|
|
192
|
+
- https://github.com
|
|
193
|
+
- https://api.github.com
|
|
194
|
+
- https://raw.githubusercontent.com
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
命中的来源立即直连,跳过宿主 web provider **及其 SSRF 检查**。仅添加你信任的来源;其 DNS/hosts 映射到本机或私有地址也会被允许。此选项不开放为工具参数。未列出的初始 URL 保留既有 provider 选择与回退行为;provider 拒绝不会自动启用此例外。
|
|
198
|
+
|
|
199
|
+
按 URL 规范化后的协议、域名、端口精确匹配,子域名需单独配置。最多 32 项,每项不超过 2,048 字符。仅接受 HTTP(S) origin,可带一个结尾 `/`;凭据、路径、查询、片段、通配符和歧义写法都会触发配置错误。此选项与数字选项不同,非法项不会静默回退默认值。
|
|
200
|
+
|
|
201
|
+
请求选用显式直连后,后续 HTTP 重定向、meta-refresh、分页及爬取子页面均限制在列表内;provider 内部处理的跳转仍属于 provider 路径。直连每次尝试最多跟随 5 次 HTTP 重定向,遇循环提前停止。越界目标在发请求前停止;被此策略阻止的刷新跳转或分页保留可读的静态结果并附提示。显式直连不使用插件的 curl 代理竞速或 SPA 渲染,防止它们扩大例外范围;启用 `spaRender` 且 `read_url` 或 `read_url_links` 原本需要渲染脚本空壳时,会提示仅能静态读取。取消、正文大小限制及一次有界 Retry-After 重试继续生效。四个工具统一遵守此策略;成功与失败缓存按完整规范化列表隔离,修改或清空列表后不会误用旧策略的结果。
|
|
202
|
+
|
|
203
|
+
受控 HTTP 服务与 provider 桩覆盖默认拒绝、显式直连、重定向、分页、四工具、缓存隔离、取消、超时及正文上限。
|
|
204
|
+
|
|
184
205
|
### 输出结构(紧凑)
|
|
185
206
|
|
|
186
207
|
```json
|
|
@@ -287,6 +308,13 @@ v1.4.0 复验(2026-08-28,代理环境):python-docs stdtypes.html 锚点
|
|
|
287
308
|
- SPA 取消关闭当前页面,插件卸载等待浏览器关闭;有效成功缓存优先于并发失败;爬取过滤遵循路径边界;编码与 Markdown 属性使用有界解析。
|
|
288
309
|
- 模型固定输入成本仍为 **description 1,159 字符 / schema 1,876 字符**,低于 1,250 / 2,000 预算。
|
|
289
310
|
|
|
311
|
+
### v1.9.0 验证(2026-09-19)
|
|
312
|
+
|
|
313
|
+
- **322 个零依赖单元断言、15 个真实 Chromium SPA 断言、53 项对抗探针**通过;包括 22 条直连来源策略检查与 3 条 SPA 限制检查。
|
|
314
|
+
- 152 站实网结果为 **105 OK / 23 THIN+EMPTY / 24 ERR / 0 THREW**,OK 站零噪声。
|
|
315
|
+
- Vue、React、酷狗三个已知 NOISE 站采用同份 HTML 与相同可选依赖对照 v1.8.1,text/Markdown 全文及 800 字符窗口逐字一致;ERR 均为网络、反爬、无效地址或预期不支持的类型。
|
|
316
|
+
- 四个工具统一遵守显式来源策略,覆盖跳转与爬取边界、缓存隔离及可见的静态读取提示。模型固定输入成本为 **description 1,159 字符 / schema 1,876 字符**,低于 1,250 / 2,000 预算。
|
|
317
|
+
|
|
290
318
|
## Roadmap
|
|
291
319
|
|
|
292
320
|
- [x] 单页多段续读(`offset` 参数)
|
package/cordis.patch.yml
CHANGED
package/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Zero external dependencies (Node 20+ built-ins only).
|
|
3
3
|
// Aligned with DSH architecture:
|
|
4
4
|
// - all network access goes through the ctx.web capability seam (official
|
|
5
|
-
// docs/capability-seams.md),
|
|
5
|
+
// docs/capability-seams.md), except explicit directFetchOrigins; falls
|
|
6
|
+
// back to global fetch when absent
|
|
6
7
|
// - cache is registered under ctx.effect, so unload fully reverts it
|
|
7
8
|
// (temporal composability, docs/cordis-primer.md)
|
|
8
9
|
// - cooperative tool-call timeout via ToolDefinition.timeoutMs + exec.signal
|
|
@@ -33,7 +34,8 @@ const DEFAULTS = {
|
|
|
33
34
|
cacheMax: 32,
|
|
34
35
|
spaRender: true,
|
|
35
36
|
paginate: true,
|
|
36
|
-
paginateMax: 3,
|
|
37
|
+
paginateMax: 3,
|
|
38
|
+
directFetchOrigins: [],
|
|
37
39
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -51,7 +53,52 @@ function cacheStore(key, full, cacheMax) {
|
|
|
51
53
|
cache.set(key, { time: Date.now(), full })
|
|
52
54
|
failCache.delete(key)
|
|
53
55
|
}
|
|
54
|
-
const decoders = new Map()
|
|
56
|
+
const decoders = new Map()
|
|
57
|
+
|
|
58
|
+
// Owner configuration only: tool arguments cannot widen this trust boundary.
|
|
59
|
+
// Preserve scheme/port, reject ambiguous origin syntax, and fail closed on
|
|
60
|
+
// invalid configuration instead of silently accepting a partial allowlist.
|
|
61
|
+
function directOrigins(values = []) {
|
|
62
|
+
const invalid = () => new Error('Invalid directFetchOrigins: expected at most 32 exact http(s) origins without credentials, paths, queries, fragments or wildcards')
|
|
63
|
+
if (!Array.isArray(values) || values.length > 32) throw invalid()
|
|
64
|
+
const origins = new Set()
|
|
65
|
+
for (const value of values) {
|
|
66
|
+
if (typeof value !== 'string' || value.length > 2048) throw invalid()
|
|
67
|
+
const raw = value.trim()
|
|
68
|
+
let u
|
|
69
|
+
try { u = new URL(raw) } catch { throw invalid() }
|
|
70
|
+
if (!raw.toLowerCase().startsWith(`${u.protocol}//`) ||
|
|
71
|
+
!['http:', 'https:'].includes(u.protocol) || u.username || u.password ||
|
|
72
|
+
u.pathname !== '/' || raw.includes('?') || raw.includes('#') ||
|
|
73
|
+
raw.includes('*') || raw.includes('\\') || raw.includes('@') ||
|
|
74
|
+
[...raw].some(c => c.charCodeAt(0) <= 32 || c.charCodeAt(0) === 127)) throw invalid()
|
|
75
|
+
// Do not accept URL parser repairs such as /a/.. or percent escapes in
|
|
76
|
+
// hostnames. Only an authority and one optional trailing slash are valid.
|
|
77
|
+
const authority = raw.slice(raw.indexOf('://') + 3)
|
|
78
|
+
if (authority.includes('%') || authority.replace(/\/$/, '').includes('/')) throw invalid()
|
|
79
|
+
origins.add(u.origin)
|
|
80
|
+
}
|
|
81
|
+
return [...origins].sort()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createRoute(cfg) {
|
|
85
|
+
const origins = directOrigins(cfg.directFetchOrigins)
|
|
86
|
+
return { origins: new Set(origins), key: JSON.stringify(origins), restricted: false, hint: '' }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function matchesDirectOrigin(url, origins) {
|
|
90
|
+
try { return origins.has(new URL(url).origin) } catch { return false }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function allowedDirectUrl(url, origins) {
|
|
94
|
+
try {
|
|
95
|
+
const u = new URL(url)
|
|
96
|
+
return !u.username && !u.password && origins.has(u.origin) && ['http:', 'https:'].includes(u.protocol)
|
|
97
|
+
} catch { return false }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DIRECT_BOUNDARY_ERROR = 'Direct fetch stopped: target is outside directFetchOrigins or contains credentials'
|
|
101
|
+
const DIRECT_SPA_HINT = '已配置直连仅支持静态读取,未启用浏览器渲染'
|
|
55
102
|
|
|
56
103
|
function getDecoder(enc) {
|
|
57
104
|
let d = decoders.get(enc)
|
|
@@ -124,13 +171,31 @@ function retryAfterMs(res) {
|
|
|
124
171
|
}
|
|
125
172
|
|
|
126
173
|
// Single fetch attempt. Never throws; maps abort/timeout/network to { error }.
|
|
127
|
-
async function directFetchOnce(url, signal, cfg) {
|
|
128
|
-
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
174
|
+
async function directFetchOnce(url, signal, cfg, origins) {
|
|
175
|
+
try {
|
|
176
|
+
let currentUrl = url
|
|
177
|
+
let res
|
|
178
|
+
const seen = new Set()
|
|
179
|
+
for (let hops = 0; ; hops++) {
|
|
180
|
+
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
181
|
+
if (origins && !allowedDirectUrl(currentUrl, origins)) return { error: DIRECT_BOUNDARY_ERROR }
|
|
182
|
+
if (origins) {
|
|
183
|
+
const normalized = normalizeUrl(currentUrl)
|
|
184
|
+
if (seen.has(normalized)) return { error: 'Direct fetch redirect loop' }
|
|
185
|
+
seen.add(normalized)
|
|
186
|
+
}
|
|
187
|
+
res = await fetch(currentUrl, {
|
|
188
|
+
redirect: origins ? 'manual' : 'follow',
|
|
189
|
+
signal,
|
|
190
|
+
headers: { 'user-agent': cfg.userAgent, accept: 'text/html,application/xhtml+xml,*/*;q=0.8' },
|
|
191
|
+
})
|
|
192
|
+
if (!origins || ![301, 302, 303, 307, 308].includes(res.status)) break
|
|
193
|
+
if (res.body) res.body.cancel().catch(() => {})
|
|
194
|
+
const location = res.headers.get('location')
|
|
195
|
+
if (!location) return { error: `HTTP ${res.status} without Location` }
|
|
196
|
+
if (hops >= 5) return { error: 'Direct fetch exceeded 5 redirects' }
|
|
197
|
+
currentUrl = new URL(location, currentUrl).href
|
|
198
|
+
}
|
|
134
199
|
// Early-return paths must cancel the body stream, or the connection is
|
|
135
200
|
// held open undrained and cannot return to the keep-alive pool.
|
|
136
201
|
const discardBody = () => { if (res.body) res.body.cancel().catch(() => {}) }
|
|
@@ -150,7 +215,7 @@ async function directFetchOnce(url, signal, cfg) {
|
|
|
150
215
|
discardBody()
|
|
151
216
|
return { error: `Unsupported content-type: ${contentType.split(';')[0]}` }
|
|
152
217
|
}
|
|
153
|
-
if (!res.body) return { buffer: Buffer.alloc(0), contentType, finalUrl: res.url }
|
|
218
|
+
if (!res.body) return { buffer: Buffer.alloc(0), contentType, finalUrl: res.url || currentUrl }
|
|
154
219
|
const chunks = []
|
|
155
220
|
let size = 0
|
|
156
221
|
for await (const chunk of res.body) {
|
|
@@ -163,7 +228,7 @@ async function directFetchOnce(url, signal, cfg) {
|
|
|
163
228
|
if (!contentType && looksBinary(buffer)) {
|
|
164
229
|
return { error: 'Unsupported content-type (no header, binary body)' }
|
|
165
230
|
}
|
|
166
|
-
return { buffer, contentType, finalUrl: res.url }
|
|
231
|
+
return { buffer, contentType, finalUrl: res.url || currentUrl }
|
|
167
232
|
} catch (e) {
|
|
168
233
|
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
169
234
|
return { error: `Timeout after ${cfg.timeoutMs}ms or cancelled` }
|
|
@@ -180,15 +245,15 @@ async function directFetchOnce(url, signal, cfg) {
|
|
|
180
245
|
// Exported for tests: the double-throttle path must always produce an { error }
|
|
181
246
|
// shape — a second `{ retryAfterMs }` would otherwise leak past the race
|
|
182
247
|
// filter into decodeBuffer as a bufferless "success" (bare TypeError there).
|
|
183
|
-
export async function directFetch(url, signal, cfg) {
|
|
184
|
-
const first = await directFetchOnce(url, signal, cfg)
|
|
248
|
+
export async function directFetch(url, signal, cfg, origins) {
|
|
249
|
+
const first = await directFetchOnce(url, signal, cfg, origins)
|
|
185
250
|
if (first && first.retryAfterMs !== undefined && !(signal && signal.aborted)) {
|
|
186
251
|
await new Promise((resolve) => {
|
|
187
252
|
const t = setTimeout(resolve, first.retryAfterMs)
|
|
188
253
|
if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
|
|
189
254
|
})
|
|
190
255
|
if (signal && signal.aborted) return { error: 'cancelled' }
|
|
191
|
-
const second = await directFetchOnce(url, signal, cfg)
|
|
256
|
+
const second = await directFetchOnce(url, signal, cfg, origins)
|
|
192
257
|
// The retry budget is spent — report the rate limit instead of returning
|
|
193
258
|
// a shapeless { retryAfterMs } (no buffer, no error) to the caller.
|
|
194
259
|
if (second && second.retryAfterMs !== undefined) {
|
|
@@ -309,6 +374,27 @@ async function fetchViaWebSeam(ctx, url, externalSignal, cfg) {
|
|
|
309
374
|
return { error: `Web provider failed: ${String((e && e.message) || e).slice(0, 160)}` }
|
|
310
375
|
}
|
|
311
376
|
}
|
|
377
|
+
|
|
378
|
+
// Every fetch in a tool call passes here. Once a chain enters an explicit
|
|
379
|
+
// direct origin it stays restricted, including meta refresh and pagination.
|
|
380
|
+
// Never send an exception through curl -L or a browser: both may make further
|
|
381
|
+
// requests outside this origin list. Unlisted initial URLs retain host policy.
|
|
382
|
+
async function fetchResource(ctx, url, externalSignal, cfg, route) {
|
|
383
|
+
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
384
|
+
if (route.restricted || matchesDirectOrigin(url, route.origins)) {
|
|
385
|
+
route.restricted = true
|
|
386
|
+
if (!allowedDirectUrl(url, route.origins)) {
|
|
387
|
+
route.hint = DIRECT_BOUNDARY_ERROR
|
|
388
|
+
return { error: DIRECT_BOUNDARY_ERROR }
|
|
389
|
+
}
|
|
390
|
+
const timeout = AbortSignal.timeout(cfg.timeoutMs)
|
|
391
|
+
const signal = externalSignal ? AbortSignal.any([externalSignal, timeout]) : timeout
|
|
392
|
+
const page = await directFetch(url, signal, cfg, route.origins)
|
|
393
|
+
if (page.error) route.hint = page.error
|
|
394
|
+
return page
|
|
395
|
+
}
|
|
396
|
+
return await fetchViaWebSeam(ctx, url, externalSignal, cfg) || await fetchPage(url, externalSignal, cfg)
|
|
397
|
+
}
|
|
312
398
|
|
|
313
399
|
// Decode common HTML entities in already-stripped text. Runs AFTER tag
|
|
314
400
|
// stripping so escaped tags stay visible to the stripper and only remaining
|
|
@@ -1567,7 +1653,7 @@ export function metaRefreshTarget(html, baseUrl) {
|
|
|
1567
1653
|
// cache key is the ORIGINAL url, and a shell re-request should re-follow
|
|
1568
1654
|
// rather than serve a foreign page.
|
|
1569
1655
|
const MAX_META_REFRESH_HOPS = 3
|
|
1570
|
-
async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx) {
|
|
1656
|
+
async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx, route) {
|
|
1571
1657
|
let cur = html
|
|
1572
1658
|
let curUrl = finalUrl
|
|
1573
1659
|
let curCharset = '' // set only when a hop actually happened
|
|
@@ -1586,7 +1672,7 @@ async function followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx) {
|
|
|
1586
1672
|
const norm = normalizeUrl(target)
|
|
1587
1673
|
if (!norm || seen.has(norm)) break
|
|
1588
1674
|
seen.add(norm)
|
|
1589
|
-
const page = await
|
|
1675
|
+
const page = await fetchResource(ctx, target, hopSignal, cfg, route)
|
|
1590
1676
|
if (page.error) break
|
|
1591
1677
|
const ct = page.kind === 'html' ? 'text/html' : (page.contentType || '').split(';')[0].toLowerCase().trim()
|
|
1592
1678
|
if (!/html|xhtml/.test(ct)) break
|
|
@@ -1603,6 +1689,8 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1603
1689
|
const url = String((args && args.url) || '').trim()
|
|
1604
1690
|
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
1605
1691
|
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
1692
|
+
let route
|
|
1693
|
+
try { route = createRoute(cfg) } catch (e) { return { error: e.message } }
|
|
1606
1694
|
const maxChars = Math.max(500, Math.min(20000, Number(args.maxChars) || cfg.maxChars))
|
|
1607
1695
|
const mode = args.mode === 'markdown' ? 'markdown' : 'text'
|
|
1608
1696
|
const offset = Math.max(0, Number(args.offset) || 0)
|
|
@@ -1632,7 +1720,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1632
1720
|
} catch {
|
|
1633
1721
|
/* keep bare url */
|
|
1634
1722
|
}
|
|
1635
|
-
|
|
1723
|
+
// Include the complete policy: an unlisted entry may reach a listed origin
|
|
1724
|
+
// through refresh/pagination, and must not reuse content after revocation.
|
|
1725
|
+
const cacheKey = `${route.key}|${cacheUrl}|${mode}|${args.includeLinks === true ? 'links' : 'no-links'}`
|
|
1636
1726
|
const hit = cache.get(cacheKey)
|
|
1637
1727
|
if (hit && Date.now() - hit.time < cfg.cacheTtlMs) {
|
|
1638
1728
|
// Valid content takes precedence over a concurrent fetch failure.
|
|
@@ -1700,9 +1790,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1700
1790
|
}
|
|
1701
1791
|
}
|
|
1702
1792
|
const dispatch = async () => {
|
|
1703
|
-
const
|
|
1704
|
-
const page = viaSeam || await fetchPage(url, externalSignal, cfg)
|
|
1793
|
+
const page = await fetchResource(ctx, url, externalSignal, cfg, route)
|
|
1705
1794
|
if (page.error) return { error: page.error }
|
|
1795
|
+
const viaSeam = typeof page.html === 'string'
|
|
1706
1796
|
const ct = viaSeam ? (page.kind === 'text' ? 'text/plain' : 'text/html') : (page.contentType || '').split(';')[0].toLowerCase().trim()
|
|
1707
1797
|
const decoded = viaSeam ? { text: page.html, charset: 'provider-decoded' } : decodeBuffer(page.buffer, page.contentType)
|
|
1708
1798
|
const meta = { finalUrl: page.finalUrl || url, sourceTruncated: page.sourceTruncated === true }
|
|
@@ -1782,7 +1872,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1782
1872
|
// follow immediately, BEFORE extraction and SPA rendering — a shell that
|
|
1783
1873
|
// redirects needs no headless round-trip, and extracting the stub is wasted
|
|
1784
1874
|
// work. Fails open on any fetch problem; the follow is hop-bounded.
|
|
1785
|
-
const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx)
|
|
1875
|
+
const followed = await followMetaRefresh(html, finalUrl, externalSignal, cfg, ctx, route)
|
|
1786
1876
|
if (externalSignal && externalSignal.aborted) return { error: 'cancelled' }
|
|
1787
1877
|
html = followed.html
|
|
1788
1878
|
finalUrl = followed.finalUrl
|
|
@@ -1826,7 +1916,10 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1826
1916
|
let renderedHtml = null
|
|
1827
1917
|
const needsRender = !extracted.text || extracted.text.length < 200
|
|
1828
1918
|
const scriptShell = !extracted.text && /<script[\s>]/i.test(html)
|
|
1829
|
-
if (cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
|
|
1919
|
+
if (route.restricted && cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
|
|
1920
|
+
spaHint = [spaHint, DIRECT_SPA_HINT].filter(Boolean).join(';')
|
|
1921
|
+
}
|
|
1922
|
+
if (!route.restricted && cfg.spaRender !== false && needsRender && (looksLikeSpa(html) || scriptShell)) {
|
|
1830
1923
|
const rr = await renderPage(finalUrl || url, externalSignal)
|
|
1831
1924
|
if (rr.html) {
|
|
1832
1925
|
renderedHtml = rr.html
|
|
@@ -1882,7 +1975,7 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1882
1975
|
const nu = normalizeUrl(nextUrl)
|
|
1883
1976
|
if (!nu || seen.has(nu)) break
|
|
1884
1977
|
seen.add(nu)
|
|
1885
|
-
const page = await
|
|
1978
|
+
const page = await fetchResource(ctx, nextUrl, externalSignal, cfg, route)
|
|
1886
1979
|
if (page.error) break
|
|
1887
1980
|
const pageFinal = normalizeUrl(page.finalUrl || nextUrl)
|
|
1888
1981
|
if (!pageFinal || !sameHost(pageFinal, startHost) || (pageFinal !== nu && seen.has(pageFinal))) break
|
|
@@ -1898,7 +1991,9 @@ export async function readUrl(args, ctx, externalSignal, cfg = DEFAULTS) {
|
|
|
1898
1991
|
nextUrl = findNextLink(nextHtml, page.finalUrl || nextUrl)
|
|
1899
1992
|
}
|
|
1900
1993
|
|
|
1901
|
-
|
|
1994
|
+
if (route.hint) spaHint = [spaHint, route.hint].filter(Boolean).join(';')
|
|
1995
|
+
|
|
1996
|
+
// A near-empty body (login wall, anti-bot shell) with no render hint yet
|
|
1902
1997
|
// still looks like "the page content" to the model — one constant-cost line
|
|
1903
1998
|
// tells it not to keep offset-reading a 40-char page.
|
|
1904
1999
|
if (!spaHint && extracted.text && extracted.text.length < 60) {
|
|
@@ -1992,8 +2087,9 @@ function renderLinks(value) {
|
|
|
1992
2087
|
if (typeof value === 'string') return value
|
|
1993
2088
|
const r = value || {}
|
|
1994
2089
|
if (r.error) return `Error: ${r.error}`
|
|
1995
|
-
if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}`
|
|
1996
|
-
const lines = [`${r.count} link(s) on ${r.url}:`, UNTRUSTED_NOTICE]
|
|
2090
|
+
if (!Array.isArray(r.links) || r.links.length === 0) return `No links found on ${r.url}${r.spaHint ? `\n提示: ${r.spaHint}` : ''}`
|
|
2091
|
+
const lines = [`${r.count} link(s) on ${r.url}:`, UNTRUSTED_NOTICE]
|
|
2092
|
+
if (r.spaHint) lines.push(`提示: ${r.spaHint}`)
|
|
1997
2093
|
for (const l of r.links) lines.push(`- ${l.title || l.url} — ${l.url}`)
|
|
1998
2094
|
return lines.join('\n')
|
|
1999
2095
|
}
|
|
@@ -2033,14 +2129,15 @@ function readLinksTool(ctx, cfg) {
|
|
|
2033
2129
|
async execute(args, exec) {
|
|
2034
2130
|
const url = String((args && args.url) || '').trim()
|
|
2035
2131
|
if (!/^https?:\/\//i.test(url)) return { error: 'Only http/https URLs are supported' }
|
|
2036
|
-
const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
|
|
2037
|
-
const
|
|
2132
|
+
const limit = Math.max(1, Math.min(50, Number(args.limit) || cfg.maxLinks))
|
|
2133
|
+
const route = createRoute(cfg)
|
|
2134
|
+
const page = await fetchResource(ctx, url, exec && exec.signal, cfg, route)
|
|
2038
2135
|
if (page.error) return { error: page.error }
|
|
2039
2136
|
let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
|
|
2040
2137
|
let finalUrl = page.finalUrl || url
|
|
2041
2138
|
// Same meta-refresh shell following as read_url: a link hop serves a
|
|
2042
2139
|
// stub whose real links live at the target. Fails open, hop-bounded.
|
|
2043
|
-
const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg, ctx)
|
|
2140
|
+
const followed = await followMetaRefresh(html, finalUrl, exec && exec.signal, cfg, ctx, route)
|
|
2044
2141
|
html = followed.html
|
|
2045
2142
|
finalUrl = followed.finalUrl
|
|
2046
2143
|
let links = extractLinks(html, limit, finalUrl)
|
|
@@ -2048,7 +2145,8 @@ function readLinksTool(ctx, cfg) {
|
|
|
2048
2145
|
// render it and re-extract when the static result looks empty. Any
|
|
2049
2146
|
// <script> counts as renderable when there are no links at all — the
|
|
2050
2147
|
// static shell may be a JS-redirect (same rationale as read_url).
|
|
2051
|
-
|
|
2148
|
+
const needsRender = links.length < 3 && cfg.spaRender !== false && (looksLikeSpa(html) || (links.length === 0 && /<script[\s>]/i.test(html)))
|
|
2149
|
+
if (!route.restricted && needsRender) {
|
|
2052
2150
|
const rr = await renderPage(finalUrl || url, exec && exec.signal)
|
|
2053
2151
|
// A challenge interstitial's links are the challenge's own scripts —
|
|
2054
2152
|
// never an improvement over whatever the static HTML offered.
|
|
@@ -2060,7 +2158,8 @@ function readLinksTool(ctx, cfg) {
|
|
|
2060
2158
|
}
|
|
2061
2159
|
}
|
|
2062
2160
|
}
|
|
2063
|
-
|
|
2161
|
+
const spaHint = [route.hint, route.restricted && needsRender ? DIRECT_SPA_HINT : ''].filter(Boolean).join(';')
|
|
2162
|
+
return { url: finalUrl, count: links.length, links, ...(spaHint ? { spaHint } : {}) }
|
|
2064
2163
|
},
|
|
2065
2164
|
}
|
|
2066
2165
|
}
|
|
@@ -2091,7 +2190,8 @@ function renderBatch(value) {
|
|
|
2091
2190
|
continue
|
|
2092
2191
|
}
|
|
2093
2192
|
const head = p.title || p.url
|
|
2094
|
-
lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
|
|
2193
|
+
lines.push('', `--- ${head} (${p.chars} 字符${p.cached ? ' · cached' : ''}) ---`, p.text || '(无可读内容)')
|
|
2194
|
+
if (p.spaHint) lines.push(`提示: ${p.spaHint}`)
|
|
2095
2195
|
if (Array.isArray(p.links) && p.links.length) {
|
|
2096
2196
|
const ls = p.links.map((l) => l.title + ' — ' + l.url)
|
|
2097
2197
|
lines.push(`links: ${ls.slice(0, 6).join(' | ')}${ls.length > 6 ? ` …共${ls.length}个` : ''}`)
|
|
@@ -2181,7 +2281,8 @@ function readUrlBatchTool(ctx, cfg) {
|
|
|
2181
2281
|
cached: p.cached === true,
|
|
2182
2282
|
text: p.text,
|
|
2183
2283
|
}
|
|
2184
|
-
if (Array.isArray(p.links) && p.links.length) out.links = p.links
|
|
2284
|
+
if (Array.isArray(p.links) && p.links.length) out.links = p.links
|
|
2285
|
+
if (p.spaHint) out.spaHint = p.spaHint
|
|
2185
2286
|
return out
|
|
2186
2287
|
}),
|
|
2187
2288
|
}
|
|
@@ -2226,7 +2327,8 @@ function isNoiseUrl(url) {
|
|
|
2226
2327
|
// failure is recorded and does not abort the crawl. No SPA rendering here —
|
|
2227
2328
|
// that is read_url's job; crawling favors speed and breadth.
|
|
2228
2329
|
async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
2229
|
-
const { maxPages, maxDepth, includeContent, perMax } = opts
|
|
2330
|
+
const { maxPages, maxDepth, includeContent, perMax } = opts
|
|
2331
|
+
const policy = createRoute(cfg)
|
|
2230
2332
|
let host = hostOf(entryUrl)
|
|
2231
2333
|
const visited = new Set()
|
|
2232
2334
|
const resolved = new Set()
|
|
@@ -2240,12 +2342,13 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2240
2342
|
// Never overshoot the page budget within a batch round.
|
|
2241
2343
|
const batch = queue.splice(0, Math.min(2, maxPages - attempted, queue.length))
|
|
2242
2344
|
const results = await Promise.all(
|
|
2243
|
-
batch.map(async ({ url, depth }) => {
|
|
2345
|
+
batch.map(async ({ url, depth, restricted = false }) => {
|
|
2244
2346
|
const norm = normalizeUrl(url)
|
|
2245
2347
|
if (!norm || visited.has(norm)) return null
|
|
2246
2348
|
visited.add(norm)
|
|
2247
2349
|
attempted++
|
|
2248
|
-
const
|
|
2350
|
+
const route = { ...policy, restricted }
|
|
2351
|
+
const page = await fetchResource(ctx, url, externalSignal, cfg, route)
|
|
2249
2352
|
if (page.error) return { url, depth, error: page.error }
|
|
2250
2353
|
let html = typeof page.html === 'string' ? page.html : decodeBuffer(page.buffer, page.contentType).text
|
|
2251
2354
|
let pageUrl = page.finalUrl || url
|
|
@@ -2256,7 +2359,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2256
2359
|
// be crawled AS the page (title-less hop text pollutes results and the
|
|
2257
2360
|
// real content is never reached). Fails open — fetch problems keep the
|
|
2258
2361
|
// stub's own HTML.
|
|
2259
|
-
const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx)
|
|
2362
|
+
const followed = await followMetaRefresh(html, pageUrl, externalSignal, cfg, ctx, route)
|
|
2260
2363
|
html = followed.html
|
|
2261
2364
|
pageUrl = followed.finalUrl
|
|
2262
2365
|
if (depth === 0) host = hostOf(pageUrl)
|
|
@@ -2271,7 +2374,9 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2271
2374
|
title: ex.title || '',
|
|
2272
2375
|
chars: ex.text.length,
|
|
2273
2376
|
text: includeContent ? smartTruncate(ex.text, perMax).text : undefined,
|
|
2274
|
-
links,
|
|
2377
|
+
links,
|
|
2378
|
+
route,
|
|
2379
|
+
...(route.hint ? { spaHint: route.hint } : {}),
|
|
2275
2380
|
}
|
|
2276
2381
|
}),
|
|
2277
2382
|
)
|
|
@@ -2285,7 +2390,8 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2285
2390
|
if (!finalNorm || resolved.has(finalNorm)) continue
|
|
2286
2391
|
resolved.add(finalNorm)
|
|
2287
2392
|
visited.add(finalNorm)
|
|
2288
|
-
|
|
2393
|
+
const { route, ...publicPage } = r
|
|
2394
|
+
pages.push(publicPage)
|
|
2289
2395
|
if (r.depth + 1 <= maxDepth) {
|
|
2290
2396
|
// Queue cap: a huge site (millions of links) would otherwise grow the
|
|
2291
2397
|
// queue unboundedly while maxPages bounds only what we *process*.
|
|
@@ -2293,7 +2399,7 @@ async function crawlSite(entryUrl, cfg, opts, externalSignal, ctx) {
|
|
|
2293
2399
|
for (const l of r.links) {
|
|
2294
2400
|
if (!sameHost(l.url, host) || isNoiseUrl(l.url)) continue
|
|
2295
2401
|
const n = normalizeUrl(l.url)
|
|
2296
|
-
if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1 })
|
|
2402
|
+
if (n && !visited.has(n)) queue.push({ url: l.url, depth: r.depth + 1, restricted: route.restricted })
|
|
2297
2403
|
}
|
|
2298
2404
|
}
|
|
2299
2405
|
}
|
|
@@ -2309,7 +2415,8 @@ function renderSite(value) {
|
|
|
2309
2415
|
for (const p of v.pages) {
|
|
2310
2416
|
const indent = ' '.repeat(p.depth)
|
|
2311
2417
|
const head = p.title || p.url
|
|
2312
|
-
lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
|
|
2418
|
+
lines.push(`${indent}[${p.depth}] ${head} (${p.chars} 字符) ${p.url}`)
|
|
2419
|
+
if (p.spaHint) lines.push(`${indent}提示: ${p.spaHint}`)
|
|
2313
2420
|
if (p.text) lines.push(`${indent} ${p.text.slice(0, 80)}`)
|
|
2314
2421
|
}
|
|
2315
2422
|
for (const f of v.failures) lines.push(`[失败] ${f.url} — ${f.error}`)
|
|
@@ -2376,9 +2483,10 @@ function readUrlSiteTool(ctx, cfg) {
|
|
|
2376
2483
|
succeeded: pages.length,
|
|
2377
2484
|
failed: failures.length,
|
|
2378
2485
|
pages: pages.map((p) => {
|
|
2379
|
-
const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
|
|
2380
|
-
if (p.text) o.text = p.text
|
|
2381
|
-
|
|
2486
|
+
const o = { url: p.url, depth: p.depth, title: p.title || '', chars: p.chars }
|
|
2487
|
+
if (p.text) o.text = p.text
|
|
2488
|
+
if (p.spaHint) o.spaHint = p.spaHint
|
|
2489
|
+
return o
|
|
2382
2490
|
}),
|
|
2383
2491
|
failures,
|
|
2384
2492
|
}
|
|
@@ -2405,7 +2513,8 @@ export function apply(ctx, config) {
|
|
|
2405
2513
|
cfg.paginateMax = clamp(cfg.paginateMax, DEFAULTS.paginateMax, 1, 10)
|
|
2406
2514
|
if (typeof cfg.userAgent !== 'string' || !cfg.userAgent.trim()) cfg.userAgent = DEFAULTS.userAgent
|
|
2407
2515
|
if (typeof cfg.spaRender !== 'boolean') cfg.spaRender = DEFAULTS.spaRender
|
|
2408
|
-
if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
|
|
2516
|
+
if (typeof cfg.paginate !== 'boolean') cfg.paginate = DEFAULTS.paginate
|
|
2517
|
+
cfg.directFetchOrigins = directOrigins(cfg.directFetchOrigins)
|
|
2409
2518
|
|
|
2410
2519
|
// Temporal composability: unload must fully revert side effects.
|
|
2411
2520
|
ctx.effect(() => () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-read-url",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "DeepSeek Harness URL reader: fetch any webpage (HTML/JSON/RSS/Atom), auto-detect encoding (UTF-16 BOM/GBK/Big5/Shift-JIS), extract clean main content with auto-pagination, output compact text/Markdown to save tokens. Zero runtime dependencies, no API key, drop-in. Follows meta-refresh shells, reads JSON-LD metadata, honors base href.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|