@hraness/kb 0.17.1
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/DISCLOSURE +43 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/agent-context.js +35 -0
- package/dist/agent-guide-audit.js +18 -0
- package/dist/attachments.js +19 -0
- package/dist/authoring.js +33 -0
- package/dist/benchmark.js +19 -0
- package/dist/capture.js +49 -0
- package/dist/cli.js +3646 -0
- package/dist/clip/acquire.js +57 -0
- package/dist/clip/args.js +21 -0
- package/dist/clip/bounded-byte-buffer.js +9 -0
- package/dist/clip/browser-profiles.js +17 -0
- package/dist/clip/bundle-reader.js +11 -0
- package/dist/clip/cli.js +27 -0
- package/dist/clip/cookies.js +21 -0
- package/dist/clip/defuddle-worker.js +84 -0
- package/dist/clip/doctor.js +32 -0
- package/dist/clip/jobs.js +784 -0
- package/dist/clip/network-proxy.js +9 -0
- package/dist/clip/network.js +28 -0
- package/dist/clip/persist.js +30 -0
- package/dist/clip/refresh.js +11 -0
- package/dist/clip/terminal.js +11 -0
- package/dist/evaluation-builder.js +12238 -0
- package/dist/evaluation-kb.js +26 -0
- package/dist/evaluation.js +35 -0
- package/dist/git.js +39 -0
- package/dist/graph.js +39 -0
- package/dist/index-01jj6rbv.js +1103 -0
- package/dist/index-06c9ctr6.js +738 -0
- package/dist/index-0kavxzqj.js +2480 -0
- package/dist/index-1gwbassd.js +743 -0
- package/dist/index-1n418kb9.js +827 -0
- package/dist/index-1vrd1rmn.js +56 -0
- package/dist/index-1xxnjn0d.js +171 -0
- package/dist/index-3rm7cz6h.js +373 -0
- package/dist/index-3v2z4f0q.js +328 -0
- package/dist/index-48pz4jpc.js +392 -0
- package/dist/index-4j3tt0c3.js +234 -0
- package/dist/index-5n05se68.js +307 -0
- package/dist/index-5vwpzb5a.js +764 -0
- package/dist/index-6g2pv9d2.js +370 -0
- package/dist/index-7fzc32gf.js +712 -0
- package/dist/index-84x0vjjp.js +432 -0
- package/dist/index-adx6khj5.js +488 -0
- package/dist/index-b88v3vtm.js +805 -0
- package/dist/index-cv6fh7z5.js +462 -0
- package/dist/index-cxfrakt7.js +1067 -0
- package/dist/index-d13v9ckt.js +150 -0
- package/dist/index-dycjbhw2.js +1943 -0
- package/dist/index-dyqwejk5.js +531 -0
- package/dist/index-e5fbsywq.js +658 -0
- package/dist/index-f984hw45.js +927 -0
- package/dist/index-gh719d91.js +63 -0
- package/dist/index-hgve9rh2.js +2046 -0
- package/dist/index-hya40gb2.js +474 -0
- package/dist/index-j4zgmzjr.js +152 -0
- package/dist/index-jsmvyyvf.js +1166 -0
- package/dist/index-kh0pwvdd.js +1974 -0
- package/dist/index-mqx4nd6v.js +119 -0
- package/dist/index-mxxxytys.js +1254 -0
- package/dist/index-n5dd7r0v.js +711 -0
- package/dist/index-npg9z1a4.js +431 -0
- package/dist/index-pj501bh1.js +140 -0
- package/dist/index-qry4vhxk.js +1041 -0
- package/dist/index-s2gw5aw9.js +199 -0
- package/dist/index-vvdj6kca.js +70 -0
- package/dist/index-w2zc0vwa.js +571 -0
- package/dist/index-x3fthpsc.js +585 -0
- package/dist/index-zr53sf63.js +44 -0
- package/dist/index-zxdy5pby.js +2728 -0
- package/dist/index-zzhgcwyt.js +966 -0
- package/dist/index.js +453 -0
- package/dist/navigation.js +13 -0
- package/dist/pdf.js +85 -0
- package/dist/percolate.js +24 -0
- package/dist/portfolio.js +77 -0
- package/dist/query.js +29 -0
- package/dist/repository-memory.js +60 -0
- package/dist/sdk.js +36 -0
- package/dist/search-rules.js +34 -0
- package/dist/search.js +22 -0
- package/dist/semantic.js +39 -0
- package/dist/source-inbox.js +17 -0
- package/dist/untrusted-content.js +32 -0
- package/dist/url-intelligence.js +74 -0
- package/dist/workflow.js +23 -0
- package/dist/workflows/decision-context.js +20 -0
- package/dist/workflows/explain-change.js +8 -0
- package/dist/workflows/index.js +28 -0
- package/dist/workflows/plan-radar.js +10 -0
- package/package.json +409 -0
- package/skills/kb/AGENTS.md +19 -0
- package/skills/kb/SKILL.md +82 -0
- package/skills/kb/agents/openai.yaml +4 -0
- package/skills/kb/references/pdf-review.md +51 -0
- package/skills/kb/references/percolate.md +144 -0
- package/skills/kb/references/plan-structure.md +105 -0
- package/skills/kb/references/plan.md +115 -0
- package/skills/kb/references/query.md +231 -0
- package/skills/kb/references/refresh.md +133 -0
- package/skills/kb/references/save-pdf.md +125 -0
- package/skills/kb/references/save-url.md +178 -0
- package/skills/kb/references/url-authentication.md +75 -0
- package/skills/kb/references/url-platforms.md +25 -0
- package/src/agent-context.ts +1241 -0
- package/src/agent-guide-audit.ts +750 -0
- package/src/attachments.ts +723 -0
- package/src/authoring.ts +1725 -0
- package/src/benchmark.ts +362 -0
- package/src/capture.ts +68 -0
- package/src/cli.ts +3660 -0
- package/src/clip/acquire.ts +1273 -0
- package/src/clip/archive-today.ts +402 -0
- package/src/clip/args.ts +423 -0
- package/src/clip/assets.ts +262 -0
- package/src/clip/bounded-byte-buffer.ts +75 -0
- package/src/clip/browser-profiles.ts +372 -0
- package/src/clip/bundle-reader.ts +608 -0
- package/src/clip/capture.ts +1054 -0
- package/src/clip/cli.ts +181 -0
- package/src/clip/cookies.ts +512 -0
- package/src/clip/doctor.ts +1110 -0
- package/src/clip/extract.ts +868 -0
- package/src/clip/jobs.ts +959 -0
- package/src/clip/lib.ts +861 -0
- package/src/clip/media.ts +1474 -0
- package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
- package/src/clip/metadata-search-tool/Cargo.toml +18 -0
- package/src/clip/metadata-search-tool/runner.ts +685 -0
- package/src/clip/metadata-search-tool/src/main.rs +389 -0
- package/src/clip/metadata-search.ts +687 -0
- package/src/clip/network-proxy.ts +627 -0
- package/src/clip/network.ts +950 -0
- package/src/clip/package-root.ts +54 -0
- package/src/clip/persist.ts +1472 -0
- package/src/clip/platforms.ts +1824 -0
- package/src/clip/refresh.ts +197 -0
- package/src/clip/structured.ts +432 -0
- package/src/clip/terminal.ts +197 -0
- package/src/clip/url-intelligence.ts +581 -0
- package/src/clip/url-metadata-backfill.ts +616 -0
- package/src/clip/url-metadata-cli.ts +251 -0
- package/src/clip/url-metadata.ts +1397 -0
- package/src/evaluation-analysis-v2.ts +1440 -0
- package/src/evaluation-builder.ts +2539 -0
- package/src/evaluation-corpus-authoring.ts +2052 -0
- package/src/evaluation-evidence.ts +1147 -0
- package/src/evaluation-execution-v2.ts +426 -0
- package/src/evaluation-implementation.ts +131 -0
- package/src/evaluation-kb-closure.ts +1430 -0
- package/src/evaluation-kb-runner-v2.ts +735 -0
- package/src/evaluation-kb-v2.ts +2567 -0
- package/src/evaluation-kb.ts +1018 -0
- package/src/evaluation-measurement-v2.ts +1462 -0
- package/src/evaluation-packing-v2.ts +452 -0
- package/src/evaluation-redaction.ts +28 -0
- package/src/evaluation-v2.ts +3857 -0
- package/src/evaluation.ts +1220 -0
- package/src/git.ts +1276 -0
- package/src/graph.ts +1487 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/navigation.ts +277 -0
- package/src/note-lock.ts +529 -0
- package/src/pdf/args.ts +202 -0
- package/src/pdf/capture.ts +298 -0
- package/src/pdf/cli.ts +159 -0
- package/src/pdf/extract.ts +303 -0
- package/src/pdf/layout.ts +618 -0
- package/src/pdf/markdown.ts +154 -0
- package/src/pdf/model.ts +234 -0
- package/src/pdf/ocr.ts +190 -0
- package/src/pdf/persist.ts +314 -0
- package/src/pdf/source.ts +135 -0
- package/src/pdf/tools.ts +146 -0
- package/src/pdf.ts +92 -0
- package/src/percolate.ts +877 -0
- package/src/portfolio-audit.ts +574 -0
- package/src/portfolio-identity.ts +162 -0
- package/src/portfolio-registry.ts +473 -0
- package/src/portfolio.ts +765 -0
- package/src/query.ts +646 -0
- package/src/repository-memory.ts +1150 -0
- package/src/sdk.ts +1452 -0
- package/src/search-rules.ts +712 -0
- package/src/search.ts +710 -0
- package/src/semantic-runtime.ts +1139 -0
- package/src/semantic.ts +2480 -0
- package/src/source-inbox.ts +232 -0
- package/src/untrusted-content.ts +401 -0
- package/src/url-intelligence.ts +104 -0
- package/src/vault.ts +636 -0
- package/src/workflow.ts +653 -0
- package/src/workflows/decision-context.ts +81 -0
- package/src/workflows/explain-change.ts +73 -0
- package/src/workflows/index.ts +3 -0
- package/src/workflows/plan-radar.ts +101 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
import type { CaptureScope } from "./args.js";
|
|
2
|
+
import type { AcquiredPage } from "./acquire.js";
|
|
3
|
+
import {
|
|
4
|
+
articleMetadataLimits,
|
|
5
|
+
resolveRemote,
|
|
6
|
+
scanImageSources,
|
|
7
|
+
type Article,
|
|
8
|
+
} from "./lib.js";
|
|
9
|
+
import { classifyPlatformUrl, type Platform as ClassifiedPlatform } from "./platforms.js";
|
|
10
|
+
|
|
11
|
+
export type CaptureStatus =
|
|
12
|
+
| "complete"
|
|
13
|
+
| "partial"
|
|
14
|
+
| "auth-required"
|
|
15
|
+
| "blocked"
|
|
16
|
+
| "unsupported";
|
|
17
|
+
|
|
18
|
+
export type Platform = ClassifiedPlatform;
|
|
19
|
+
|
|
20
|
+
export type ExtractedPage = {
|
|
21
|
+
readonly article: Article;
|
|
22
|
+
readonly canonicalUrl: URL;
|
|
23
|
+
readonly platform: Platform;
|
|
24
|
+
readonly status: CaptureStatus;
|
|
25
|
+
readonly score: number;
|
|
26
|
+
readonly wordCount: number;
|
|
27
|
+
readonly expectedItems: number | null;
|
|
28
|
+
readonly capturedItems: number;
|
|
29
|
+
readonly extractor: string;
|
|
30
|
+
readonly warnings: readonly string[];
|
|
31
|
+
readonly acquisition: AcquiredPage;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const nonEmpty = (value: unknown): string | null =>
|
|
35
|
+
typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
36
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
37
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
|
|
39
|
+
export const MAX_RENDERED_PAGE_FALLBACK_BYTES = 256 * 1024;
|
|
40
|
+
const renderedPageTruncationMarker = "[Rendered page text truncated at the bounded fallback limit.]";
|
|
41
|
+
|
|
42
|
+
type BoundedRenderedText = {
|
|
43
|
+
readonly content: string;
|
|
44
|
+
readonly truncated: boolean;
|
|
45
|
+
readonly byteLimit: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function isWhitespaceCodeUnit(code: number): boolean {
|
|
49
|
+
return (code >= 0x0009 && code <= 0x000d)
|
|
50
|
+
|| code === 0x0020
|
|
51
|
+
|| code === 0x00a0
|
|
52
|
+
|| code === 0x1680
|
|
53
|
+
|| (code >= 0x2000 && code <= 0x200a)
|
|
54
|
+
|| code === 0x2028
|
|
55
|
+
|| code === 0x2029
|
|
56
|
+
|| code === 0x202f
|
|
57
|
+
|| code === 0x205f
|
|
58
|
+
|| code === 0x3000
|
|
59
|
+
|| code === 0xfeff;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function utf8CodePointWidth(value: string, index: number): { readonly bytes: number; readonly codeUnits: number } {
|
|
63
|
+
const first = value.charCodeAt(index);
|
|
64
|
+
if (first <= 0x7f) return { bytes: 1, codeUnits: 1 };
|
|
65
|
+
if (first <= 0x7ff) return { bytes: 2, codeUnits: 1 };
|
|
66
|
+
const second = value.charCodeAt(index + 1);
|
|
67
|
+
if (first >= 0xd800 && first <= 0xdbff && second >= 0xdc00 && second <= 0xdfff) {
|
|
68
|
+
return { bytes: 4, codeUnits: 2 };
|
|
69
|
+
}
|
|
70
|
+
return { bytes: 3, codeUnits: 1 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function utf8PrefixEnd(value: string, maxBytes: number): number {
|
|
74
|
+
let bytes = 0;
|
|
75
|
+
let index = 0;
|
|
76
|
+
while (index < value.length) {
|
|
77
|
+
const width = utf8CodePointWidth(value, index);
|
|
78
|
+
if (bytes + width.bytes > maxBytes) break;
|
|
79
|
+
bytes += width.bytes;
|
|
80
|
+
index += width.codeUnits;
|
|
81
|
+
}
|
|
82
|
+
return index;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Bound unstructured browser text before it can become retained page content. */
|
|
86
|
+
export function boundedRenderedPageText(value: unknown, requestedByteLimit?: unknown): BoundedRenderedText | null {
|
|
87
|
+
if (typeof value !== "string") return null;
|
|
88
|
+
const byteLimit = typeof requestedByteLimit === "number"
|
|
89
|
+
&& Number.isSafeInteger(requestedByteLimit)
|
|
90
|
+
&& requestedByteLimit > 0
|
|
91
|
+
? Math.min(requestedByteLimit, MAX_RENDERED_PAGE_FALLBACK_BYTES)
|
|
92
|
+
: MAX_RENDERED_PAGE_FALLBACK_BYTES;
|
|
93
|
+
const fullEnd = utf8PrefixEnd(value, byteLimit);
|
|
94
|
+
if (fullEnd === value.length) {
|
|
95
|
+
const content = value.trim();
|
|
96
|
+
return content === "" ? null : { content, truncated: false, byteLimit };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const detailedMarker = `\n\n${renderedPageTruncationMarker}\n`;
|
|
100
|
+
const detailedMarkerBytes = new TextEncoder().encode(detailedMarker).byteLength;
|
|
101
|
+
const marker = detailedMarkerBytes < byteLimit
|
|
102
|
+
? detailedMarker
|
|
103
|
+
: byteLimit >= 3 ? "…" : ".".repeat(byteLimit);
|
|
104
|
+
const markerBytes = new TextEncoder().encode(marker).byteLength;
|
|
105
|
+
const boundedEnd = utf8PrefixEnd(value, byteLimit - markerBytes);
|
|
106
|
+
const prefix = value.slice(0, boundedEnd).trim();
|
|
107
|
+
if (prefix === "") return null;
|
|
108
|
+
return { content: `${prefix}${marker}`, truncated: true, byteLimit };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function boundedTrimmedSlice(value: string, start: number, end: number, maxCodeUnits: number): string | null {
|
|
112
|
+
while (start < end && isWhitespaceCodeUnit(value.charCodeAt(start))) start += 1;
|
|
113
|
+
while (end > start && isWhitespaceCodeUnit(value.charCodeAt(end - 1))) end -= 1;
|
|
114
|
+
if (start === end) return null;
|
|
115
|
+
if (end - start <= maxCodeUnits) return value.slice(start, end);
|
|
116
|
+
let boundedEnd = start + Math.max(0, maxCodeUnits - 1);
|
|
117
|
+
const finalCode = value.charCodeAt(boundedEnd - 1);
|
|
118
|
+
if (finalCode >= 0xd800 && finalCode <= 0xdbff) boundedEnd -= 1;
|
|
119
|
+
return `${value.slice(start, boundedEnd)}…`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function boundedMetadata(value: unknown, maxCodeUnits: number): string | null {
|
|
123
|
+
return typeof value === "string"
|
|
124
|
+
? boundedTrimmedSlice(value, 0, value.length, maxCodeUnits)
|
|
125
|
+
: null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Count ECMAScript whitespace-delimited words without materializing a token array. */
|
|
129
|
+
export function countWords(value: string): number {
|
|
130
|
+
let count = 0;
|
|
131
|
+
let insideWord = false;
|
|
132
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
133
|
+
if (isWhitespaceCodeUnit(value.charCodeAt(index))) {
|
|
134
|
+
insideWord = false;
|
|
135
|
+
} else if (!insideWord) {
|
|
136
|
+
count += 1;
|
|
137
|
+
insideWord = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return count;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isAsciiWordCodeUnit(code: number): boolean {
|
|
144
|
+
return (code >= 0x30 && code <= 0x39)
|
|
145
|
+
|| (code >= 0x41 && code <= 0x5a)
|
|
146
|
+
|| code === 0x5f
|
|
147
|
+
|| (code >= 0x61 && code <= 0x7a);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function asciiCaseEqualAt(value: string, offset: number, expected: string, end = value.length): boolean {
|
|
151
|
+
if (offset < 0 || offset + expected.length > end) return false;
|
|
152
|
+
for (let index = 0; index < expected.length; index += 1) {
|
|
153
|
+
const actual = value.charCodeAt(offset + index);
|
|
154
|
+
const folded = actual >= 0x41 && actual <= 0x5a ? actual + 0x20 : actual;
|
|
155
|
+
if (folded !== expected.charCodeAt(index)) return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function tagHasExactCommentClass(html: string, start: number, end: number): boolean {
|
|
161
|
+
const doubleQuoted = 'class="comment"';
|
|
162
|
+
const singleQuoted = "class='comment'";
|
|
163
|
+
for (let index = start; index < end; index += 1) {
|
|
164
|
+
const preceding = index === 0 ? -1 : html.charCodeAt(index - 1);
|
|
165
|
+
if (preceding >= 0 && isAsciiWordCodeUnit(preceding)) continue;
|
|
166
|
+
if (
|
|
167
|
+
asciiCaseEqualAt(html, index, doubleQuoted, end)
|
|
168
|
+
|| asciiCaseEqualAt(html, index, singleQuoted, end)
|
|
169
|
+
) return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function countDefuddleCommentMarkers(html: string): number {
|
|
175
|
+
let count = 0;
|
|
176
|
+
let cursor = 0;
|
|
177
|
+
while (cursor < html.length) {
|
|
178
|
+
const start = html.indexOf("<", cursor);
|
|
179
|
+
if (start < 0) break;
|
|
180
|
+
cursor = start + 1;
|
|
181
|
+
if (!asciiCaseEqualAt(html, start + 1, "div")) continue;
|
|
182
|
+
const afterName = start + 4;
|
|
183
|
+
if (afterName < html.length && isAsciiWordCodeUnit(html.charCodeAt(afterName))) continue;
|
|
184
|
+
const end = html.indexOf(">", afterName);
|
|
185
|
+
if (end < 0) break;
|
|
186
|
+
cursor = end + 1;
|
|
187
|
+
if (tagHasExactCommentClass(html, afterName, end)) count += 1;
|
|
188
|
+
}
|
|
189
|
+
return count;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function countDefuddleSeparators(html: string): number {
|
|
193
|
+
let count = 0;
|
|
194
|
+
let cursor = 0;
|
|
195
|
+
while (cursor < html.length) {
|
|
196
|
+
const start = html.indexOf("<", cursor);
|
|
197
|
+
if (start < 0) break;
|
|
198
|
+
cursor = start + 1;
|
|
199
|
+
if (!asciiCaseEqualAt(html, start + 1, "hr")) continue;
|
|
200
|
+
const afterName = start + 3;
|
|
201
|
+
const next = html.charCodeAt(afterName);
|
|
202
|
+
if (next === 0x3e) {
|
|
203
|
+
count += 1;
|
|
204
|
+
cursor = afterName + 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (!isWhitespaceCodeUnit(next)) continue;
|
|
208
|
+
const end = html.indexOf(">", afterName + 1);
|
|
209
|
+
if (end < 0) break;
|
|
210
|
+
count += 1;
|
|
211
|
+
cursor = end + 1;
|
|
212
|
+
}
|
|
213
|
+
return count;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function countMarkdownMarkers(value: string, kind: "image" | "link", limit: number): number {
|
|
217
|
+
const prefix = kind === "image" ? "![" : "[";
|
|
218
|
+
let count = 0;
|
|
219
|
+
let cursor = 0;
|
|
220
|
+
while (count < limit && cursor < value.length) {
|
|
221
|
+
const start = value.indexOf(prefix, cursor);
|
|
222
|
+
if (start < 0) break;
|
|
223
|
+
const openBracket = kind === "image" ? start + 1 : start;
|
|
224
|
+
const closeBracket = value.indexOf("]", openBracket + 1);
|
|
225
|
+
if (closeBracket < 0) break;
|
|
226
|
+
const nonEmptyLinkLabel = kind === "image" || closeBracket > openBracket + 1;
|
|
227
|
+
if (nonEmptyLinkLabel && value.charCodeAt(closeBracket + 1) === 0x28) {
|
|
228
|
+
count += 1;
|
|
229
|
+
cursor = closeBracket + 2;
|
|
230
|
+
} else {
|
|
231
|
+
cursor = start + prefix.length;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return count;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
type DefuddleWorkerResult =
|
|
238
|
+
| { readonly ok: true; readonly value: Record<string, unknown> }
|
|
239
|
+
| { readonly ok: false; readonly message: string };
|
|
240
|
+
|
|
241
|
+
export function defuddleWorkerUrl(moduleUrl = import.meta.url): URL {
|
|
242
|
+
return moduleUrl.endsWith(".ts")
|
|
243
|
+
? new URL("./defuddle-worker.ts", moduleUrl)
|
|
244
|
+
: new URL("./clip/defuddle-worker.js", moduleUrl);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function runDefuddleWorker(
|
|
248
|
+
acquisition: AcquiredPage,
|
|
249
|
+
scope: CaptureScope,
|
|
250
|
+
timeoutMs: number,
|
|
251
|
+
): Promise<Record<string, unknown>> {
|
|
252
|
+
const worker = new Worker(defuddleWorkerUrl().href, { type: "module" });
|
|
253
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
254
|
+
try {
|
|
255
|
+
const result = await new Promise<DefuddleWorkerResult>((resolve, reject) => {
|
|
256
|
+
timeout = setTimeout(() => {
|
|
257
|
+
reject(new Error(`Defuddle exceeded the ${timeoutMs}ms extraction deadline.`));
|
|
258
|
+
}, timeoutMs);
|
|
259
|
+
worker.onmessage = (event: MessageEvent<unknown>): void => {
|
|
260
|
+
const message = event.data;
|
|
261
|
+
if (!isRecord(message) || typeof message.ok !== "boolean") {
|
|
262
|
+
reject(new Error("Defuddle worker returned malformed data."));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (message.ok === true && isRecord(message.value)) {
|
|
266
|
+
resolve({ ok: true, value: message.value });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
resolve({
|
|
270
|
+
ok: false,
|
|
271
|
+
message: typeof message.message === "string"
|
|
272
|
+
? message.message.slice(0, 1_000)
|
|
273
|
+
: "Defuddle worker failed.",
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
worker.onerror = (): void => reject(new Error("Defuddle worker failed."));
|
|
277
|
+
worker.postMessage({
|
|
278
|
+
html: acquisition.body,
|
|
279
|
+
url: acquisition.finalUrl.href,
|
|
280
|
+
includeReplies: scope === "page" ? false : scope === "comments" ? true : "extractors",
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
if (!result.ok) throw new Error(result.message);
|
|
284
|
+
return result.value;
|
|
285
|
+
} finally {
|
|
286
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
287
|
+
worker.terminate();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function detectPlatform(url: URL): Platform {
|
|
292
|
+
return classifyPlatformUrl(url.href)?.platform ?? "generic";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function detectedExtractorPlatform(value: unknown, fallback: Platform): Platform {
|
|
296
|
+
if (fallback !== "generic") return fallback;
|
|
297
|
+
return value === "github" ? "github" : value === "discourse" ? "discourse" : fallback;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const trackingKeys = new Set([
|
|
301
|
+
"fbclid",
|
|
302
|
+
"gclid",
|
|
303
|
+
"dclid",
|
|
304
|
+
"msclkid",
|
|
305
|
+
"mc_cid",
|
|
306
|
+
"mc_eid",
|
|
307
|
+
"igshid",
|
|
308
|
+
"mibextid",
|
|
309
|
+
]);
|
|
310
|
+
const credentialQueryKey = /(?:^|[-_])(?:access[-_]?token|refresh[-_]?token|auth(?:orization)?|api[-_]?key|credential|csrf|xsrf|jwt|pass(?:word|wd)?|secret|session[-_]?id|signature|sig|code|ticket|otp|nonce|key|magic[-_]?link|one[-_]?time)(?:$|[-_])/i;
|
|
311
|
+
|
|
312
|
+
/** Remove known tracking parameters while preserving content-bearing query state. */
|
|
313
|
+
export function canonicalizeUrl(url: URL, platform = detectPlatform(url)): URL {
|
|
314
|
+
const canonical = new URL(url);
|
|
315
|
+
canonical.hash = "";
|
|
316
|
+
for (const key of [...canonical.searchParams.keys()]) {
|
|
317
|
+
if (
|
|
318
|
+
key.toLowerCase().startsWith("utm_")
|
|
319
|
+
|| trackingKeys.has(key.toLowerCase())
|
|
320
|
+
|| credentialQueryKey.test(key)
|
|
321
|
+
) {
|
|
322
|
+
canonical.searchParams.delete(key);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (platform === "x") {
|
|
326
|
+
canonical.hostname = "x.com";
|
|
327
|
+
canonical.searchParams.delete("s");
|
|
328
|
+
canonical.searchParams.delete("t");
|
|
329
|
+
}
|
|
330
|
+
return canonical;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const MAX_SCHEMA_COMMENT_NODES = 50_000;
|
|
334
|
+
|
|
335
|
+
/** Find the first depth-first schema.org comment count without recursive or unbounded traversal. */
|
|
336
|
+
export function schemaCommentCount(value: unknown): number | null {
|
|
337
|
+
const seen = new Set<object>();
|
|
338
|
+
const stack: unknown[] = [value];
|
|
339
|
+
let visited = 0;
|
|
340
|
+
while (stack.length > 0 && visited < MAX_SCHEMA_COMMENT_NODES) {
|
|
341
|
+
const current = stack.pop();
|
|
342
|
+
visited += 1;
|
|
343
|
+
if (typeof current !== "object" || current === null || seen.has(current)) continue;
|
|
344
|
+
seen.add(current);
|
|
345
|
+
if (Array.isArray(current)) {
|
|
346
|
+
const remaining = MAX_SCHEMA_COMMENT_NODES - visited;
|
|
347
|
+
for (let index = Math.min(current.length, remaining) - 1; index >= 0; index -= 1) {
|
|
348
|
+
stack.push(current[index]);
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!isRecord(current)) continue;
|
|
353
|
+
const own = current.commentCount;
|
|
354
|
+
if (typeof own === "number" && Number.isSafeInteger(own) && own >= 0) return own;
|
|
355
|
+
if (typeof own === "string" && /^\d+$/.test(own)) {
|
|
356
|
+
const parsed = Number(own);
|
|
357
|
+
if (Number.isSafeInteger(parsed)) return parsed;
|
|
358
|
+
}
|
|
359
|
+
const children: unknown[] = [];
|
|
360
|
+
const remaining = MAX_SCHEMA_COMMENT_NODES - visited;
|
|
361
|
+
if (remaining <= 0) continue;
|
|
362
|
+
for (const key in current) {
|
|
363
|
+
if (!Object.prototype.hasOwnProperty.call(current, key)) continue;
|
|
364
|
+
children.push(current[key]);
|
|
365
|
+
if (children.length >= remaining) break;
|
|
366
|
+
}
|
|
367
|
+
for (let index = children.length - 1; index >= 0; index -= 1) stack.push(children[index]);
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Count only the fixed structural markers emitted by Defuddle's conversational extractors. */
|
|
373
|
+
export function countDefuddleConversationItems(response: Readonly<Record<string, unknown>>, platform: Platform): number | null {
|
|
374
|
+
const html = nonEmpty(response.content);
|
|
375
|
+
const extractorType = nonEmpty(response.extractorType);
|
|
376
|
+
if (html === null || extractorType === null) return null;
|
|
377
|
+
const supported = new Set(["twitter", "reddit", "hackernews", "github", "discourse", "linkedin"]);
|
|
378
|
+
if (!supported.has(extractorType)) return null;
|
|
379
|
+
const comments = countDefuddleCommentMarkers(html);
|
|
380
|
+
if (platform !== "x" || extractorType !== "twitter") return comments;
|
|
381
|
+
|
|
382
|
+
// Twitter self-replies are emitted as top-level post fragments separated by <hr>;
|
|
383
|
+
// a populated public-replies section contributes exactly one additional separator.
|
|
384
|
+
const separators = countDefuddleSeparators(html);
|
|
385
|
+
return comments + Math.max(0, separators - (comments > 0 ? 1 : 0));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Restore deliberate X post line breaks when Defuddle's tweet HTML flattens them. */
|
|
389
|
+
export function restoreXPostLineBreaks(content: string, description: string | null): string {
|
|
390
|
+
if (description === null || !/[\r\n]/.test(description)) return content;
|
|
391
|
+
const preserved = description.replace(/\r\n?/g, "\n").trim();
|
|
392
|
+
const flattened = preserved.replace(/\s+/g, " ");
|
|
393
|
+
const offset = content.indexOf(flattened);
|
|
394
|
+
if (offset < 0) return content;
|
|
395
|
+
const literal = preserved.split("\n").map((line) => {
|
|
396
|
+
let escaped = line
|
|
397
|
+
.replace(/\\/g, "\\\\")
|
|
398
|
+
.replace(/([`*_~[\]])/g, "\\$1")
|
|
399
|
+
.replace(/</g, "<")
|
|
400
|
+
.replace(/>/g, ">");
|
|
401
|
+
escaped = escaped.replace(/^(\s*)(#{1,6}|[-+]|\d+[.)])(?=\s)/, "$1\\$2");
|
|
402
|
+
if (/^\s*-{3,}\s*$/.test(escaped)) escaped = escaped.replace("-", "\\-");
|
|
403
|
+
if (/^(?: {4}|\t)/.test(escaped)) escaped = ` ${escaped.slice(1)}`;
|
|
404
|
+
return escaped;
|
|
405
|
+
}).join("\n");
|
|
406
|
+
return `${content.slice(0, offset)}${literal}${content.slice(offset + flattened.length)}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function normalizedDefuddleMediaUrl(value: unknown, baseUrl: URL): URL | null {
|
|
410
|
+
const candidate = nonEmpty(value);
|
|
411
|
+
if (candidate === null || candidate.length > 8_192) return null;
|
|
412
|
+
const resolved = resolveRemote(candidate, baseUrl);
|
|
413
|
+
if (resolved === null || resolved.username !== "" || resolved.password !== "") return null;
|
|
414
|
+
resolved.hash = "";
|
|
415
|
+
return resolved;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const defuddleInlineImage = /!\[([^\]\r\n]*)\]\((?:<([^<>\r\n]*)>|([^()\s\r\n]+))((?:\s+"[^"\r\n]*")?)\)/g;
|
|
419
|
+
|
|
420
|
+
function removeDefuddleSamePageImages(
|
|
421
|
+
content: string,
|
|
422
|
+
reportedImage: unknown,
|
|
423
|
+
baseUrl: URL,
|
|
424
|
+
): string {
|
|
425
|
+
const exactCandidates = new Set<string>();
|
|
426
|
+
const exactPage = new URL(baseUrl);
|
|
427
|
+
exactPage.hash = "";
|
|
428
|
+
exactCandidates.add(exactPage.href);
|
|
429
|
+
const reported = normalizedDefuddleMediaUrl(reportedImage, baseUrl);
|
|
430
|
+
if (
|
|
431
|
+
reported !== null
|
|
432
|
+
&& reported.origin === baseUrl.origin
|
|
433
|
+
&& reported.pathname === baseUrl.pathname
|
|
434
|
+
) exactCandidates.add(reported.href);
|
|
435
|
+
|
|
436
|
+
return content.replace(
|
|
437
|
+
defuddleInlineImage,
|
|
438
|
+
(
|
|
439
|
+
whole,
|
|
440
|
+
_alt: string,
|
|
441
|
+
bracketed: string | undefined,
|
|
442
|
+
bare: string | undefined,
|
|
443
|
+
) => {
|
|
444
|
+
const image = normalizedDefuddleMediaUrl(bracketed ?? bare, baseUrl);
|
|
445
|
+
return image !== null && exactCandidates.has(image.href)
|
|
446
|
+
? ""
|
|
447
|
+
: whole;
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Preserve Defuddle's separate main-image field and poster attributes that its Markdown conversion can omit.
|
|
454
|
+
* Existing Markdown/HTML images are compared after URL resolution so LinkedIn and other extractors do not duplicate them.
|
|
455
|
+
*/
|
|
456
|
+
export function retainDefuddleMedia(
|
|
457
|
+
content: string,
|
|
458
|
+
response: Readonly<Record<string, unknown>>,
|
|
459
|
+
baseUrl: URL,
|
|
460
|
+
): string {
|
|
461
|
+
content = removeDefuddleSamePageImages(content, response.image, baseUrl);
|
|
462
|
+
const existing = new Set<string>();
|
|
463
|
+
const scan = scanImageSources(content);
|
|
464
|
+
for (const source of scan.sources) {
|
|
465
|
+
const url = normalizedDefuddleMediaUrl(source, baseUrl);
|
|
466
|
+
if (url !== null) existing.add(url.href);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const candidates: Array<{ readonly value: unknown; readonly label: string }> = [
|
|
470
|
+
{ value: response.image, label: "Cover image" },
|
|
471
|
+
];
|
|
472
|
+
if (Array.isArray(response.captureVideoPosters)) {
|
|
473
|
+
const maximum = Math.min(response.captureVideoPosters.length, 64);
|
|
474
|
+
for (let index = 0; index < maximum; index += 1) {
|
|
475
|
+
candidates.push({ value: response.captureVideoPosters[index], label: "Video thumbnail" });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const additions: string[] = [];
|
|
480
|
+
for (const candidate of candidates) {
|
|
481
|
+
const url = normalizedDefuddleMediaUrl(candidate.value, baseUrl);
|
|
482
|
+
if (
|
|
483
|
+
url === null
|
|
484
|
+
|| existing.has(url.href)
|
|
485
|
+
|| (url.origin === baseUrl.origin && url.pathname === baseUrl.pathname)
|
|
486
|
+
) continue;
|
|
487
|
+
existing.add(url.href);
|
|
488
|
+
additions.push(``);
|
|
489
|
+
}
|
|
490
|
+
return additions.length === 0
|
|
491
|
+
? content
|
|
492
|
+
: `${content.trimEnd()}\n\n${additions.join("\n\n")}\n`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function compactCount(value: string, suffix: string | undefined): number | null {
|
|
496
|
+
const number = Number(value.replace(/,/g, ""));
|
|
497
|
+
if (!Number.isFinite(number) || number < 0) return null;
|
|
498
|
+
const multiplier = suffix?.toLowerCase() === "m" ? 1_000_000 : suffix?.toLowerCase() === "k" ? 1_000 : 1;
|
|
499
|
+
const result = Math.floor(number * multiplier);
|
|
500
|
+
return Number.isSafeInteger(result) ? result : null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function visibleCommentCount(content: string, platform: Platform): number | null {
|
|
504
|
+
const patterns = platform === "x"
|
|
505
|
+
? [/\bread\s+([\d,.]+)\s*([km])?\s+repl(?:y|ies)\b/i, /\b([\d,.]+)\s*([km])?\s+repl(?:y|ies)\b/i]
|
|
506
|
+
: [/\b(?:view|read|show)\s+(?:all\s+)?([\d,.]+)\s*([km])?\s+comments?\b/i];
|
|
507
|
+
for (const pattern of patterns) {
|
|
508
|
+
const match = pattern.exec(content);
|
|
509
|
+
if (match?.[1] === undefined) continue;
|
|
510
|
+
const count = compactCount(match[1], match[2]);
|
|
511
|
+
if (count !== null) return count;
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const blockedPattern = /(?:verify (?:that )?you are (?:a )?human|(?:complete|solve) (?:the )?captcha|\bcaptcha\b|access denied|request (?:has been )?blocked|unusual traffic|cloudflare ray id)/i;
|
|
517
|
+
const blockedTitlePattern = /^(?:403(?: forbidden)?|access denied|request blocked|unusual traffic|verify (?:that )?you are (?:a )?human|human verification|captcha|security (?:check|verification)|attention required|just a moment(?:\.{3})?)$/i;
|
|
518
|
+
const blockedContextPattern = /(?:\b(?:please )?(?:verify|confirm) (?:that )?you are (?:a )?human\b|\b(?:complete|solve) (?:the )?captcha\b|\b(?:you (?:do not|don't) have permission|you have been blocked)\b|\b(?:your|this) (?:request|access|ip(?: address)?) (?:has been|was|is) blocked\b|\bunusual traffic from your (?:computer )?network\b|\bautomated (?:queries|requests)\b|\b(?:before proceeding|to continue),? (?:please )?(?:verify|complete|enable)\b|\bcloudflare ray id\b)/i;
|
|
519
|
+
const blockedStandaloneLinePattern = /(?:^|\n)[ \t]*(?:#{1,6}[ \t]+)?(?:403(?: forbidden)?|access denied|request blocked|unusual traffic|verify (?:that )?you are (?:a )?human|captcha|security (?:check|verification))[.!]?[ \t]*(?:\r?\n|$)/i;
|
|
520
|
+
const rateLimitSignalPattern = /\b(?:429(?: too many requests)?|too many requests|rate limit(?:ed| exceeded)?)\b/i;
|
|
521
|
+
const rateLimitTitlePattern = /^(?:429(?: too many requests)?|too many requests|rate limit(?:ed| exceeded)?)$/i;
|
|
522
|
+
const rateLimitStandaloneLinePattern = /(?:^|\n)[ \t]*(?:#{1,6}[ \t]+)?(?:429(?: too many requests)?|too many requests|rate limit(?:ed| exceeded)?)[.!]?[ \t]*(?:\r?\n|$)/i;
|
|
523
|
+
const rateLimitRetryContextPattern = /\b(?:please )?(?:(?:try|retry) again(?: later| in \d+)|wait (?:a moment|before retrying)|respect the retry-after header)\b/i;
|
|
524
|
+
const articleDiscussionPattern = /(?:\bhow to\b|\btroubleshoot(?:ing)?\b|\b(?:this|the) (?:article|guide|tutorial)\b|\b(?:this|the) (?:article|guide|tutorial) explains?\b|\blearn (?:how|why)\b)/i;
|
|
525
|
+
const MAX_BLOCKED_SHELL_CODE_UNITS = 4_096;
|
|
526
|
+
const MAX_BLOCKED_SHELL_WORDS = 160;
|
|
527
|
+
const MAX_STANDALONE_BLOCKED_SHELL_WORDS = 24;
|
|
528
|
+
|
|
529
|
+
/** Treat block phrases as a gate only in a bounded shell-shaped context, not ordinary prose. */
|
|
530
|
+
function looksLikeBlockedShell(content: string, title: string | null): boolean {
|
|
531
|
+
if (content.length > MAX_BLOCKED_SHELL_CODE_UNITS) return false;
|
|
532
|
+
const wordCount = countWords(content);
|
|
533
|
+
if (wordCount > MAX_BLOCKED_SHELL_WORDS) return false;
|
|
534
|
+
const normalizedTitle = (title ?? "").slice(0, articleMetadataLimits.title).replace(/\s+/g, " ").trim();
|
|
535
|
+
const boundedVisible = `${normalizedTitle}\n${content}`;
|
|
536
|
+
if (articleDiscussionPattern.test(boundedVisible)) return false;
|
|
537
|
+
if (
|
|
538
|
+
wordCount <= MAX_STANDALONE_BLOCKED_SHELL_WORDS
|
|
539
|
+
&& (blockedStandaloneLinePattern.test(content) || rateLimitStandaloneLinePattern.test(content))
|
|
540
|
+
) return true;
|
|
541
|
+
const exactGateTitle = blockedTitlePattern.test(normalizedTitle) || rateLimitTitlePattern.test(normalizedTitle);
|
|
542
|
+
const rateLimitShell = rateLimitSignalPattern.test(content) && rateLimitRetryContextPattern.test(content);
|
|
543
|
+
const hasBlockSignal = exactGateTitle || blockedPattern.test(content) || rateLimitShell;
|
|
544
|
+
return hasBlockSignal && (exactGateTitle || blockedContextPattern.test(content) || rateLimitShell);
|
|
545
|
+
}
|
|
546
|
+
const authenticationGatePattern = /(?:\b(?:sign|log) in to (?:continue|read|view|see|access|comment|reply)\b|\blogin required\b|\bmembers? only\b|\bsubscriber-only\b|\bsubscribe to (?:continue|read)\b|\bthis content is private\b|\byou must be logged in\b)/i;
|
|
547
|
+
const paywallCallToActionPattern = /\b(?:subscribe(?: now)? to (?:unlock (?:this|the) (?:article|story|content)|(?:keep|continue) reading)|create (?:an? |your )?account to (?:keep|continue) reading|register to (?:keep|continue) reading|sign up to (?:keep|continue) reading)\b/i;
|
|
548
|
+
const paywallTitlePattern = /^(?:subscribe(?: now)? to (?:unlock (?:this|the) (?:article|story|content)|(?:keep|continue) reading)|create (?:an? |your )?account to (?:keep|continue) reading|register to (?:keep|continue) reading|sign up to (?:keep|continue) reading)[.!]?$/i;
|
|
549
|
+
const paywallStandaloneLinePattern = /(?:^|\n)[ \t]*(?:#{1,6}[ \t]+)?(?:subscribe(?: now)? to (?:unlock (?:this|the) (?:article|story|content)|(?:keep|continue) reading)|create (?:an? |your )?account to (?:keep|continue) reading|register to (?:keep|continue) reading|sign up to (?:keep|continue) reading)[.!]?[ \t]*(?:\r?\n|$)/i;
|
|
550
|
+
const paywallDiscussionPattern = /\b(?:analy[sz](?:e|es|ed|ing)|discuss(?:es|ed|ing)?|examin(?:e|es|ed|ing)|quot(?:e|es|ed|ing)|stud(?:y|ies|ied|ying)|interface copy|wording|publishers? (?:sometimes )?(?:say|write|use))\b/i;
|
|
551
|
+
const shellPattern = /(?:enable javascript|javascript is disabled|something went wrong|try reloading)/i;
|
|
552
|
+
const xReplyGatePattern = /\bjoin\s+x\s+now\s+to\s+read\s+repl(?:y|ies)\b/i;
|
|
553
|
+
const xCombinedAccountShellPattern = /\blog\s*in\s*sign\s*up\b/i;
|
|
554
|
+
const loginShellPattern = /\blog\s*in\b/i;
|
|
555
|
+
const signupShellPattern = /\bsign\s*up\b/i;
|
|
556
|
+
|
|
557
|
+
/** Recognize compact paywall calls to action without treating quoted interface language as a gate. */
|
|
558
|
+
function looksLikePaywallShell(content: string, title: string | null): boolean {
|
|
559
|
+
if (content.length > MAX_BLOCKED_SHELL_CODE_UNITS) return false;
|
|
560
|
+
const wordCount = countWords(content);
|
|
561
|
+
if (wordCount > MAX_BLOCKED_SHELL_WORDS) return false;
|
|
562
|
+
const normalizedTitle = (title ?? "").slice(0, articleMetadataLimits.title).replace(/\s+/g, " ").trim();
|
|
563
|
+
if (paywallTitlePattern.test(normalizedTitle) || paywallStandaloneLinePattern.test(content)) return true;
|
|
564
|
+
if (wordCount > 48) return false;
|
|
565
|
+
const boundedVisible = `${normalizedTitle}\n${content}`;
|
|
566
|
+
return paywallCallToActionPattern.test(boundedVisible) && !paywallDiscussionPattern.test(boundedVisible);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function isRenderedConversationAccessGate(content: string, platform: Platform): boolean {
|
|
570
|
+
if (authenticationGatePattern.test(content)) return true;
|
|
571
|
+
return platform === "x"
|
|
572
|
+
&& (
|
|
573
|
+
xReplyGatePattern.test(content)
|
|
574
|
+
|| xCombinedAccountShellPattern.test(content)
|
|
575
|
+
|| (loginShellPattern.test(content) && signupShellPattern.test(content))
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function statusFor(
|
|
580
|
+
content: string,
|
|
581
|
+
title: string | null,
|
|
582
|
+
scope: CaptureScope,
|
|
583
|
+
contentTruncated: boolean,
|
|
584
|
+
renderedTextFallback: boolean,
|
|
585
|
+
): CaptureStatus {
|
|
586
|
+
const visible = `${title ?? ""}\n${content}`;
|
|
587
|
+
if (looksLikeBlockedShell(content, title)) return "blocked";
|
|
588
|
+
const accessGate = authenticationGatePattern.test(visible) || looksLikePaywallShell(content, title);
|
|
589
|
+
if (accessGate && content.length < 1_500) return "auth-required";
|
|
590
|
+
if (shellPattern.test(visible) && content.length < 500) return "unsupported";
|
|
591
|
+
if (content.trim().length < 40) return "unsupported";
|
|
592
|
+
if (accessGate) return "partial";
|
|
593
|
+
if (contentTruncated || renderedTextFallback) return "partial";
|
|
594
|
+
// Only structured adapters can prove traversal completeness. A rendered DOM,
|
|
595
|
+
// saved page, or cookie-authenticated HTML can omit pagination invisibly.
|
|
596
|
+
if (scope === "thread" || scope === "comments") return "partial";
|
|
597
|
+
return "complete";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Preserve access-control evidence even when useful surrounding content makes the extraction partial. */
|
|
601
|
+
export function extractionShowsAccessControl(extraction: ExtractedPage): boolean {
|
|
602
|
+
if (extraction.status === "auth-required" || extraction.status === "blocked") return true;
|
|
603
|
+
const { content, title } = extraction.article;
|
|
604
|
+
return looksLikeBlockedShell(content, title)
|
|
605
|
+
|| looksLikePaywallShell(content, title)
|
|
606
|
+
|| authenticationGatePattern.test(`${title ?? ""}\n${content}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function scoreExtraction(
|
|
610
|
+
article: Article,
|
|
611
|
+
status: CaptureStatus,
|
|
612
|
+
wordCount: number,
|
|
613
|
+
capturedItems: number,
|
|
614
|
+
acquisition: AcquiredPage,
|
|
615
|
+
): number {
|
|
616
|
+
const statusWeight: Readonly<Record<CaptureStatus, number>> = {
|
|
617
|
+
complete: 5_000,
|
|
618
|
+
partial: 2_000,
|
|
619
|
+
"auth-required": -2_000,
|
|
620
|
+
blocked: -4_000,
|
|
621
|
+
unsupported: -5_000,
|
|
622
|
+
};
|
|
623
|
+
const images = countMarkdownMarkers(article.content, "image", 100);
|
|
624
|
+
const links = countMarkdownMarkers(article.content, "link", 500);
|
|
625
|
+
const acquisitionAdjustment = acquisition.method.startsWith("browser")
|
|
626
|
+
? acquisition.contentType?.toLowerCase().includes("text/plain") === true ? -500 : 0
|
|
627
|
+
: 0;
|
|
628
|
+
return statusWeight[status]
|
|
629
|
+
+ Math.min(article.content.length, 50_000)
|
|
630
|
+
+ Math.min(wordCount, 10_000) * 5
|
|
631
|
+
+ Math.min(capturedItems, 1_000) * 50
|
|
632
|
+
+ images * 100
|
|
633
|
+
+ links * 5
|
|
634
|
+
+ acquisitionAdjustment;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function plainTextArticle(acquisition: AcquiredPage): Article | null {
|
|
638
|
+
const content = acquisition.body.trim();
|
|
639
|
+
if (content === "") return null;
|
|
640
|
+
const browserTitle = boundedMetadata(acquisition.browserTitle, articleMetadataLimits.title);
|
|
641
|
+
const firstHeading = browserTitle === null ? firstMarkdownHeading(content) : null;
|
|
642
|
+
const pathname = acquisition.finalUrl.pathname;
|
|
643
|
+
let pathEnd = pathname.length;
|
|
644
|
+
while (pathEnd > 0 && pathname.charCodeAt(pathEnd - 1) === 0x2f) pathEnd -= 1;
|
|
645
|
+
const pathStart = pathname.lastIndexOf("/", pathEnd - 1) + 1;
|
|
646
|
+
const lastSegment = boundedTrimmedSlice(pathname, pathStart, pathEnd, articleMetadataLimits.title);
|
|
647
|
+
return {
|
|
648
|
+
content,
|
|
649
|
+
title: browserTitle
|
|
650
|
+
?? firstHeading
|
|
651
|
+
?? lastSegment
|
|
652
|
+
?? boundedMetadata(acquisition.finalUrl.hostname, articleMetadataLimits.title),
|
|
653
|
+
author: null,
|
|
654
|
+
published: null,
|
|
655
|
+
description: null,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function firstMarkdownHeading(content: string): string | null {
|
|
660
|
+
let lineStart = 0;
|
|
661
|
+
while (lineStart < content.length) {
|
|
662
|
+
const newline = content.indexOf("\n", lineStart);
|
|
663
|
+
const lineEnd = newline < 0 ? content.length : newline;
|
|
664
|
+
let cursor = lineStart;
|
|
665
|
+
let hashes = 0;
|
|
666
|
+
while (hashes < 3 && cursor < lineEnd && content.charCodeAt(cursor) === 0x23) {
|
|
667
|
+
hashes += 1;
|
|
668
|
+
cursor += 1;
|
|
669
|
+
}
|
|
670
|
+
if (
|
|
671
|
+
hashes > 0
|
|
672
|
+
&& cursor < lineEnd
|
|
673
|
+
&& (content.charCodeAt(cursor) === 0x20 || content.charCodeAt(cursor) === 0x09)
|
|
674
|
+
) {
|
|
675
|
+
const heading = boundedTrimmedSlice(content, cursor, lineEnd, articleMetadataLimits.title);
|
|
676
|
+
if (heading !== null) return heading;
|
|
677
|
+
}
|
|
678
|
+
if (newline < 0) break;
|
|
679
|
+
lineStart = newline + 1;
|
|
680
|
+
}
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Convert one acquired response into normalized Markdown and auditable completeness metadata. */
|
|
685
|
+
export async function extractPage(
|
|
686
|
+
acquisition: AcquiredPage,
|
|
687
|
+
scope: CaptureScope,
|
|
688
|
+
timeoutMs = 30_000,
|
|
689
|
+
): Promise<ExtractedPage | null> {
|
|
690
|
+
let platform = detectPlatform(acquisition.finalUrl);
|
|
691
|
+
const contentType = acquisition.contentType?.toLowerCase() ?? "";
|
|
692
|
+
let article: Article | null = null;
|
|
693
|
+
let wordCount = 0;
|
|
694
|
+
let expectedItems: number | null = null;
|
|
695
|
+
let structurallyCapturedItems: number | null = null;
|
|
696
|
+
let extractor = "plain-text";
|
|
697
|
+
const warnings = [...acquisition.warnings];
|
|
698
|
+
const renderedPage = scope === "page" && acquisition.method.startsWith("browser")
|
|
699
|
+
? boundedRenderedPageText(acquisition.renderedText, acquisition.renderedTextByteLimit)
|
|
700
|
+
: null;
|
|
701
|
+
let renderedPageFallback = false;
|
|
702
|
+
let renderedPageFallbackTruncated = false;
|
|
703
|
+
|
|
704
|
+
if (contentType.includes("text/markdown") || contentType.includes("text/plain")) {
|
|
705
|
+
article = plainTextArticle(acquisition);
|
|
706
|
+
wordCount = article === null ? 0 : countWords(article.content);
|
|
707
|
+
if (acquisition.method.startsWith("browser")) {
|
|
708
|
+
warnings.push("Rendered readable-text fallback may include surrounding account or interface content; review it before reuse.");
|
|
709
|
+
}
|
|
710
|
+
} else {
|
|
711
|
+
try {
|
|
712
|
+
const response = await runDefuddleWorker(acquisition, scope, timeoutMs);
|
|
713
|
+
platform = detectedExtractorPlatform(response.extractorType, platform);
|
|
714
|
+
const content = nonEmpty(response.contentMarkdown) ?? nonEmpty(response.content);
|
|
715
|
+
if (content !== null) {
|
|
716
|
+
const description = boundedMetadata(response.description, articleMetadataLimits.description);
|
|
717
|
+
const restoredContent = platform === "x" ? restoreXPostLineBreaks(content, description) : content;
|
|
718
|
+
article = {
|
|
719
|
+
content: retainDefuddleMedia(restoredContent, response, acquisition.finalUrl),
|
|
720
|
+
title: boundedMetadata(response.title, articleMetadataLimits.title)
|
|
721
|
+
?? boundedMetadata(acquisition.browserTitle, articleMetadataLimits.title),
|
|
722
|
+
author: boundedMetadata(response.author, articleMetadataLimits.author),
|
|
723
|
+
published: boundedMetadata(response.published, articleMetadataLimits.published),
|
|
724
|
+
description,
|
|
725
|
+
};
|
|
726
|
+
wordCount = typeof response.wordCount === "number" && Number.isSafeInteger(response.wordCount) && response.wordCount >= 0
|
|
727
|
+
? response.wordCount
|
|
728
|
+
: countWords(content);
|
|
729
|
+
expectedItems = schemaCommentCount(response.schemaOrgData);
|
|
730
|
+
structurallyCapturedItems = countDefuddleConversationItems(response, platform);
|
|
731
|
+
extractor = platform === "generic" ? "defuddle" : `defuddle:${platform}`;
|
|
732
|
+
}
|
|
733
|
+
} catch (error) {
|
|
734
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
735
|
+
if (renderedPage !== null) {
|
|
736
|
+
warnings.push(
|
|
737
|
+
"Article extraction failed; evaluated the bounded rendered-page text fallback from the same browser navigation.",
|
|
738
|
+
);
|
|
739
|
+
} else {
|
|
740
|
+
throw new Error(`Defuddle could not parse this acquisition: ${message}`, { cause: error });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (renderedPage !== null) {
|
|
746
|
+
const primaryStatus = article === null
|
|
747
|
+
? "unsupported"
|
|
748
|
+
: statusFor(
|
|
749
|
+
article.content,
|
|
750
|
+
article.title,
|
|
751
|
+
scope,
|
|
752
|
+
acquisition.contentTruncated === true,
|
|
753
|
+
false,
|
|
754
|
+
);
|
|
755
|
+
const fallbackBase = plainTextArticle({
|
|
756
|
+
...acquisition,
|
|
757
|
+
body: renderedPage.content,
|
|
758
|
+
contentType: "text/plain; charset=utf-8",
|
|
759
|
+
});
|
|
760
|
+
if (fallbackBase !== null) {
|
|
761
|
+
const fallbackStatus = statusFor(
|
|
762
|
+
fallbackBase.content,
|
|
763
|
+
fallbackBase.title,
|
|
764
|
+
scope,
|
|
765
|
+
renderedPage.truncated || acquisition.renderedTextTruncated === true,
|
|
766
|
+
true,
|
|
767
|
+
);
|
|
768
|
+
if (article === null || (primaryStatus === "unsupported" && fallbackStatus !== "unsupported")) {
|
|
769
|
+
const primary = article;
|
|
770
|
+
article = {
|
|
771
|
+
...fallbackBase,
|
|
772
|
+
title: primary?.title ?? fallbackBase.title,
|
|
773
|
+
author: primary?.author ?? fallbackBase.author,
|
|
774
|
+
published: primary?.published ?? fallbackBase.published,
|
|
775
|
+
description: primary?.description ?? fallbackBase.description,
|
|
776
|
+
};
|
|
777
|
+
wordCount = countWords(article.content);
|
|
778
|
+
extractor = primary === null ? "rendered-page" : `${extractor}+rendered-page`;
|
|
779
|
+
renderedPageFallback = true;
|
|
780
|
+
renderedPageFallbackTruncated = renderedPage.truncated || acquisition.renderedTextTruncated === true;
|
|
781
|
+
warnings.push(
|
|
782
|
+
"Used bounded rendered-page text because article extraction produced no usable page body; it may include surrounding account or interface text and cannot prove feed completeness.",
|
|
783
|
+
);
|
|
784
|
+
if (renderedPageFallbackTruncated) {
|
|
785
|
+
warnings.push(
|
|
786
|
+
`Rendered-page text reached the ${renderedPage.byteLimit}-byte fallback limit or the browser read boundary and was truncated.`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (article === null) return null;
|
|
793
|
+
|
|
794
|
+
const renderedConversation = scope !== "page" && structurallyCapturedItems === null
|
|
795
|
+
? nonEmpty(acquisition.renderedText)
|
|
796
|
+
: null;
|
|
797
|
+
if (renderedConversation !== null && renderedConversation !== article.content.trim()) {
|
|
798
|
+
if (isRenderedConversationAccessGate(renderedConversation, platform)) {
|
|
799
|
+
warnings.push(
|
|
800
|
+
"Skipped the separately rendered conversation context because it exposed an access gate rather than a trustworthy reply or comment tree.",
|
|
801
|
+
);
|
|
802
|
+
} else {
|
|
803
|
+
article = {
|
|
804
|
+
...article,
|
|
805
|
+
content: `${article.content.trimEnd()}\n\n## Rendered conversation context\n\n${renderedConversation}\n`,
|
|
806
|
+
};
|
|
807
|
+
wordCount = countWords(article.content);
|
|
808
|
+
extractor = `${extractor}+rendered-context`;
|
|
809
|
+
warnings.push(
|
|
810
|
+
"Preserved the separately rendered conversation context because the article extractor exposed no trustworthy item tree; it can include duplicated article, account, or interface text.",
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
expectedItems ??= visibleCommentCount(article.content, platform);
|
|
816
|
+
const capturedItems = scope === "page" ? 1 : structurallyCapturedItems ?? 0;
|
|
817
|
+
if (scope === "page") {
|
|
818
|
+
expectedItems = null;
|
|
819
|
+
} else if (structurallyCapturedItems === null) {
|
|
820
|
+
warnings.push("The rendered response exposed no trustworthy per-item structure; capturedItems is conservatively reported as 0.");
|
|
821
|
+
} else if (expectedItems !== null && capturedItems > expectedItems) {
|
|
822
|
+
warnings.push(
|
|
823
|
+
`The source declared ${expectedItems} scoped items, but ${capturedItems} items were observed; the expected count was normalized to the observed count.`,
|
|
824
|
+
);
|
|
825
|
+
expectedItems = capturedItems;
|
|
826
|
+
}
|
|
827
|
+
const status = statusFor(
|
|
828
|
+
article.content,
|
|
829
|
+
article.title,
|
|
830
|
+
scope,
|
|
831
|
+
renderedPageFallback ? renderedPageFallbackTruncated : acquisition.contentTruncated === true,
|
|
832
|
+
renderedPageFallback || (acquisition.method.startsWith("browser") && contentType.includes("text/plain")),
|
|
833
|
+
);
|
|
834
|
+
if (status !== "complete") warnings.push(`Capture status is ${status}; inspect the source before relying on completeness.`);
|
|
835
|
+
const canonicalUrl = canonicalizeUrl(acquisition.finalUrl, platform);
|
|
836
|
+
return {
|
|
837
|
+
article,
|
|
838
|
+
canonicalUrl,
|
|
839
|
+
platform,
|
|
840
|
+
status,
|
|
841
|
+
score: scoreExtraction(article, status, wordCount, capturedItems, acquisition),
|
|
842
|
+
wordCount,
|
|
843
|
+
expectedItems,
|
|
844
|
+
capturedItems,
|
|
845
|
+
extractor,
|
|
846
|
+
warnings,
|
|
847
|
+
acquisition,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function chooseBestExtraction(candidates: readonly ExtractedPage[]): ExtractedPage | null {
|
|
852
|
+
const statusRank: Readonly<Record<CaptureStatus, number>> = {
|
|
853
|
+
complete: 5,
|
|
854
|
+
partial: 4,
|
|
855
|
+
"auth-required": 3,
|
|
856
|
+
blocked: 2,
|
|
857
|
+
unsupported: 1,
|
|
858
|
+
};
|
|
859
|
+
let best: ExtractedPage | null = null;
|
|
860
|
+
for (const candidate of candidates) {
|
|
861
|
+
if (
|
|
862
|
+
best === null
|
|
863
|
+
|| statusRank[candidate.status] > statusRank[best.status]
|
|
864
|
+
|| (statusRank[candidate.status] === statusRank[best.status] && candidate.score > best.score)
|
|
865
|
+
) best = candidate;
|
|
866
|
+
}
|
|
867
|
+
return best;
|
|
868
|
+
}
|