@zeldrisho/pi-web-fetch 0.14.1 → 0.15.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 +21 -1
- package/README.md +3 -3
- package/package.json +5 -2
- package/src/acquisition/fetch.ts +82 -12
- package/src/acquisition/llms.ts +42 -11
- package/src/document/content.ts +4 -0
- package/src/document/focus.ts +117 -11
- package/src/document/openapi.ts +80 -0
- package/src/document/source-format.ts +180 -0
- package/src/service.ts +23 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Return minified JavaScript, JSON, and YAML responses as indented, fenced code blocks.
|
|
15
|
+
- Identify OpenAPI and Swagger JSON and YAML documents and JavaScript bundles containing literal specs as `openapi-spec`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Focus large fenced source documents using line-aligned excerpts.
|
|
20
|
+
- Shorten documentation index notices.
|
|
21
|
+
- Probe one more ancestor directory for `/llms.txt`, so locale-prefixed documentation sites resolve to their section index.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Report AWS WAF challenge responses as blocks instead of empty pages.
|
|
26
|
+
- Fetch Markdown documentation that static hosts serve as `application/octet-stream`.
|
|
27
|
+
- Fall back to a discovered documentation index when a bot challenge withholds the requested page.
|
|
28
|
+
|
|
10
29
|
## [0.14.1] - 2026-10-25
|
|
11
30
|
|
|
12
31
|
### Changed
|
|
@@ -270,7 +289,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
270
289
|
|
|
271
290
|
- Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
|
|
272
291
|
|
|
273
|
-
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.
|
|
292
|
+
[Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.15.0...HEAD
|
|
293
|
+
[0.15.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.1...pi-web-fetch-v0.15.0
|
|
274
294
|
[0.14.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.0...pi-web-fetch-v0.14.1
|
|
275
295
|
[0.14.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.13.0...pi-web-fetch-v0.14.0
|
|
276
296
|
[0.13.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.12.0...pi-web-fetch-v0.13.0
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ Use `pi config` to enable or disable the extension, and `pi list` to see configu
|
|
|
15
15
|
|
|
16
16
|
## Usage
|
|
17
17
|
|
|
18
|
-
The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted to Markdown; images are omitted. URLs with credentials, local or private network targets, unsafe redirects, downloads over 5 MiB, and unsupported content types are rejected.
|
|
18
|
+
The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markdown, plain text, JSON, and XML. HTML is converted to Markdown; images are omitted. Minified JavaScript, JSON, and YAML are returned as indented code blocks so long single-line payloads stay readable. URLs with credentials, local or private network targets, unsafe redirects, downloads over 5 MiB, and unsupported content types are rejected.
|
|
19
19
|
|
|
20
20
|
| Parameter | Description |
|
|
21
21
|
| --------- | --------------------------------------------------------------------------------------------------------- |
|
|
@@ -23,13 +23,13 @@ The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markd
|
|
|
23
23
|
| `query` | Optional; selects matching sections from a long page. Sections remain in source order. |
|
|
24
24
|
| `offset` | Optional character offset for continuing through extracted content; takes precedence over a URL fragment. |
|
|
25
25
|
|
|
26
|
-
Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue. A query selects a focused view of the fetched page; omit it to read the complete document. Pi's preview renders Markdown with the active theme and is collapsed by default (`Ctrl+O` expands it). The call preview shows the URL; the focus query and character offset are not repeated there.
|
|
26
|
+
Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue. A query selects a focused view of the fetched page; omit it to read the complete document. Large fenced source files can be focused in line-aligned excerpts; excerpts are not necessarily valid standalone source. OpenAPI and Swagger JSON or YAML documents and JavaScript bundles containing literal specs can be reported as `openapi-spec`; detection is best-effort and limited to source bodies up to 512 KiB. Pi's preview renders Markdown with the active theme and is collapsed by default (`Ctrl+O` expands it). The call preview shows the URL; the focus query and character offset are not repeated there.
|
|
27
27
|
|
|
28
28
|
For example, a URL such as `https://github.com/shadcn-ui/lint#get-started` starts at the matching heading. GitHub `blob` links and bare gists are converted to raw source URLs; repository roots use their README. GitHub directory listings are not supported.
|
|
29
29
|
|
|
30
30
|
### Page alternatives and bot challenges
|
|
31
31
|
|
|
32
|
-
The tool can discover advertised Markdown versions and `/llms.txt` indexes. When an index is discovered, its URL is shown in the result so you can follow it if the fetched page is insufficient; when the index replaces the requested page, the result explains that fallback. Anubis proof-of-work challenges are supported
|
|
32
|
+
The tool can discover advertised Markdown versions and `/llms.txt` indexes. When an index is discovered, its URL is shown in the result so you can follow it if the fetched page is insufficient; when the index replaces the requested page, the result explains that fallback. Markdown files served with a generic binary content type are read as text. Anubis proof-of-work challenges are supported, and a page withheld by an AWS WAF or Cloudflare bot challenge falls back to the site's documentation index when one exists. Other unsupported challenges and JavaScript CAPTCHAs return a blocked result. There is no browser fallback. Challenge cookies are not reused between fetches.
|
|
33
33
|
|
|
34
34
|
Pages with HTML nested beyond 512 elements are rejected.
|
|
35
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeldrisho/pi-web-fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -30,8 +30,11 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
+
"acorn": "^8.19.0",
|
|
34
|
+
"acorn-walk": "^8.3.5",
|
|
33
35
|
"defuddle": "^0.19.5",
|
|
34
|
-
"linkedom": "^0.18.13"
|
|
36
|
+
"linkedom": "^0.18.13",
|
|
37
|
+
"yaml": "^2.9.1"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
40
|
"@earendil-works/pi-coding-agent": "^1.4.0",
|
package/src/acquisition/fetch.ts
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
} from "../document/evidence.ts";
|
|
28
28
|
import { extractHtmlToMarkdown } from "../document/extract.ts";
|
|
29
29
|
import { assertConversionDepth } from "../document/complexity.ts";
|
|
30
|
+
import { formatMinifiedSource } from "../document/source-format.ts";
|
|
31
|
+
import { isOpenapiSource } from "../document/openapi.ts";
|
|
30
32
|
import {
|
|
31
33
|
requestFollowingRedirects,
|
|
32
34
|
type RedirectDependencies,
|
|
@@ -154,17 +156,47 @@ function isHtmlContentType(
|
|
|
154
156
|
return value === "text/html" || value === "application/xhtml+xml";
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Path suffixes whose content is read as plain text when a server labels it
|
|
161
|
+
* `application/octet-stream`. Static hosts commonly serve Markdown documentation
|
|
162
|
+
* with that generic binary type; restricting the exception to these suffixes keeps
|
|
163
|
+
* real binary downloads out of extraction.
|
|
164
|
+
*/
|
|
165
|
+
const TEXT_DOCUMENT_PATH_PATTERN = /\.(?:md|markdown|mdx|txt)$/i;
|
|
166
|
+
|
|
157
167
|
/** Narrows a media type to those web_fetch is willing to read. */
|
|
158
|
-
function isAllowedContentType(value: string | undefined): value is string {
|
|
159
|
-
return
|
|
160
|
-
|
|
161
|
-
);
|
|
168
|
+
function isAllowedContentType(value: string | undefined, pathname: string): value is string {
|
|
169
|
+
if (value === undefined) return false;
|
|
170
|
+
|
|
171
|
+
if (value.startsWith("text/") || ALLOWED_CONTENT_TYPES.includes(value)) return true;
|
|
172
|
+
|
|
173
|
+
return value === "application/octet-stream" && TEXT_DOCUMENT_PATH_PATTERN.test(pathname);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Identifies a page a bot-protection layer refused to serve.
|
|
178
|
+
*
|
|
179
|
+
* The response carries no page content, so callers may substitute a site index the
|
|
180
|
+
* same page would otherwise have been extracted from.
|
|
181
|
+
*/
|
|
182
|
+
export class ChallengeBlockedError extends Error {
|
|
183
|
+
constructor(message: string) {
|
|
184
|
+
super(message);
|
|
185
|
+
this.name = "ChallengeBlockedError";
|
|
186
|
+
}
|
|
162
187
|
}
|
|
163
188
|
|
|
164
189
|
/** Throws the established error for a response whose status is not successful. */
|
|
165
190
|
function assertSuccessfulResponse(response: IncomingMessage): void {
|
|
166
191
|
const status = response.statusCode ?? 0;
|
|
167
192
|
|
|
193
|
+
if (responseHeader(response, "x-amzn-waf-action")?.trim().toLowerCase() === "challenge") {
|
|
194
|
+
response.resume();
|
|
195
|
+
throw new ChallengeBlockedError(
|
|
196
|
+
`web_fetch was blocked by an AWS WAF anti-bot challenge (HTTP ${status}).`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
168
200
|
if (status >= 200 && status < 300) return;
|
|
169
201
|
|
|
170
202
|
const cloudflareChallenge =
|
|
@@ -173,7 +205,9 @@ function assertSuccessfulResponse(response: IncomingMessage): void {
|
|
|
173
205
|
response.resume();
|
|
174
206
|
|
|
175
207
|
if (cloudflareChallenge) {
|
|
176
|
-
throw new
|
|
208
|
+
throw new ChallengeBlockedError(
|
|
209
|
+
`web_fetch was blocked by Cloudflare's anti-bot challenge (HTTP ${status}).`,
|
|
210
|
+
);
|
|
177
211
|
}
|
|
178
212
|
|
|
179
213
|
const authenticationHint = [401, 403, 404].includes(status)
|
|
@@ -243,6 +277,8 @@ async function documentFromDecoded(
|
|
|
243
277
|
let extractor: CompleteDocument["extractor"] = "raw";
|
|
244
278
|
let describedBy: string | undefined = linkHints.describedBy;
|
|
245
279
|
let markdownAlternateUrl: string | undefined = linkHints.markdownAlternate;
|
|
280
|
+
let sourceLanguage: string | undefined;
|
|
281
|
+
const openapiSpec = isOpenapiSource(raw, contentType);
|
|
246
282
|
|
|
247
283
|
if (isHtmlContentType(contentType)) {
|
|
248
284
|
const extracted = await awaitWithAbort(extractHtml(raw, target.url), signal);
|
|
@@ -254,12 +290,23 @@ async function documentFromDecoded(
|
|
|
254
290
|
markdownAlternateUrl = markdownAlternateUrl ?? extracted.markdownAlternateLink;
|
|
255
291
|
} else if (contentType === "application/json") {
|
|
256
292
|
try {
|
|
257
|
-
|
|
293
|
+
const parsed: unknown = JSON.parse(raw);
|
|
294
|
+
|
|
295
|
+
markdown = `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
|
|
296
|
+
sourceLanguage = "json";
|
|
258
297
|
} catch {
|
|
259
|
-
|
|
298
|
+
// Minified or JSON-like bodies are still worth structuring even when they do
|
|
299
|
+
// not parse as strict JSON.
|
|
300
|
+
const formatted = formatMinifiedSource(raw, contentType);
|
|
301
|
+
|
|
302
|
+
markdown = formatted?.markdown ?? raw;
|
|
303
|
+
sourceLanguage = formatted?.language;
|
|
260
304
|
}
|
|
261
305
|
} else {
|
|
262
|
-
|
|
306
|
+
const formatted = formatMinifiedSource(raw, contentType);
|
|
307
|
+
|
|
308
|
+
markdown = formatted?.markdown ?? raw;
|
|
309
|
+
sourceLanguage = formatted?.language;
|
|
263
310
|
|
|
264
311
|
const markdownContent = /\bmarkdown\b|\.md(?:$|[?#])/i.test(
|
|
265
312
|
contentTypeHeader + target.url.pathname,
|
|
@@ -306,6 +353,8 @@ async function documentFromDecoded(
|
|
|
306
353
|
cachedAt: Date.now(),
|
|
307
354
|
llmsTxtDescribedBy: describedBy,
|
|
308
355
|
markdownAlternateUrl,
|
|
356
|
+
sourceLanguage,
|
|
357
|
+
openapiSpec,
|
|
309
358
|
};
|
|
310
359
|
}
|
|
311
360
|
|
|
@@ -334,7 +383,7 @@ async function documentFromResponse(
|
|
|
334
383
|
const contentTypeHeader = responseContentTypeHeader(response);
|
|
335
384
|
const contentType = normalizeContentType(contentTypeHeader);
|
|
336
385
|
|
|
337
|
-
if (!isAllowedContentType(contentType)) {
|
|
386
|
+
if (!isAllowedContentType(contentType, target.url.pathname)) {
|
|
338
387
|
response.destroy();
|
|
339
388
|
throw new Error(
|
|
340
389
|
`web_fetch does not support ${contentType ?? (contentTypeHeader || "this content type")}.`,
|
|
@@ -368,6 +417,7 @@ async function documentFromAcquisition(
|
|
|
368
417
|
dependencies: FetchRemoteDependencies,
|
|
369
418
|
allowSolve: boolean,
|
|
370
419
|
): Promise<CompleteDocument> {
|
|
420
|
+
assertSuccessfulResponse(response);
|
|
371
421
|
const status = response.statusCode ?? 0;
|
|
372
422
|
const contentTypeHeader = responseContentTypeHeader(response);
|
|
373
423
|
const contentType = normalizeContentType(contentTypeHeader);
|
|
@@ -458,6 +508,7 @@ async function fetchDocument(
|
|
|
458
508
|
dependencies: FetchRemoteDependencies,
|
|
459
509
|
cached?: CompleteDocument,
|
|
460
510
|
allowEmptyContent = false,
|
|
511
|
+
retryRepresentationMiss = true,
|
|
461
512
|
): Promise<{ document: CompleteDocument; revalidated: boolean }> {
|
|
462
513
|
assertAbsoluteHttpUrlForFetch(normalizeGitHubRawUrl(rawUrl));
|
|
463
514
|
const controller = new AbortController();
|
|
@@ -499,8 +550,10 @@ async function fetchDocument(
|
|
|
499
550
|
// Some servers route Markdown negotiation to a missing representation even
|
|
500
551
|
// though their HTML page exists (for example, Redis documentation URLs).
|
|
501
552
|
// Retry only these representation-related statuses, through the same
|
|
502
|
-
// validated redirect/network boundary, with HTML preferred.
|
|
503
|
-
|
|
553
|
+
// validated redirect/network boundary, with HTML preferred. Callers that probe
|
|
554
|
+
// for one exact representation opt out: HTML from that URL is never the
|
|
555
|
+
// representation they are looking for.
|
|
556
|
+
if (retryRepresentationMiss && [404, 406].includes(response.statusCode ?? 0)) {
|
|
504
557
|
response.resume();
|
|
505
558
|
({ target, response } = await requestFollowingRedirects(
|
|
506
559
|
target.url,
|
|
@@ -566,13 +619,30 @@ async function fetchDocument(
|
|
|
566
619
|
}
|
|
567
620
|
}
|
|
568
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Fetches a complete document for one URL.
|
|
624
|
+
*
|
|
625
|
+
* @param allowEmptyContent - Whether an empty body is acceptable
|
|
626
|
+
* @param retryRepresentationMiss - Whether a 404/406 may be retried as HTML. Probe
|
|
627
|
+
* callers that want one exact representation pass false.
|
|
628
|
+
*/
|
|
569
629
|
export async function fetchCompleteDocument(
|
|
570
630
|
rawUrl: string,
|
|
571
631
|
signal: AbortSignal | undefined,
|
|
572
632
|
dependencies: FetchRemoteDependencies,
|
|
573
633
|
allowEmptyContent = false,
|
|
634
|
+
retryRepresentationMiss = true,
|
|
574
635
|
): Promise<CompleteDocument> {
|
|
575
|
-
return (
|
|
636
|
+
return (
|
|
637
|
+
await fetchDocument(
|
|
638
|
+
rawUrl,
|
|
639
|
+
signal,
|
|
640
|
+
dependencies,
|
|
641
|
+
undefined,
|
|
642
|
+
allowEmptyContent,
|
|
643
|
+
retryRepresentationMiss,
|
|
644
|
+
)
|
|
645
|
+
).document;
|
|
576
646
|
}
|
|
577
647
|
|
|
578
648
|
/** Revalidates a stale representation with its ETag and Last-Modified validators. */
|
package/src/acquisition/llms.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { ExpiringLruCache } from "../cache";
|
|
2
2
|
import type { CompleteDocument } from "../document/content.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ChallengeBlockedError,
|
|
5
|
+
fetchCompleteDocument,
|
|
6
|
+
type FetchRemoteDependencies,
|
|
7
|
+
} from "./fetch.ts";
|
|
4
8
|
import { InflightCoalescer } from "../inflight";
|
|
5
9
|
|
|
6
10
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
@@ -97,8 +101,13 @@ interface LlmsTxtProbe {
|
|
|
97
101
|
|
|
98
102
|
const MAX_LLMS_TXT_PROBE_ENTRIES = 512;
|
|
99
103
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Deepest ancestor-directory `/llms.txt` probed, e.g. `/a/b/c` also probes
|
|
106
|
+
* `/a/b/llms.txt`. Locale-prefixed documentation sites publish their section index
|
|
107
|
+
* one level below the locale root, so two levels are needed to reach
|
|
108
|
+
* `/en/docs/llms.txt` from `/en/docs/catalog/...`.
|
|
109
|
+
*/
|
|
110
|
+
const MAX_LLMS_TXT_DIRECTORY_DEPTH = 2;
|
|
102
111
|
|
|
103
112
|
const llmsTxtProbes = new ExpiringLruCache<string, LlmsTxtProbe>(
|
|
104
113
|
MAX_LLMS_TXT_PROBE_ENTRIES,
|
|
@@ -179,10 +188,16 @@ async function probeUsableRawText(
|
|
|
179
188
|
if (existing) return existing;
|
|
180
189
|
|
|
181
190
|
try {
|
|
182
|
-
const document = await fetchCompleteDocument(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
const document = await fetchCompleteDocument(
|
|
192
|
+
candidate.toString(),
|
|
193
|
+
sharedSignal,
|
|
194
|
+
{ ...dependencies, solveAnubisChallenges: false },
|
|
195
|
+
// An index probe never accepts an empty body.
|
|
196
|
+
false,
|
|
197
|
+
// A probe wants exactly this representation; retrying as HTML only burns
|
|
198
|
+
// a request on a URL that is already known to be missing.
|
|
199
|
+
false,
|
|
200
|
+
);
|
|
186
201
|
|
|
187
202
|
const available = isUsableLlmsTxtIndex(document) ? document : undefined;
|
|
188
203
|
const result = { document: available, expires: Date.now() + CACHE_TTL_MS };
|
|
@@ -226,8 +241,8 @@ function isSafeToProbe(candidate: URL | undefined, primaryUrl: string): boolean
|
|
|
226
241
|
*
|
|
227
242
|
* - Probes the site's `/llms.txt` index once per origin per TTL window, in parallel with
|
|
228
243
|
* the primary fetch, so later calls can advertise it.
|
|
229
|
-
* - Serves the index instead of the page when the page looks like an app shell
|
|
230
|
-
* too little readable text.
|
|
244
|
+
* - Serves the index instead of the page when the page looks like an app shell, carries
|
|
245
|
+
* too little readable text, or was withheld by a bot-protection challenge.
|
|
231
246
|
* - Otherwise annotates the page with the index URL so agents can discover sibling pages.
|
|
232
247
|
*
|
|
233
248
|
* A missing or useless index can never degrade the primary fetch.
|
|
@@ -247,16 +262,32 @@ export async function fetchDocumentWithLlmsTxtSupport(
|
|
|
247
262
|
|
|
248
263
|
const candidates = buildLlmsTxtCandidateUrls(rawUrl);
|
|
249
264
|
|
|
250
|
-
|
|
265
|
+
// A withheld page must not discard an index that is still usable, so the pair is
|
|
266
|
+
// settled instead of rejected together.
|
|
267
|
+
const [primarySettled, indexSettled] = await Promise.allSettled([
|
|
251
268
|
fetchCompleteDocument(rawUrl, signal, dependencies, true),
|
|
252
269
|
candidates.length > 0
|
|
253
270
|
? ensureLlmsTxtIndex(candidates, signal, dependencies)
|
|
254
271
|
: Promise.resolve(undefined),
|
|
255
272
|
]);
|
|
256
273
|
|
|
274
|
+
// An index probe only rejects when the caller cancels, which must still surface.
|
|
275
|
+
if (indexSettled.status === "rejected") throw indexSettled.reason;
|
|
276
|
+
|
|
277
|
+
if (primarySettled.status === "rejected") {
|
|
278
|
+
// A bot-protection layer replaced the page with a challenge that carries no
|
|
279
|
+
// content; the discovered index is the only remaining pointer to that content.
|
|
280
|
+
if (indexSettled.value && primarySettled.reason instanceof ChallengeBlockedError)
|
|
281
|
+
return { ...indexSettled.value.document, llmsTxtFallback: true };
|
|
282
|
+
|
|
283
|
+
throw primarySettled.reason;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const primary = primarySettled.value;
|
|
287
|
+
|
|
257
288
|
// A `describedby` advertisement names the covering index authoritatively per
|
|
258
289
|
// llmstxt.org v2, so it outranks the blind root/section probes whenever usable.
|
|
259
|
-
let index =
|
|
290
|
+
let index = indexSettled.value;
|
|
260
291
|
|
|
261
292
|
if (primary.llmsTxtDescribedBy) {
|
|
262
293
|
const describedBy = toAbsoluteUrl(primary.llmsTxtDescribedBy);
|
package/src/document/content.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface CompleteDocument {
|
|
|
33
33
|
cachedAt?: number;
|
|
34
34
|
/** True when this document is the site's /llms.txt served instead of a low-quality page. */
|
|
35
35
|
llmsTxtFallback?: boolean;
|
|
36
|
+
/** Code-fence language when the body was returned as reformatted source. */
|
|
37
|
+
sourceLanguage?: string;
|
|
38
|
+
/** Whether the response is a standalone OpenAPI or Swagger document. */
|
|
39
|
+
openapiSpec?: boolean;
|
|
36
40
|
/** Set when the site publishes a usable /llms.txt index and this document is not that index. */
|
|
37
41
|
llmsTxtIndexUrl?: string;
|
|
38
42
|
/** Absolute URL advertised via `rel="describedby"` (header or HTML link) for this page. */
|
package/src/document/focus.ts
CHANGED
|
@@ -22,27 +22,130 @@ interface Section {
|
|
|
22
22
|
tokens: string[];
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface SplitSections {
|
|
26
|
+
sections: Section[];
|
|
27
|
+
/** Separator that rejoins selected sections; windowed source stays contiguous. */
|
|
28
|
+
separator: "\n" | "\n\n";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Target size of one section when a block has no headings and no blank lines. */
|
|
32
|
+
const WINDOW_TARGET_CHARACTERS = 8_000;
|
|
33
|
+
|
|
34
|
+
/** Fewest lines a headingless block needs before it is split into windows. */
|
|
35
|
+
const WINDOW_MIN_LINES = 80;
|
|
36
|
+
|
|
37
|
+
/** Fewest characters a headingless block needs before it is split into windows. */
|
|
38
|
+
const WINDOW_MIN_CHARACTERS = 12_000;
|
|
39
|
+
|
|
25
40
|
function tokens(value: string): string[] {
|
|
26
41
|
return (value.toLowerCase().match(WORD_PATTERN) ?? []).filter((token) => token.length > 1);
|
|
27
42
|
}
|
|
28
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Splits one headingless, blank-line-free block into line-aligned windows.
|
|
46
|
+
*
|
|
47
|
+
* Formatted source and log files have neither headings nor blank lines, so treating the
|
|
48
|
+
* whole body as a single section would make focus select everything. Windows are bounded
|
|
49
|
+
* by characters but never cut a line, and short blocks keep their single section.
|
|
50
|
+
*/
|
|
51
|
+
function splitIntoWindows(block: string): string[] {
|
|
52
|
+
const lines = block.split(/\r?\n/);
|
|
53
|
+
|
|
54
|
+
if (lines.length < WINDOW_MIN_LINES && block.length < WINDOW_MIN_CHARACTERS) return [block];
|
|
55
|
+
|
|
56
|
+
const windows: string[] = [];
|
|
57
|
+
let current: string[] = [];
|
|
58
|
+
let size = 0;
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (size > 0 && size + line.length + 1 > WINDOW_TARGET_CHARACTERS) {
|
|
62
|
+
windows.push(current.join("\n"));
|
|
63
|
+
current = [];
|
|
64
|
+
size = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
current.push(line);
|
|
68
|
+
size += line.length + 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const last = current.join("\n");
|
|
72
|
+
|
|
73
|
+
if (last) windows.push(last);
|
|
74
|
+
|
|
75
|
+
return windows.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
|
|
29
78
|
/**
|
|
30
79
|
* Splits Markdown into sections. Headings stay attached to their following content; a
|
|
31
|
-
* headingless document falls back to paragraphs
|
|
32
|
-
* plain-text
|
|
80
|
+
* headingless document falls back to paragraphs, and a headingless block without blank
|
|
81
|
+
* lines falls back to line windows so focused extraction remains useful for plain-text,
|
|
82
|
+
* log, and reformatted-source responses.
|
|
33
83
|
*/
|
|
34
|
-
function splitSections(markdown: string):
|
|
84
|
+
function splitSections(markdown: string): SplitSections {
|
|
85
|
+
// Window a standalone fenced source body without leaving partial Markdown fences.
|
|
86
|
+
const fenced = /^( {0,3})(`{3,}|~{3,})([^\n\r]*)\r?\n([\s\S]*?)\r?\n\1\2\s*$/.exec(markdown);
|
|
87
|
+
|
|
88
|
+
const hasInteriorClose = fenced?.[4].split(/\r?\n/).some((line) => {
|
|
89
|
+
const close = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
90
|
+
|
|
91
|
+
return close && close[1][0] === fenced[2][0] && close[1].length >= fenced[2].length;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (fenced && !hasInteriorClose) {
|
|
95
|
+
const windows = splitIntoWindows(fenced[4]);
|
|
96
|
+
|
|
97
|
+
if (windows.length > 1) {
|
|
98
|
+
return {
|
|
99
|
+
sections: windows.map((window) => ({
|
|
100
|
+
markdown: `${fenced[1]}${fenced[2]}${fenced[3]}\n${window}\n${fenced[1]}${fenced[2]}`,
|
|
101
|
+
tokens: tokens(window),
|
|
102
|
+
})),
|
|
103
|
+
separator: "\n\n",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { sections: [{ markdown, tokens: tokens(fenced[4]) }], separator: "\n\n" };
|
|
108
|
+
}
|
|
109
|
+
|
|
35
110
|
const blocks = markdown
|
|
36
111
|
.split(/\n{2,}/)
|
|
37
112
|
.map((block) => block.trim())
|
|
38
113
|
.filter(Boolean);
|
|
39
114
|
|
|
40
|
-
if (blocks.length === 0) return [];
|
|
115
|
+
if (blocks.length === 0) return { sections: [], separator: "\n\n" };
|
|
41
116
|
|
|
42
117
|
const lines = markdown.split(/\r?\n/);
|
|
43
|
-
|
|
118
|
+
let fence: { marker: string; length: number } | undefined;
|
|
119
|
+
|
|
120
|
+
const headingLines = lines.map((line) => {
|
|
121
|
+
const match = /^[ \t]{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
122
|
+
|
|
123
|
+
if (match) {
|
|
124
|
+
if (!fence) fence = { marker: match[1][0], length: match[1].length };
|
|
125
|
+
else if (match[1][0] === fence.marker && match[1].length >= fence.length && !match[2].trim())
|
|
126
|
+
fence = undefined;
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return !fence && HEADING_PATTERN.test(line);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const hasHeadings = headingLines.some(Boolean);
|
|
135
|
+
|
|
136
|
+
if (!hasHeadings) {
|
|
137
|
+
// Keep mixed fenced Markdown intact rather than splitting a fence at blank lines.
|
|
138
|
+
if (lines.some((line) => /^[ \t]{0,3}(`{3,}|~{3,})/.test(line))) {
|
|
139
|
+
return { sections: [{ markdown, tokens: tokens(markdown) }], separator: "\n\n" };
|
|
140
|
+
}
|
|
44
141
|
|
|
45
|
-
|
|
142
|
+
const windowed = blocks.flatMap(splitIntoWindows);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
sections: windowed.map((block) => ({ markdown: block, tokens: tokens(block) })),
|
|
146
|
+
separator: blocks.length === 1 && windowed.length > 1 ? "\n" : "\n\n",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
46
149
|
|
|
47
150
|
const sections: string[] = [];
|
|
48
151
|
let current: string[] = [];
|
|
@@ -54,14 +157,17 @@ function splitSections(markdown: string): Section[] {
|
|
|
54
157
|
current = [];
|
|
55
158
|
};
|
|
56
159
|
|
|
57
|
-
for (const line of lines) {
|
|
58
|
-
if (
|
|
160
|
+
for (const [index, line] of lines.entries()) {
|
|
161
|
+
if (headingLines[index]) flush();
|
|
59
162
|
current.push(line);
|
|
60
163
|
}
|
|
61
164
|
|
|
62
165
|
flush();
|
|
63
166
|
|
|
64
|
-
return
|
|
167
|
+
return {
|
|
168
|
+
sections: sections.map((section) => ({ markdown: section, tokens: tokens(section) })),
|
|
169
|
+
separator: "\n\n",
|
|
170
|
+
};
|
|
65
171
|
}
|
|
66
172
|
|
|
67
173
|
/**
|
|
@@ -72,7 +178,7 @@ function splitSections(markdown: string): Section[] {
|
|
|
72
178
|
export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
73
179
|
const normalizedQuery = query.trim();
|
|
74
180
|
const queryTerms = [...new Set(tokens(normalizedQuery))];
|
|
75
|
-
const sections = splitSections(markdown);
|
|
181
|
+
const { sections, separator } = splitSections(markdown);
|
|
76
182
|
const originalCharacters = markdown.length;
|
|
77
183
|
|
|
78
184
|
if (queryTerms.length === 0 || sections.length === 0) {
|
|
@@ -126,7 +232,7 @@ export function focusMarkdown(markdown: string, query: string): FocusResult {
|
|
|
126
232
|
return score > 0;
|
|
127
233
|
});
|
|
128
234
|
|
|
129
|
-
const focused = selected.map((section) => section.markdown).join(
|
|
235
|
+
const focused = selected.map((section) => section.markdown).join(separator);
|
|
130
236
|
|
|
131
237
|
return {
|
|
132
238
|
markdown: focused,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { parse } from "acorn";
|
|
2
|
+
import { simple } from "acorn-walk";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { Check } from "typebox/value";
|
|
6
|
+
|
|
7
|
+
const schema = Type.Union([
|
|
8
|
+
Type.Object({
|
|
9
|
+
openapi: Type.String({ pattern: "^3\\.\\d+\\.\\d+$" }),
|
|
10
|
+
info: Type.Object({}),
|
|
11
|
+
paths: Type.Object({}),
|
|
12
|
+
}),
|
|
13
|
+
Type.Object({ swagger: Type.Literal("2.0"), info: Type.Object({}), paths: Type.Object({}) }),
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// Parsing is synchronous and untrusted. Larger inputs remain readable without classification.
|
|
17
|
+
const MAX_CLASSIFICATION_CHARACTERS = 512 * 1024;
|
|
18
|
+
|
|
19
|
+
/** Conservatively recognizes a document or a literal object embedded in JavaScript. Never executes source. */
|
|
20
|
+
export function isOpenapiSource(raw: string, contentType: string): boolean {
|
|
21
|
+
if (raw.length > MAX_CLASSIFICATION_CHARACTERS) return false;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
if (contentType === "application/json") return Check(schema, JSON.parse(raw));
|
|
25
|
+
|
|
26
|
+
if (contentType === "text/yaml") {
|
|
27
|
+
const document = parseDocument(raw, { uniqueKeys: true });
|
|
28
|
+
|
|
29
|
+
return document.errors.length === 0 && Check(schema, document.toJS({ maxAliasCount: 0 }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (contentType !== "text/javascript") return false;
|
|
33
|
+
const ast = parse(raw, { ecmaVersion: "latest", sourceType: "module" });
|
|
34
|
+
let found = false;
|
|
35
|
+
simple(ast, {
|
|
36
|
+
ObjectExpression(node) {
|
|
37
|
+
let version = false;
|
|
38
|
+
let info = false;
|
|
39
|
+
let paths = false;
|
|
40
|
+
|
|
41
|
+
for (const property of node.properties) {
|
|
42
|
+
if (property.type !== "Property" || property.computed || property.kind !== "init")
|
|
43
|
+
continue;
|
|
44
|
+
|
|
45
|
+
const key =
|
|
46
|
+
property.key.type === "Identifier"
|
|
47
|
+
? property.key.name
|
|
48
|
+
: property.key.type === "Literal"
|
|
49
|
+
? property.key.value
|
|
50
|
+
: undefined;
|
|
51
|
+
|
|
52
|
+
if ((key === "openapi" || key === "swagger") && property.value.type === "Literal") {
|
|
53
|
+
const value = property.value.value;
|
|
54
|
+
version ||=
|
|
55
|
+
key === "swagger"
|
|
56
|
+
? value === "2.0"
|
|
57
|
+
: Check(Type.String({ pattern: "^3\\.\\d+\\.\\d+$" }), value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (key === "info" && property.value.type === "ObjectExpression") info = true;
|
|
61
|
+
|
|
62
|
+
if (key === "paths" && property.value.type === "ObjectExpression") paths = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (version && info && paths) found = true;
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return found;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reconstructs detection from older cached canonical representations, without a network request. */
|
|
76
|
+
export function isCachedOpenapi(markdown: string, contentType: string): boolean {
|
|
77
|
+
const match = /^```(?:json|js|yaml)\r?\n([\s\S]*)\r?\n```\s*$/.exec(markdown);
|
|
78
|
+
|
|
79
|
+
return isOpenapiSource(match ? match[1] : markdown, contentType);
|
|
80
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source text that is read as code rather than prose, mapped to the code-fence
|
|
3
|
+
* language used when one of these bodies is reformatted.
|
|
4
|
+
*/
|
|
5
|
+
const SOURCE_LANGUAGE_BY_CONTENT_TYPE = {
|
|
6
|
+
"application/json": "json",
|
|
7
|
+
"text/javascript": "js",
|
|
8
|
+
"text/yaml": "yaml",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
type SourceContentType = keyof typeof SOURCE_LANGUAGE_BY_CONTENT_TYPE;
|
|
12
|
+
|
|
13
|
+
/** Narrows a media type to the source types that are reformatted. */
|
|
14
|
+
function isSourceContentType(value: string): value is SourceContentType {
|
|
15
|
+
return Object.hasOwn(SOURCE_LANGUAGE_BY_CONTENT_TYPE, value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Shortest body reformatted; below this a page is returned verbatim. */
|
|
19
|
+
const MINIFIED_MIN_CHARACTERS = 2_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Largest body reformatted. Reformatting triples transient memory for a document too
|
|
23
|
+
* large to cache, so bigger bodies keep their original single-line layout.
|
|
24
|
+
*/
|
|
25
|
+
const MINIFIED_MAX_CHARACTERS = 2 * 1_024 * 1_024;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shortest average line that still counts as machine-generated. Bundled source is
|
|
29
|
+
* packed into very long lines even when it keeps a few real line breaks inside block
|
|
30
|
+
* scalars, while hand-written source keeps short lines.
|
|
31
|
+
*/
|
|
32
|
+
const MINIFIED_MIN_AVERAGE_LINE_CHARACTERS = 300;
|
|
33
|
+
|
|
34
|
+
/** Deepest nesting that still receives extra indentation. */
|
|
35
|
+
const MAX_FORMAT_DEPTH = 24;
|
|
36
|
+
|
|
37
|
+
const INDENT = " ";
|
|
38
|
+
|
|
39
|
+
const QUOTE_CHARACTERS = new Set(['"', "'", "`"]);
|
|
40
|
+
|
|
41
|
+
const OPENING_CHARACTERS = new Set(["{", "["]);
|
|
42
|
+
|
|
43
|
+
const CLOSING_CHARACTERS = new Set(["}", "]"]);
|
|
44
|
+
|
|
45
|
+
const LINE_ENDING_CHARACTERS = new Set([",", ";"]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns whether a body is machine-generated source rather than hand-written code.
|
|
49
|
+
*
|
|
50
|
+
* Only minified bodies are reformatted: a hand-written file already carries the
|
|
51
|
+
* author's line structure, which is more useful than any generated layout.
|
|
52
|
+
*/
|
|
53
|
+
function isMinified(value: string): boolean {
|
|
54
|
+
if (value.length < MINIFIED_MIN_CHARACTERS || value.length > MINIFIED_MAX_CHARACTERS)
|
|
55
|
+
return false;
|
|
56
|
+
|
|
57
|
+
let lineBreaks = 0;
|
|
58
|
+
|
|
59
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
60
|
+
if (value[index] === "\n") lineBreaks += 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return value.length / (lineBreaks + 1) >= MINIFIED_MIN_AVERAGE_LINE_CHARACTERS;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Returns the fence language for a content type, or undefined when it is not source. */
|
|
67
|
+
export function sourceLanguageFor(contentType: string): string | undefined {
|
|
68
|
+
return isSourceContentType(contentType)
|
|
69
|
+
? SOURCE_LANGUAGE_BY_CONTENT_TYPE[contentType]
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Adds line breaks and indentation to minified source without interpreting it.
|
|
75
|
+
*
|
|
76
|
+
* Layout whitespace is normalized and every other character is preserved in order.
|
|
77
|
+
* String literals are tracked only far enough to keep their punctuation from changing
|
|
78
|
+
* the layout, so no value is parsed, evaluated, or re-serialized.
|
|
79
|
+
*
|
|
80
|
+
* @param value - The minified source
|
|
81
|
+
* @returns Indented source, or undefined when the input was not minified
|
|
82
|
+
*/
|
|
83
|
+
export function indentMinifiedSource(value: string): string | undefined {
|
|
84
|
+
if (!isMinified(value)) return undefined;
|
|
85
|
+
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
let current = "";
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let quote: string | undefined;
|
|
90
|
+
let escaped = false;
|
|
91
|
+
|
|
92
|
+
const indent = (): string => INDENT.repeat(Math.min(depth, MAX_FORMAT_DEPTH));
|
|
93
|
+
|
|
94
|
+
const flush = (): void => {
|
|
95
|
+
const line = current.trimEnd();
|
|
96
|
+
|
|
97
|
+
if (line) lines.push(line);
|
|
98
|
+
current = "";
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (const character of value) {
|
|
102
|
+
if (quote !== undefined) {
|
|
103
|
+
current += character;
|
|
104
|
+
|
|
105
|
+
if (escaped) escaped = false;
|
|
106
|
+
else if (character === "\\") escaped = true;
|
|
107
|
+
else if (character === quote) quote = undefined;
|
|
108
|
+
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (QUOTE_CHARACTERS.has(character)) {
|
|
113
|
+
quote = character;
|
|
114
|
+
current += character;
|
|
115
|
+
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (OPENING_CHARACTERS.has(character)) {
|
|
120
|
+
current += character;
|
|
121
|
+
flush();
|
|
122
|
+
depth += 1;
|
|
123
|
+
current = indent();
|
|
124
|
+
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (CLOSING_CHARACTERS.has(character)) {
|
|
129
|
+
flush();
|
|
130
|
+
depth = Math.max(0, depth - 1);
|
|
131
|
+
current = indent() + character;
|
|
132
|
+
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (LINE_ENDING_CHARACTERS.has(character)) {
|
|
137
|
+
current += character;
|
|
138
|
+
flush();
|
|
139
|
+
current = indent();
|
|
140
|
+
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (character === "\n") {
|
|
145
|
+
flush();
|
|
146
|
+
current = indent();
|
|
147
|
+
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (character !== "\r") current += character;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
flush();
|
|
155
|
+
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Reformats minified JavaScript, JSON, or YAML as a fenced code block.
|
|
161
|
+
*
|
|
162
|
+
* @param raw - The decoded response body
|
|
163
|
+
* @param contentType - The normalized response media type
|
|
164
|
+
* @returns A fenced, indented Markdown block, or undefined when the body is not
|
|
165
|
+
* source that benefits from reformatting
|
|
166
|
+
*/
|
|
167
|
+
export function formatMinifiedSource(
|
|
168
|
+
raw: string,
|
|
169
|
+
contentType: string,
|
|
170
|
+
): { markdown: string; language: string } | undefined {
|
|
171
|
+
const language = sourceLanguageFor(contentType);
|
|
172
|
+
|
|
173
|
+
if (language === undefined) return undefined;
|
|
174
|
+
|
|
175
|
+
const indented = indentMinifiedSource(raw);
|
|
176
|
+
|
|
177
|
+
if (indented === undefined) return undefined;
|
|
178
|
+
|
|
179
|
+
return { language, markdown: `\`\`\`${language}\n${indented}\n\`\`\`` };
|
|
180
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { sliceCompleteDocument, type CompleteDocument } from "./document/content
|
|
|
13
13
|
import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./acquisition/fetch.ts";
|
|
14
14
|
import { fetchDocumentWithLlmsTxtSupport } from "./acquisition/llms.ts";
|
|
15
15
|
import { focusMarkdown, type FocusDetails } from "./document/focus.ts";
|
|
16
|
+
import { isCachedOpenapi } from "./document/openapi.ts";
|
|
16
17
|
import { resolveFragmentOffset, urlWithoutFragment } from "./document/fragments.ts";
|
|
17
18
|
import { InflightCoalescer } from "./inflight";
|
|
18
19
|
import { createDocumentOutline, type DocumentOutline } from "./document/outline.ts";
|
|
@@ -43,6 +44,7 @@ const encoder = new TextEncoder();
|
|
|
43
44
|
export type ContentKind =
|
|
44
45
|
| "repository-readme"
|
|
45
46
|
| "code-file"
|
|
47
|
+
| "openapi-spec"
|
|
46
48
|
| "directory-listing"
|
|
47
49
|
| "llms-index"
|
|
48
50
|
| "article"
|
|
@@ -73,14 +75,24 @@ function safeUrl(value: string): URL | undefined {
|
|
|
73
75
|
* @param url - The fetched URL
|
|
74
76
|
* @param extractor - Extraction method used
|
|
75
77
|
* @param shellSuspected - Whether the page appears to be an app shell
|
|
78
|
+
* @param sourceLanguage - Code-fence language when the body was reformatted as source
|
|
76
79
|
* @returns Content classification
|
|
77
80
|
*/
|
|
78
81
|
export function classifyContentKind(
|
|
79
82
|
url: string,
|
|
80
83
|
extractor: CompleteDocument["extractor"],
|
|
81
84
|
shellSuspected: boolean,
|
|
85
|
+
sourceLanguage?: string,
|
|
86
|
+
openapiSpec = false,
|
|
82
87
|
): ContentKind {
|
|
83
88
|
if (shellSuspected) return "markup-shell";
|
|
89
|
+
|
|
90
|
+
if (openapiSpec) return "openapi-spec";
|
|
91
|
+
|
|
92
|
+
// A reformatted body is code whatever host served it: the response was read as
|
|
93
|
+
// source, not as page prose.
|
|
94
|
+
if (sourceLanguage !== undefined) return "code-file";
|
|
95
|
+
|
|
84
96
|
const parsed = safeUrl(url);
|
|
85
97
|
const host = parsed?.hostname ?? "";
|
|
86
98
|
const path = parsed?.pathname ?? "";
|
|
@@ -423,6 +435,7 @@ export async function executeWebFetch(
|
|
|
423
435
|
|
|
424
436
|
const fresh = Boolean(
|
|
425
437
|
document &&
|
|
438
|
+
!document.llmsTxtFallback &&
|
|
426
439
|
!document.extractionDiagnostics?.botWall &&
|
|
427
440
|
(document.cachedAt === undefined || now - document.cachedAt < CACHE_TTL_MS),
|
|
428
441
|
);
|
|
@@ -576,7 +589,15 @@ export async function executeWebFetch(
|
|
|
576
589
|
const rawFinalUrl = result.url;
|
|
577
590
|
const finalUrl = redactUrlForDisplay(rawFinalUrl);
|
|
578
591
|
const shellSuspected = result.shellSuspected;
|
|
579
|
-
|
|
592
|
+
|
|
593
|
+
const contentKind = classifyContentKind(
|
|
594
|
+
rawFinalUrl,
|
|
595
|
+
result.extractor,
|
|
596
|
+
shellSuspected,
|
|
597
|
+
result.sourceLanguage,
|
|
598
|
+
document.openapiSpec ?? isCachedOpenapi(document.markdown, document.contentType),
|
|
599
|
+
);
|
|
600
|
+
|
|
580
601
|
const confidence = classifyConfidence(result.extractor, shellSuspected, result.markdown.length);
|
|
581
602
|
|
|
582
603
|
const requestedPath = safeUrl(params.url)?.pathname.replace(/\/+$/u, "").toLowerCase();
|
|
@@ -603,10 +624,7 @@ export async function executeWebFetch(
|
|
|
603
624
|
]
|
|
604
625
|
: []),
|
|
605
626
|
...(showLlmsNotice && result.llmsTxtIndexUrl
|
|
606
|
-
? [
|
|
607
|
-
`Documentation index available: ${redactUrlForDisplay(result.llmsTxtIndexUrl)}. Use it if this page does not cover the topic.`,
|
|
608
|
-
"",
|
|
609
|
-
]
|
|
627
|
+
? [`Index: ${redactUrlForDisplay(result.llmsTxtIndexUrl)}`, ""]
|
|
610
628
|
: []),
|
|
611
629
|
...(resolvedFragment.fragment !== undefined && !fragmentMatched
|
|
612
630
|
? [
|