@intentic/webq 1.317.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +38 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/cache.command.d.ts +3 -0
- package/dist/commands/cache.command.d.ts.map +1 -0
- package/dist/commands/cache.command.js +54 -0
- package/dist/commands/cache.command.js.map +1 -0
- package/dist/commands/crawl.command.d.ts +3 -0
- package/dist/commands/crawl.command.d.ts.map +1 -0
- package/dist/commands/crawl.command.js +77 -0
- package/dist/commands/crawl.command.js.map +1 -0
- package/dist/commands/fetch.command.d.ts +3 -0
- package/dist/commands/fetch.command.d.ts.map +1 -0
- package/dist/commands/fetch.command.js +55 -0
- package/dist/commands/fetch.command.js.map +1 -0
- package/dist/lib/bm25.d.ts +7 -0
- package/dist/lib/bm25.d.ts.map +1 -0
- package/dist/lib/bm25.js +51 -0
- package/dist/lib/bm25.js.map +1 -0
- package/dist/lib/browser.d.ts +8 -0
- package/dist/lib/browser.d.ts.map +1 -0
- package/dist/lib/browser.js +29 -0
- package/dist/lib/browser.js.map +1 -0
- package/dist/lib/cache.d.ts +15 -0
- package/dist/lib/cache.d.ts.map +1 -0
- package/dist/lib/cache.js +28 -0
- package/dist/lib/cache.js.map +1 -0
- package/dist/lib/crawl.d.ts +31 -0
- package/dist/lib/crawl.d.ts.map +1 -0
- package/dist/lib/crawl.js +188 -0
- package/dist/lib/crawl.js.map +1 -0
- package/dist/lib/dom.d.ts +23 -0
- package/dist/lib/dom.d.ts.map +1 -0
- package/dist/lib/dom.js +74 -0
- package/dist/lib/dom.js.map +1 -0
- package/dist/lib/env.d.ts +2 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +4 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/flags.d.ts +69 -0
- package/dist/lib/flags.d.ts.map +1 -0
- package/dist/lib/flags.js +23 -0
- package/dist/lib/flags.js.map +1 -0
- package/dist/lib/http.d.ts +16 -0
- package/dist/lib/http.d.ts.map +1 -0
- package/dist/lib/http.js +75 -0
- package/dist/lib/http.js.map +1 -0
- package/dist/lib/links.d.ts +9 -0
- package/dist/lib/links.d.ts.map +1 -0
- package/dist/lib/links.js +41 -0
- package/dist/lib/links.js.map +1 -0
- package/dist/lib/markdown.d.ts +6 -0
- package/dist/lib/markdown.d.ts.map +1 -0
- package/dist/lib/markdown.js +292 -0
- package/dist/lib/markdown.js.map +1 -0
- package/dist/lib/output.d.ts +20 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +64 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/page.d.ts +26 -0
- package/dist/lib/page.d.ts.map +1 -0
- package/dist/lib/page.js +153 -0
- package/dist/lib/page.js.map +1 -0
- package/dist/lib/prune.d.ts +7 -0
- package/dist/lib/prune.d.ts.map +1 -0
- package/dist/lib/prune.js +100 -0
- package/dist/lib/prune.js.map +1 -0
- package/dist/lib/robots.d.ts +10 -0
- package/dist/lib/robots.d.ts.map +1 -0
- package/dist/lib/robots.js +84 -0
- package/dist/lib/robots.js.map +1 -0
- package/dist/lib/sitemap.d.ts +9 -0
- package/dist/lib/sitemap.d.ts.map +1 -0
- package/dist/lib/sitemap.js +61 -0
- package/dist/lib/sitemap.js.map +1 -0
- package/dist/lib/version.d.ts +2 -0
- package/dist/lib/version.d.ts.map +1 -0
- package/dist/lib/version.js +3 -0
- package/dist/lib/version.js.map +1 -0
- package/package.json +69 -0
- package/src/app.ts +43 -0
- package/src/cli.integration.test.ts +191 -0
- package/src/cli.ts +6 -0
- package/src/commands/cache.command.ts +54 -0
- package/src/commands/crawl.command.ts +94 -0
- package/src/commands/fetch.command.ts +61 -0
- package/src/lib/bm25.test.ts +33 -0
- package/src/lib/bm25.ts +70 -0
- package/src/lib/browser.ts +42 -0
- package/src/lib/cache.ts +44 -0
- package/src/lib/crawl.ts +247 -0
- package/src/lib/dom.ts +107 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/flags.ts +41 -0
- package/src/lib/http.ts +101 -0
- package/src/lib/links.test.ts +37 -0
- package/src/lib/links.ts +47 -0
- package/src/lib/markdown.test.ts +76 -0
- package/src/lib/markdown.ts +337 -0
- package/src/lib/output.test.ts +16 -0
- package/src/lib/output.ts +86 -0
- package/src/lib/page.ts +213 -0
- package/src/lib/prune.test.ts +78 -0
- package/src/lib/prune.ts +133 -0
- package/src/lib/robots.test.ts +52 -0
- package/src/lib/robots.ts +102 -0
- package/src/lib/sitemap.test.ts +37 -0
- package/src/lib/sitemap.ts +74 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* `webq cache`: what the shared cache holds and the one switch that empties it. */
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { toolHome } from "@intentic/agent-cli/env";
|
|
5
|
+
import { buildCommand, type CommandContext } from "@stricli/core";
|
|
6
|
+
import { cacheClear } from "../lib/cache.js";
|
|
7
|
+
import { cacheDir } from "../lib/env.js";
|
|
8
|
+
|
|
9
|
+
export const cacheCommand = buildCommand({
|
|
10
|
+
docs: { brief: "Show the shared fetch cache; --clear empties it" },
|
|
11
|
+
parameters: {
|
|
12
|
+
flags: {
|
|
13
|
+
clear: { kind: "boolean", default: false, brief: "Delete every cached fetch" },
|
|
14
|
+
},
|
|
15
|
+
positional: { kind: "tuple", parameters: [] },
|
|
16
|
+
},
|
|
17
|
+
async func(this: CommandContext, flags: { readonly clear: boolean }) {
|
|
18
|
+
if (flags.clear) {
|
|
19
|
+
await cacheClear();
|
|
20
|
+
this.process.stdout.write("cache cleared\n");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const { entries, bytes } = await measure(cacheDir());
|
|
24
|
+
this.process.stdout.write(`webq home: ${toolHome("webq")}\ncache: ${entries} entries, ${(bytes / 1024 / 1024).toFixed(1)} MiB\n`);
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const measure = async (dir: string): Promise<{ entries: number; bytes: number }> => {
|
|
29
|
+
let entries = 0;
|
|
30
|
+
let bytes = 0;
|
|
31
|
+
const walkDir = async (current: string): Promise<void> => {
|
|
32
|
+
let names: string[];
|
|
33
|
+
try {
|
|
34
|
+
names = await readdir(current);
|
|
35
|
+
} catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const path = join(current, name);
|
|
40
|
+
const info = await stat(path).catch(() => undefined);
|
|
41
|
+
if (info === undefined) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (info.isDirectory()) {
|
|
45
|
+
await walkDir(path);
|
|
46
|
+
} else {
|
|
47
|
+
entries += 1;
|
|
48
|
+
bytes += info.size;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
await walkDir(dir);
|
|
53
|
+
return { entries, bytes };
|
|
54
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* `webq crawl <url>`: a bounded same-site crawl into a directory of markdown files plus an index. */
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { toolOutDir } from "@intentic/agent-cli/env";
|
|
4
|
+
import { countParser } from "@intentic/agent-cli/flags";
|
|
5
|
+
import { buildCommand, type CommandContext } from "@stricli/core";
|
|
6
|
+
import { DEFAULT_MAX_AGE_S } from "../lib/cache.js";
|
|
7
|
+
import { crawl } from "../lib/crawl.js";
|
|
8
|
+
import { sharedFlagParameters, type SharedFlags, urlParser } from "../lib/flags.js";
|
|
9
|
+
import { slugFor } from "../lib/output.js";
|
|
10
|
+
|
|
11
|
+
// Failed pages named one per line before the rest are counted; the JSON report carries all of them.
|
|
12
|
+
const FAILURES_SHOWN = 10;
|
|
13
|
+
|
|
14
|
+
type CrawlFlags = SharedFlags & {
|
|
15
|
+
readonly maxPages: number;
|
|
16
|
+
readonly depth: number;
|
|
17
|
+
readonly concurrency: number;
|
|
18
|
+
readonly sitemap: boolean;
|
|
19
|
+
readonly external: boolean;
|
|
20
|
+
readonly ignoreRobots: boolean;
|
|
21
|
+
readonly include?: string[];
|
|
22
|
+
readonly exclude?: string[];
|
|
23
|
+
readonly delay: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const crawlCommand = buildCommand({
|
|
27
|
+
docs: { brief: "Bounded same-site crawl: one markdown file per page, plus an index" },
|
|
28
|
+
parameters: {
|
|
29
|
+
flags: {
|
|
30
|
+
...sharedFlagParameters,
|
|
31
|
+
maxPages: { kind: "parsed", parse: countParser, default: "20", brief: "Hard page cap" },
|
|
32
|
+
depth: { kind: "parsed", parse: countParser, default: "2", brief: "Link hops from the start URL" },
|
|
33
|
+
concurrency: { kind: "parsed", parse: countParser, default: "4", brief: "Pages in flight at once" },
|
|
34
|
+
sitemap: { kind: "boolean", default: false, brief: "Seed the frontier from the site's sitemap" },
|
|
35
|
+
external: { kind: "boolean", default: false, brief: "Follow links off the start origin too" },
|
|
36
|
+
ignoreRobots: { kind: "boolean", default: false, brief: "Crawl paths robots.txt disallows (your responsibility)" },
|
|
37
|
+
include: { kind: "parsed", parse: String, variadic: true, optional: true, brief: "Only URLs containing this (or *-glob); repeatable" },
|
|
38
|
+
exclude: { kind: "parsed", parse: String, variadic: true, optional: true, brief: "Skip URLs containing this (or *-glob); repeatable" },
|
|
39
|
+
delay: { kind: "parsed", parse: countParser, default: "0", brief: "Milliseconds between requests (robots crawl-delay still respected)" },
|
|
40
|
+
},
|
|
41
|
+
positional: { kind: "tuple", parameters: [{ parse: urlParser, brief: "Where the crawl starts", placeholder: "url" }] },
|
|
42
|
+
},
|
|
43
|
+
async func(this: CommandContext, flags: CrawlFlags, url: string) {
|
|
44
|
+
const outDir = flags.out ?? join(toolOutDir("webq"), slugFor(url).replace(/\.md$/, ""));
|
|
45
|
+
const report = await crawl(url, {
|
|
46
|
+
outDir,
|
|
47
|
+
maxPages: flags.maxPages,
|
|
48
|
+
maxDepth: flags.depth,
|
|
49
|
+
concurrency: flags.concurrency,
|
|
50
|
+
robots: !flags.ignoreRobots,
|
|
51
|
+
sitemap: flags.sitemap,
|
|
52
|
+
external: flags.external,
|
|
53
|
+
...(flags.include === undefined ? {} : { include: flags.include }),
|
|
54
|
+
...(flags.exclude === undefined ? {} : { exclude: flags.exclude }),
|
|
55
|
+
delayMs: flags.delay,
|
|
56
|
+
raw: flags.raw,
|
|
57
|
+
query: flags.query,
|
|
58
|
+
browser: flags.browser,
|
|
59
|
+
maxAgeS: flags.fresh ? 0 : (flags.maxAge ?? DEFAULT_MAX_AGE_S),
|
|
60
|
+
timeoutMs: flags.timeout * 1000,
|
|
61
|
+
threshold: flags.threshold,
|
|
62
|
+
});
|
|
63
|
+
if (flags.json) {
|
|
64
|
+
this.process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
65
|
+
process.exitCode = report.pages.length > 0 ? 0 : 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const totalTokens = report.pages.reduce((sum, page) => sum + page.tokens, 0);
|
|
69
|
+
const skipLine = Object.entries(report.skipped)
|
|
70
|
+
.filter(([, count]) => count > 0)
|
|
71
|
+
.map(([reason, count]) => `${reason} ${count}`)
|
|
72
|
+
.join(", ");
|
|
73
|
+
this.process.stdout.write(
|
|
74
|
+
`webq crawl: ${url} · ${report.pages.length} pages · ${totalTokens} tokens${skipLine === "" ? "" : ` · skipped: ${skipLine}`}\n`,
|
|
75
|
+
);
|
|
76
|
+
for (const { url: failed, reason } of report.failures.slice(0, FAILURES_SHOWN)) {
|
|
77
|
+
this.process.stdout.write(`failed: ${failed}: ${reason}\n`);
|
|
78
|
+
}
|
|
79
|
+
if (report.failures.length > FAILURES_SHOWN) {
|
|
80
|
+
this.process.stdout.write(`…and ${report.failures.length - FAILURES_SHOWN} more failed pages (--json lists every one)\n`);
|
|
81
|
+
}
|
|
82
|
+
if (report.sitemapCapped) {
|
|
83
|
+
this.process.stdout.write("note: sitemap larger than the seed cap, frontier seeded from a prefix of it\n");
|
|
84
|
+
}
|
|
85
|
+
this.process.stdout.write(`index: ${report.indexMarkdown}\n`);
|
|
86
|
+
for (const page of report.pages) {
|
|
87
|
+
this.process.stdout.write(` ${page.tokens}t d${page.depth} ${page.url} → ${page.path}\n`);
|
|
88
|
+
for (const note of page.notes) {
|
|
89
|
+
this.process.stdout.write(` note: ${note}\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
process.exitCode = report.pages.length > 0 ? 0 : 1;
|
|
93
|
+
},
|
|
94
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* `webq fetch <url>`: one page as markdown — a capsule line saying what happened, the content up to a token budget. */
|
|
2
|
+
import { toolOutDir } from "@intentic/agent-cli/env";
|
|
3
|
+
import { countParser } from "@intentic/agent-cli/flags";
|
|
4
|
+
import { capsule, clip } from "@intentic/agent-cli/output";
|
|
5
|
+
import { buildCommand, type CommandContext } from "@stricli/core";
|
|
6
|
+
import { closeBrowser } from "../lib/browser.js";
|
|
7
|
+
import { DEFAULT_MAX_AGE_S } from "../lib/cache.js";
|
|
8
|
+
import { sharedFlagParameters, type SharedFlags, urlParser } from "../lib/flags.js";
|
|
9
|
+
import { savePage } from "../lib/output.js";
|
|
10
|
+
import { fetchPage, type PageResult } from "../lib/page.js";
|
|
11
|
+
|
|
12
|
+
type FetchFlags = SharedFlags & { readonly budget: number };
|
|
13
|
+
|
|
14
|
+
export const fetchCommand = buildCommand({
|
|
15
|
+
docs: { brief: "One URL as clean markdown: budgeted on stdout, whole in a saved file" },
|
|
16
|
+
parameters: {
|
|
17
|
+
flags: {
|
|
18
|
+
...sharedFlagParameters,
|
|
19
|
+
budget: { kind: "parsed", parse: countParser, default: "4000", brief: "Max stdout tokens; 0 prints only the capsule" },
|
|
20
|
+
},
|
|
21
|
+
positional: { kind: "tuple", parameters: [{ parse: urlParser, brief: "The page to fetch", placeholder: "url" }] },
|
|
22
|
+
},
|
|
23
|
+
async func(this: CommandContext, flags: FetchFlags, url: string) {
|
|
24
|
+
try {
|
|
25
|
+
const page = await fetchPage(url, {
|
|
26
|
+
raw: flags.raw,
|
|
27
|
+
query: flags.query,
|
|
28
|
+
browser: flags.browser,
|
|
29
|
+
maxAgeS: flags.fresh ? 0 : (flags.maxAge ?? DEFAULT_MAX_AGE_S),
|
|
30
|
+
timeoutMs: flags.timeout * 1000,
|
|
31
|
+
threshold: flags.threshold,
|
|
32
|
+
});
|
|
33
|
+
const saved = await savePage(flags.out ?? toolOutDir("webq"), page, new Date());
|
|
34
|
+
if (flags.json) {
|
|
35
|
+
this.process.stdout.write(
|
|
36
|
+
`${JSON.stringify({ url, finalUrl: page.finalUrl, status: page.status, title: page.meta.title, tokens: saved.tokens, path: saved.path, source: page.source, notes: page.notes })}\n`,
|
|
37
|
+
);
|
|
38
|
+
} else {
|
|
39
|
+
this.process.stdout.write(capsule("webq", capsuleFields(page, saved.tokens, flags.raw), page.notes));
|
|
40
|
+
this.process.stdout.write(`saved: ${saved.path}\n`);
|
|
41
|
+
if (flags.budget > 0 && page.markdown !== "") {
|
|
42
|
+
this.process.stdout.write("---\n");
|
|
43
|
+
this.process.stdout.write(clip(page.markdown, flags.budget, saved.path, "page"));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
process.exitCode = page.status >= 400 ? 1 : 0;
|
|
47
|
+
} finally {
|
|
48
|
+
await closeBrowser();
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// What a fetch has to say before its content: the page's name, where it really came from (redirects included),
|
|
54
|
+
// what it will cost to read, how much chrome was pruned, and whether the bytes were cached, fetched or rendered.
|
|
55
|
+
const capsuleFields = (page: PageResult, tokens: number, raw: boolean): string[] => [
|
|
56
|
+
page.meta.title === "" ? page.finalUrl : page.meta.title,
|
|
57
|
+
page.finalUrl,
|
|
58
|
+
`${tokens} tokens`,
|
|
59
|
+
raw ? "raw" : page.prunedShare === undefined ? "fit" : `fit (pruned ${Math.round(page.prunedShare * 100)}% of text mass)`,
|
|
60
|
+
page.source,
|
|
61
|
+
];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { bm25Rank, tokenize } from "./bm25.js";
|
|
2
|
+
|
|
3
|
+
describe("tokenize", () => {
|
|
4
|
+
it("lowercases, splits on non-word runs and drops one-char tokens", () => {
|
|
5
|
+
expect(tokenize("The API’s rate-limit: 5/s")).toEqual(["the", "api", "rate", "limit"]);
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it("folds English plurals so a query's form matches the page's", () => {
|
|
9
|
+
expect(tokenize("webhooks retries pages boxes patches class press")).toEqual(["webhook", "retry", "page", "box", "patch", "class", "press"]);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("bm25Rank", () => {
|
|
14
|
+
const blocks = [
|
|
15
|
+
"installation guide for the command line tool",
|
|
16
|
+
"webhook delivery retries use exponential backoff with jitter",
|
|
17
|
+
"pricing tiers and billing frequently asked questions",
|
|
18
|
+
"configuring webhook endpoints and secrets",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
it("ranks the block about the query first and drops zero-signal blocks", () => {
|
|
22
|
+
const ranked = bm25Rank(blocks, (block) => block, "webhook retry backoff");
|
|
23
|
+
expect(ranked[0]?.block).toBe(blocks[1]);
|
|
24
|
+
const kept = ranked.map((entry) => entry.block);
|
|
25
|
+
expect(kept).not.toContain(blocks[0]);
|
|
26
|
+
expect(kept).not.toContain(blocks[2]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns nothing for an empty or unmatched query", () => {
|
|
30
|
+
expect(bm25Rank(blocks, (block) => block, "")).toEqual([]);
|
|
31
|
+
expect(bm25Rank(blocks, (block) => block, "zebra")).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/lib/bm25.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Scores blocks against the query with BM25, keeping only ones with signal, in document order; headings are always
|
|
2
|
+
// kept. Follows crawl4ai's BM25ContentFilter (Apache-2.0, https://github.com/unclecode/crawl4ai). Plain BM25, k1=1.2
|
|
3
|
+
// b=0.75, no stemming.
|
|
4
|
+
|
|
5
|
+
export interface ScoredBlock<T> {
|
|
6
|
+
readonly block: T;
|
|
7
|
+
readonly score: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const tokenize = (text: string): string[] =>
|
|
11
|
+
text
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
14
|
+
.filter((token) => token.length >= 2)
|
|
15
|
+
.map(foldPlural);
|
|
16
|
+
|
|
17
|
+
// Folds English plurals (webhooks to webhook, retries to retry) instead of a real stemmer, which would add a dependency
|
|
18
|
+
// and a language commitment.
|
|
19
|
+
const foldPlural = (token: string): string => {
|
|
20
|
+
if (token.length <= 3) {
|
|
21
|
+
return token;
|
|
22
|
+
}
|
|
23
|
+
if (token.endsWith("ies")) {
|
|
24
|
+
return `${token.slice(0, -3)}y`;
|
|
25
|
+
}
|
|
26
|
+
// -es only marks the plural after a sibilant or o (boxes, patches, heroes); "pages" is page + s.
|
|
27
|
+
if (/(?:[xz]|ch|sh|o)es$/.test(token)) {
|
|
28
|
+
return token.slice(0, -2);
|
|
29
|
+
}
|
|
30
|
+
return token.endsWith("s") && !token.endsWith("ss") ? token.slice(0, -1) : token;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const K1 = 1.2;
|
|
34
|
+
const B = 0.75;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ranks `blocks` against `query`; returns every block with a positive score, highest first. Caller decides how many to
|
|
38
|
+
* keep.
|
|
39
|
+
*/
|
|
40
|
+
export const bm25Rank = <T>(blocks: readonly T[], textOf: (block: T) => string, query: string): ScoredBlock<T>[] => {
|
|
41
|
+
const queryTerms = [...new Set(tokenize(query))];
|
|
42
|
+
if (queryTerms.length === 0 || blocks.length === 0) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const docs = blocks.map((block) => tokenize(textOf(block)));
|
|
46
|
+
const avgLen = docs.reduce((sum, doc) => sum + doc.length, 0) / docs.length || 1;
|
|
47
|
+
const docFrequency = new Map<string, number>();
|
|
48
|
+
for (const term of queryTerms) {
|
|
49
|
+
docFrequency.set(term, docs.filter((doc) => doc.includes(term)).length);
|
|
50
|
+
}
|
|
51
|
+
const scored = blocks.map((block, index) => {
|
|
52
|
+
const doc = docs[index] ?? [];
|
|
53
|
+
const counts = new Map<string, number>();
|
|
54
|
+
for (const token of doc) {
|
|
55
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
let score = 0;
|
|
58
|
+
for (const term of queryTerms) {
|
|
59
|
+
const df = docFrequency.get(term) ?? 0;
|
|
60
|
+
if (df === 0) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const idf = Math.log((docs.length - df + 0.5) / (df + 0.5) + 1);
|
|
64
|
+
const tf = counts.get(term) ?? 0;
|
|
65
|
+
score += idf * ((tf * (K1 + 1)) / (tf + K1 * (1 - B + B * (doc.length / avgLen))));
|
|
66
|
+
}
|
|
67
|
+
return { block, score };
|
|
68
|
+
});
|
|
69
|
+
return scored.filter((entry) => entry.score > 0).toSorted((a, b) => b.score - a.score);
|
|
70
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// JS-rendering fallback via the image's own headless Chromium, a feature pack that may not be present; existsSync is
|
|
2
|
+
// what confirms it, not executablePath() alone. One browser per process; images, media and fonts are aborted since only
|
|
3
|
+
// markdown is read from the page.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import type { Browser } from "playwright";
|
|
6
|
+
|
|
7
|
+
export interface RenderedPage {
|
|
8
|
+
readonly finalUrl: string;
|
|
9
|
+
readonly html: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let browser: Browser | undefined;
|
|
13
|
+
|
|
14
|
+
export const chromiumAvailable = async (): Promise<boolean> => {
|
|
15
|
+
const { chromium } = await import("playwright");
|
|
16
|
+
return existsSync(chromium.executablePath());
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const renderPage = async (url: string, timeoutMs: number): Promise<RenderedPage> => {
|
|
20
|
+
const { chromium } = await import("playwright");
|
|
21
|
+
// executablePath is explicit: a bare launch reaches for the headless-shell build, absent from this image.
|
|
22
|
+
browser ??= await chromium.launch({ executablePath: chromium.executablePath(), headless: true, args: ["--no-sandbox"] });
|
|
23
|
+
const context = await browser.newContext({ javaScriptEnabled: true, viewport: { width: 1280, height: 900 } });
|
|
24
|
+
try {
|
|
25
|
+
await context.route("**/*", (route) => {
|
|
26
|
+
const kind = route.request().resourceType();
|
|
27
|
+
return kind === "image" || kind === "media" || kind === "font" ? route.abort() : route.continue();
|
|
28
|
+
});
|
|
29
|
+
const page = await context.newPage();
|
|
30
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: timeoutMs });
|
|
31
|
+
// Network-idle is a bonus with its own short deadline; a long-polling page must not hold the command hostage.
|
|
32
|
+
await page.waitForLoadState("networkidle", { timeout: Math.min(5_000, timeoutMs) }).catch(() => undefined);
|
|
33
|
+
return { finalUrl: page.url(), html: await page.content() };
|
|
34
|
+
} finally {
|
|
35
|
+
await context.close();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const closeBrowser = async (): Promise<void> => {
|
|
40
|
+
await browser?.close();
|
|
41
|
+
browser = undefined;
|
|
42
|
+
};
|
package/src/lib/cache.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/* Cache raw HTML by URL and render mode; transforms never share derived markdown. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { cacheDir } from "./env.js";
|
|
6
|
+
|
|
7
|
+
export type RenderMode = "static" | "browser";
|
|
8
|
+
|
|
9
|
+
export interface CacheEntry {
|
|
10
|
+
readonly url: string;
|
|
11
|
+
readonly finalUrl: string;
|
|
12
|
+
readonly status: number;
|
|
13
|
+
readonly contentType: string;
|
|
14
|
+
readonly body: string;
|
|
15
|
+
readonly mode: RenderMode;
|
|
16
|
+
readonly fetchedAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_MAX_AGE_S = 900;
|
|
20
|
+
|
|
21
|
+
const entryPath = (url: string, mode: RenderMode): string => {
|
|
22
|
+
const hash = createHash("sha256").update(`${mode}\n${url}`).digest("hex");
|
|
23
|
+
return join(cacheDir(), hash.slice(0, 2), `${hash}.json`);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const cacheRead = async (url: string, mode: RenderMode, maxAgeS: number): Promise<CacheEntry | undefined> => {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(entryPath(url, mode), "utf8");
|
|
29
|
+
const entry = JSON.parse(raw) as CacheEntry;
|
|
30
|
+
return Date.now() - entry.fetchedAt <= maxAgeS * 1000 ? entry : undefined;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const cacheWrite = async (entry: CacheEntry): Promise<void> => {
|
|
37
|
+
const path = entryPath(entry.url, entry.mode);
|
|
38
|
+
await mkdir(dirname(path), { recursive: true });
|
|
39
|
+
await writeFile(path, JSON.stringify(entry));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const cacheClear = async (): Promise<void> => {
|
|
43
|
+
await rm(cacheDir(), { recursive: true, force: true });
|
|
44
|
+
};
|
package/src/lib/crawl.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Bounded crawl: BFS by default, best-first once a query scores anchor/URL word overlap (best-first and pruning follow
|
|
2
|
+
// crawl4ai, Apache-2.0). Caps (max-pages, depth, robots.txt) are a contract: everything skipped returns as a per-reason
|
|
3
|
+
// count. Shares `webq fetch`'s page pipeline and cache.
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { errorMessage } from "@intentic/base/errors";
|
|
6
|
+
import { tokenize } from "./bm25.js";
|
|
7
|
+
import { closeBrowser } from "./browser.js";
|
|
8
|
+
import { httpFetch } from "./http.js";
|
|
9
|
+
import { normalizeUrl, sameOrigin } from "./links.js";
|
|
10
|
+
import { saveIndex, savePage, type IndexEntry } from "./output.js";
|
|
11
|
+
import { fetchPage, type FetchPageOptions } from "./page.js";
|
|
12
|
+
import { EVERYTHING_ALLOWED, isAllowed, parseRobots, type RobotsRules } from "./robots.js";
|
|
13
|
+
import { fetchSitemapUrls } from "./sitemap.js";
|
|
14
|
+
|
|
15
|
+
export interface CrawlOptions extends FetchPageOptions {
|
|
16
|
+
readonly maxPages?: number;
|
|
17
|
+
readonly maxDepth?: number;
|
|
18
|
+
readonly concurrency?: number;
|
|
19
|
+
/** Off only when the caller explicitly takes responsibility (--ignore-robots). */
|
|
20
|
+
readonly robots?: boolean;
|
|
21
|
+
readonly sitemap?: boolean;
|
|
22
|
+
readonly external?: boolean;
|
|
23
|
+
/** Substring (or *-glob) filters over the URL; include must match when given, exclude must not. */
|
|
24
|
+
readonly include?: string[];
|
|
25
|
+
readonly exclude?: string[];
|
|
26
|
+
readonly delayMs?: number;
|
|
27
|
+
readonly outDir: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CrawlPageReport extends IndexEntry {
|
|
31
|
+
readonly source: string;
|
|
32
|
+
readonly notes: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CrawlReport {
|
|
36
|
+
readonly pages: CrawlPageReport[];
|
|
37
|
+
readonly skipped: Record<string, number>;
|
|
38
|
+
// Each page counted under `skipped.errors`, with what failed: a count alone cannot tell a flaky site from a full disk.
|
|
39
|
+
readonly failures: ReadonlyArray<{ readonly url: string; readonly reason: string }>;
|
|
40
|
+
readonly indexMarkdown: string;
|
|
41
|
+
readonly indexJson: string;
|
|
42
|
+
readonly sitemapCapped: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Candidate {
|
|
46
|
+
readonly url: string;
|
|
47
|
+
depth: number;
|
|
48
|
+
score: number;
|
|
49
|
+
readonly order: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MAX_CRAWL_DELAY_MS = 10_000;
|
|
53
|
+
|
|
54
|
+
export const crawl = async (startUrl: string, options: CrawlOptions): Promise<CrawlReport> => {
|
|
55
|
+
const maxPages = options.maxPages ?? 20;
|
|
56
|
+
const maxDepth = options.maxDepth ?? 2;
|
|
57
|
+
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
58
|
+
const queryTerms = options.query === undefined ? [] : [...new Set(tokenize(options.query))];
|
|
59
|
+
const skipped = { robots: 0, offsite: 0, filtered: 0, "beyond-depth": 0, "beyond-cap": 0, "http-errors": 0, errors: 0 };
|
|
60
|
+
|
|
61
|
+
const robots = options.robots === false ? EVERYTHING_ALLOWED : await loadRobots(startUrl, options.timeoutMs);
|
|
62
|
+
const delayMs = Math.max(options.delayMs ?? 0, Math.min((robots.crawlDelayS ?? 0) * 1000, MAX_CRAWL_DELAY_MS));
|
|
63
|
+
|
|
64
|
+
const taken = new Set<string>();
|
|
65
|
+
const pending = new Map<string, Candidate>();
|
|
66
|
+
let order = 0;
|
|
67
|
+
const admit = (raw: string | undefined, depth: number, anchorText: string): void => {
|
|
68
|
+
const url = normalizeUrl(raw);
|
|
69
|
+
if (url === undefined || taken.has(url)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const queued = pending.get(url);
|
|
73
|
+
if (queued !== undefined) {
|
|
74
|
+
// A URL reached through a better link (real anchor text) can promote a candidate the sitemap seeded blind.
|
|
75
|
+
const score = scoreCandidate(url, anchorText, depth, queryTerms);
|
|
76
|
+
if (score > queued.score) {
|
|
77
|
+
queued.score = score;
|
|
78
|
+
queued.depth = Math.min(queued.depth, depth);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (options.external !== true && !sameOrigin(url, startUrl)) {
|
|
83
|
+
skipped.offsite += 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!passesFilters(url, options.include, options.exclude)) {
|
|
87
|
+
skipped.filtered += 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (options.robots !== false && !isAllowed(robots, pathOf(url))) {
|
|
91
|
+
skipped.robots += 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (depth > maxDepth) {
|
|
95
|
+
skipped["beyond-depth"] += 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
pending.set(url, { url, depth, score: scoreCandidate(url, anchorText, depth, queryTerms), order: order++ });
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
admit(startUrl, 0, "");
|
|
102
|
+
let sitemapCapped = false;
|
|
103
|
+
if (options.sitemap === true) {
|
|
104
|
+
const seed = await fetchSitemapUrls(startUrl, robots.sitemaps, sitemapFetcher(options.timeoutMs));
|
|
105
|
+
sitemapCapped = seed.capped;
|
|
106
|
+
for (const url of seed.urls) {
|
|
107
|
+
admit(url, 1, "");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pages: CrawlPageReport[] = [];
|
|
112
|
+
const failures: { url: string; reason: string }[] = [];
|
|
113
|
+
const fetchedAt = new Date();
|
|
114
|
+
let inFlight = 0;
|
|
115
|
+
let landed = 0;
|
|
116
|
+
|
|
117
|
+
// Works the best candidate until the frontier is idle or the cap is hit; the rest then count as beyond-cap.
|
|
118
|
+
await new Promise<void>((resolve) => {
|
|
119
|
+
const pump = (): void => {
|
|
120
|
+
if (landed >= maxPages || (pending.size === 0 && inFlight === 0)) {
|
|
121
|
+
if (inFlight === 0) {
|
|
122
|
+
skipped["beyond-cap"] += landed >= maxPages ? pending.size : 0;
|
|
123
|
+
resolve();
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Start query crawls with one page so anchor scores exist before fan-out.
|
|
128
|
+
const width = queryTerms.length > 0 && landed === 0 ? 1 : concurrency;
|
|
129
|
+
while (inFlight < width && pending.size > 0 && landed + inFlight < maxPages) {
|
|
130
|
+
const next = takeBest(pending);
|
|
131
|
+
taken.add(next.url);
|
|
132
|
+
inFlight += 1;
|
|
133
|
+
void workOne(next).finally(() => {
|
|
134
|
+
inFlight -= 1;
|
|
135
|
+
pump();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const workOne = async (candidate: Candidate): Promise<void> => {
|
|
140
|
+
try {
|
|
141
|
+
if (delayMs > 0) {
|
|
142
|
+
await sleep(delayMs);
|
|
143
|
+
}
|
|
144
|
+
const page = await fetchPage(candidate.url, options);
|
|
145
|
+
// A 4xx/5xx is a broken link, not a page: counted but never written to the index.
|
|
146
|
+
if (page.status >= 400) {
|
|
147
|
+
skipped["http-errors"] += 1;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const saved = await savePage(options.outDir, page, fetchedAt);
|
|
151
|
+
landed += 1;
|
|
152
|
+
pages.push({
|
|
153
|
+
url: candidate.url,
|
|
154
|
+
title: page.meta.title,
|
|
155
|
+
path: saved.path,
|
|
156
|
+
tokens: saved.tokens,
|
|
157
|
+
depth: candidate.depth,
|
|
158
|
+
source: page.source,
|
|
159
|
+
notes: page.notes,
|
|
160
|
+
});
|
|
161
|
+
for (const link of page.links) {
|
|
162
|
+
admit(link.url, candidate.depth + 1, link.text);
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
skipped.errors += 1;
|
|
166
|
+
failures.push({ url: candidate.url, reason: errorMessage(error).split("\n")[0] ?? "" });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
pump();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await closeBrowser();
|
|
173
|
+
const index = await saveIndex(options.outDir, startUrl, pages, skipped);
|
|
174
|
+
return { pages, skipped, failures, indexMarkdown: index.markdownPath, indexJson: index.jsonPath, sitemapCapped };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const takeBest = (pending: Map<string, Candidate>): Candidate => {
|
|
178
|
+
let best: Candidate | undefined;
|
|
179
|
+
for (const candidate of pending.values()) {
|
|
180
|
+
if (best === undefined || candidate.score > best.score || (candidate.score === best.score && candidate.order < best.order)) {
|
|
181
|
+
best = candidate;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
pending.delete((best as Candidate).url);
|
|
185
|
+
return best as Candidate;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Without a query this is plain BFS (depth only). With one, a link whose anchor text or URL echoes the query's words is
|
|
189
|
+
// visited before its siblings.
|
|
190
|
+
const scoreCandidate = (url: string, anchorText: string, depth: number, queryTerms: string[]): number => {
|
|
191
|
+
if (queryTerms.length === 0) {
|
|
192
|
+
return -depth;
|
|
193
|
+
}
|
|
194
|
+
const haystack = new Set(tokenize(`${anchorText} ${pathOf(url).replaceAll(/[/\-_.]/g, " ")}`));
|
|
195
|
+
const overlap = queryTerms.filter((term) => haystack.has(term)).length / queryTerms.length;
|
|
196
|
+
return overlap - depth * 0.05;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const passesFilters = (url: string, include: string[] | undefined, exclude: string[] | undefined): boolean => {
|
|
200
|
+
const test = (pattern: string): boolean =>
|
|
201
|
+
pattern.includes("*")
|
|
202
|
+
? new RegExp(pattern.replaceAll(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("\\*", ".*")).test(url)
|
|
203
|
+
: url.includes(pattern);
|
|
204
|
+
if (include !== undefined && include.length > 0 && !include.some(test)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return !(exclude ?? []).some(test);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const pathOf = (url: string): string => {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = new URL(url);
|
|
213
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
214
|
+
} catch {
|
|
215
|
+
return url;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const loadRobots = async (startUrl: string, timeoutMs: number | undefined): Promise<RobotsRules> => {
|
|
220
|
+
try {
|
|
221
|
+
const origin = new URL(startUrl).origin;
|
|
222
|
+
const response = await httpFetch(`${origin}/robots.txt`, { timeoutMs: Math.min(timeoutMs ?? 10_000, 10_000) });
|
|
223
|
+
// A missing robots.txt allows everything; an unreachable site fails loudly on the first page fetch instead.
|
|
224
|
+
return response.status >= 200 && response.status < 300 ? parseRobots(response.body) : EVERYTHING_ALLOWED;
|
|
225
|
+
} catch {
|
|
226
|
+
return EVERYTHING_ALLOWED;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const sitemapFetcher =
|
|
231
|
+
(timeoutMs: number | undefined) =>
|
|
232
|
+
async (url: string): Promise<{ body: string; bytes?: Uint8Array } | undefined> => {
|
|
233
|
+
try {
|
|
234
|
+
if (url.endsWith(".gz")) {
|
|
235
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs ?? 10_000) });
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
240
|
+
return { body: "", bytes };
|
|
241
|
+
}
|
|
242
|
+
const response = await httpFetch(url, { timeoutMs: timeoutMs ?? 10_000 });
|
|
243
|
+
return response.status >= 200 && response.status < 300 ? { body: response.body } : undefined;
|
|
244
|
+
} catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
};
|