@open-mercato/web-research 0.7.1-develop.7194.1.ab4fc81f82

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.
Files changed (121) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +67 -0
  3. package/build.mjs +10 -0
  4. package/dist/contract/adapter.js +13 -0
  5. package/dist/contract/adapter.js.map +7 -0
  6. package/dist/contract/errors.js +28 -0
  7. package/dist/contract/errors.js.map +7 -0
  8. package/dist/contract/http.js +7 -0
  9. package/dist/contract/http.js.map +7 -0
  10. package/dist/contract/index.js +9 -0
  11. package/dist/contract/index.js.map +7 -0
  12. package/dist/contract/outcomes.js +58 -0
  13. package/dist/contract/outcomes.js.map +7 -0
  14. package/dist/contract/policy.js +91 -0
  15. package/dist/contract/policy.js.map +7 -0
  16. package/dist/contract/results.js +13 -0
  17. package/dist/contract/results.js.map +7 -0
  18. package/dist/contract/steps.js +1 -0
  19. package/dist/contract/steps.js.map +7 -0
  20. package/dist/contract/version.js +11 -0
  21. package/dist/contract/version.js.map +7 -0
  22. package/dist/engine/cache.js +29 -0
  23. package/dist/engine/cache.js.map +7 -0
  24. package/dist/engine/deadline.js +54 -0
  25. package/dist/engine/deadline.js.map +7 -0
  26. package/dist/engine/engine.js +481 -0
  27. package/dist/engine/engine.js.map +7 -0
  28. package/dist/engine/index.js +20 -0
  29. package/dist/engine/index.js.map +7 -0
  30. package/dist/engine/runAdapter.js +57 -0
  31. package/dist/engine/runAdapter.js.map +7 -0
  32. package/dist/engine/types.js +1 -0
  33. package/dist/engine/types.js.map +7 -0
  34. package/dist/extract/classify.js +51 -0
  35. package/dist/extract/classify.js.map +7 -0
  36. package/dist/extract/content.js +104 -0
  37. package/dist/extract/content.js.map +7 -0
  38. package/dist/extract/entities.js +95 -0
  39. package/dist/extract/entities.js.map +7 -0
  40. package/dist/extract/index.js +24 -0
  41. package/dist/extract/index.js.map +7 -0
  42. package/dist/extract/text.js +114 -0
  43. package/dist/extract/text.js.map +7 -0
  44. package/dist/extract/tokenizer.js +141 -0
  45. package/dist/extract/tokenizer.js.map +7 -0
  46. package/dist/fusion/canonicalize.js +88 -0
  47. package/dist/fusion/canonicalize.js.map +7 -0
  48. package/dist/fusion/fuse.js +88 -0
  49. package/dist/fusion/fuse.js.map +7 -0
  50. package/dist/fusion/index.js +11 -0
  51. package/dist/fusion/index.js.map +7 -0
  52. package/dist/index.js +21 -0
  53. package/dist/index.js.map +7 -0
  54. package/dist/loader/describe.js +92 -0
  55. package/dist/loader/describe.js.map +7 -0
  56. package/dist/loader/index.js +31 -0
  57. package/dist/loader/index.js.map +7 -0
  58. package/dist/loader/manifest.js +32 -0
  59. package/dist/loader/manifest.js.map +7 -0
  60. package/dist/loader/registry.js +98 -0
  61. package/dist/loader/registry.js.map +7 -0
  62. package/dist/net/client.js +298 -0
  63. package/dist/net/client.js.map +7 -0
  64. package/dist/net/ssrf.js +165 -0
  65. package/dist/net/ssrf.js.map +7 -0
  66. package/dist/net/url.js +9 -0
  67. package/dist/net/url.js.map +7 -0
  68. package/dist/testing/contract.js +118 -0
  69. package/dist/testing/contract.js.map +7 -0
  70. package/dist/testing/fakeAdapter.js +44 -0
  71. package/dist/testing/fakeAdapter.js.map +7 -0
  72. package/dist/testing/index.js +15 -0
  73. package/dist/testing/index.js.map +7 -0
  74. package/jest.config.cjs +24 -0
  75. package/package.json +65 -0
  76. package/src/contract/adapter.ts +100 -0
  77. package/src/contract/errors.ts +48 -0
  78. package/src/contract/http.ts +46 -0
  79. package/src/contract/index.ts +8 -0
  80. package/src/contract/outcomes.ts +91 -0
  81. package/src/contract/policy.ts +157 -0
  82. package/src/contract/results.ts +79 -0
  83. package/src/contract/steps.ts +45 -0
  84. package/src/contract/version.ts +25 -0
  85. package/src/engine/__tests__/engine.test.ts +597 -0
  86. package/src/engine/cache.ts +46 -0
  87. package/src/engine/deadline.ts +65 -0
  88. package/src/engine/engine.ts +633 -0
  89. package/src/engine/index.ts +22 -0
  90. package/src/engine/runAdapter.ts +89 -0
  91. package/src/engine/types.ts +137 -0
  92. package/src/extract/__tests__/content.test.ts +81 -0
  93. package/src/extract/__tests__/text.test.ts +61 -0
  94. package/src/extract/__tests__/tokenizer.test.ts +85 -0
  95. package/src/extract/classify.ts +81 -0
  96. package/src/extract/content.ts +136 -0
  97. package/src/extract/entities.ts +98 -0
  98. package/src/extract/index.ts +11 -0
  99. package/src/extract/text.ts +124 -0
  100. package/src/extract/tokenizer.ts +197 -0
  101. package/src/fusion/__tests__/fusion.test.ts +198 -0
  102. package/src/fusion/canonicalize.ts +119 -0
  103. package/src/fusion/fuse.ts +145 -0
  104. package/src/fusion/index.ts +8 -0
  105. package/src/index.ts +15 -0
  106. package/src/loader/__tests__/describe.test.ts +119 -0
  107. package/src/loader/__tests__/registry.test.ts +134 -0
  108. package/src/loader/describe.ts +145 -0
  109. package/src/loader/index.ts +25 -0
  110. package/src/loader/manifest.ts +44 -0
  111. package/src/loader/registry.ts +156 -0
  112. package/src/net/__tests__/client.test.ts +273 -0
  113. package/src/net/__tests__/ssrf.test.ts +165 -0
  114. package/src/net/client.ts +434 -0
  115. package/src/net/ssrf.ts +235 -0
  116. package/src/net/url.ts +13 -0
  117. package/src/testing/contract.ts +172 -0
  118. package/src/testing/fakeAdapter.ts +69 -0
  119. package/src/testing/index.ts +9 -0
  120. package/tsconfig.json +10 -0
  121. package/watch.mjs +7 -0
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/engine/runAdapter.ts"],
4
+ "sourcesContent": ["import type { AdapterContext } from '../contract/adapter'\nimport { errorMessage } from '../contract/errors'\nimport type { HttpClient, Logger } from '../contract/http'\nimport { failed, type SearchOutcome } from '../contract/outcomes'\nimport type { SearchRequest } from '../contract/results'\nimport type { SearchStepEvent, SearchStepMetrics } from '../contract/steps'\nimport { linkSignals } from './deadline'\nimport type { AdapterDiagnosticStatus, EngineAdapterEntry } from './types'\n\nexport type AdapterRunResult = {\n readonly entry: EngineAdapterEntry\n readonly outcome: SearchOutcome\n readonly latencyMs: number\n}\n\nexport type AdapterRunDeps = {\n readonly http: HttpClient\n readonly logger: Logger\n readonly now: () => number\n readonly report: (adapterId: string, event: SearchStepEvent, detail?: string, metrics?: SearchStepMetrics) => void\n readonly parentSignal: AbortSignal\n readonly deadlineAt: number\n readonly adapterTimeoutMs: number\n}\n\nexport function outcomeToStatus(outcome: SearchOutcome): AdapterDiagnosticStatus {\n switch (outcome.status) {\n case 'ok':\n return 'ok'\n case 'empty':\n return 'empty'\n case 'unavailable':\n return 'unavailable'\n case 'blocked':\n return 'blocked'\n case 'timeout':\n return 'timeout'\n case 'error':\n return 'error'\n }\n}\n\nexport function outcomeReason(outcome: SearchOutcome): string | undefined {\n if (outcome.status === 'ok' || outcome.status === 'empty') return undefined\n return outcome.reason\n}\n\n/**\n * Runs one adapter under its own timeout and abort linkage.\n *\n * The contract says adapters return outcomes rather than throwing, but a\n * third-party adapter is not trusted to honour that \u2014 a throw here is converted\n * into an `error` outcome so one misbehaving package cannot abort the whole run.\n */\nexport async function runAdapter(\n entry: EngineAdapterEntry,\n request: SearchRequest,\n deps: AdapterRunDeps,\n): Promise<AdapterRunResult> {\n const startedAt = deps.now()\n const allowance = entry.timeoutMs ?? deps.adapterTimeoutMs\n const budget = Math.max(0, Math.min(allowance, deps.deadlineAt - startedAt))\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), budget)\n timer.unref?.()\n const linked = linkSignals([deps.parentSignal, controller.signal])\n\n const context: AdapterContext = {\n signal: linked.signal,\n http: deps.http,\n report: (event, detail, metrics) => deps.report(entry.adapter.id, event, detail, metrics),\n deadlineAt: startedAt + budget,\n logger: deps.logger,\n }\n\n deps.report(entry.adapter.id, 'started')\n\n try {\n const outcome = await entry.adapter.search(request, context)\n return { entry, outcome, latencyMs: deps.now() - startedAt }\n } catch (error) {\n const detail = `adapter threw instead of returning an outcome: ${errorMessage(error)}`\n deps.logger.warn(`[web-research] ${entry.adapter.id} ${detail}`)\n return { entry, outcome: failed(detail, false), latencyMs: deps.now() - startedAt }\n } finally {\n clearTimeout(timer)\n linked.dispose()\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,oBAAoB;AAE7B,SAAS,cAAkC;AAG3C,SAAS,mBAAmB;AAmBrB,SAAS,gBAAgB,SAAiD;AAC/E,UAAQ,QAAQ,QAAQ;AAAA,IACtB,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,EACX;AACF;AAEO,SAAS,cAAc,SAA4C;AACxE,MAAI,QAAQ,WAAW,QAAQ,QAAQ,WAAW,QAAS,QAAO;AAClE,SAAO,QAAQ;AACjB;AASA,eAAsB,WACpB,OACA,SACA,MAC2B;AAC3B,QAAM,YAAY,KAAK,IAAI;AAC3B,QAAM,YAAY,MAAM,aAAa,KAAK;AAC1C,QAAM,SAAS,KAAK,IAAI,GAAG,KAAK,IAAI,WAAW,KAAK,aAAa,SAAS,CAAC;AAC3E,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,QAAQ,WAAW,MAAM,WAAW,MAAM,GAAG,MAAM;AACzD,QAAM,QAAQ;AACd,QAAM,SAAS,YAAY,CAAC,KAAK,cAAc,WAAW,MAAM,CAAC;AAEjE,QAAM,UAA0B;AAAA,IAC9B,QAAQ,OAAO;AAAA,IACf,MAAM,KAAK;AAAA,IACX,QAAQ,CAAC,OAAO,QAAQ,YAAY,KAAK,OAAO,MAAM,QAAQ,IAAI,OAAO,QAAQ,OAAO;AAAA,IACxF,YAAY,YAAY;AAAA,IACxB,QAAQ,KAAK;AAAA,EACf;AAEA,OAAK,OAAO,MAAM,QAAQ,IAAI,SAAS;AAEvC,MAAI;AACF,UAAM,UAAU,MAAM,MAAM,QAAQ,OAAO,SAAS,OAAO;AAC3D,WAAO,EAAE,OAAO,SAAS,WAAW,KAAK,IAAI,IAAI,UAAU;AAAA,EAC7D,SAAS,OAAO;AACd,UAAM,SAAS,kDAAkD,aAAa,KAAK,CAAC;AACpF,SAAK,OAAO,KAAK,kBAAkB,MAAM,QAAQ,EAAE,IAAI,MAAM,EAAE;AAC/D,WAAO,EAAE,OAAO,SAAS,OAAO,QAAQ,KAAK,GAAG,WAAW,KAAK,IAAI,IAAI,UAAU;AAAA,EACpF,UAAE;AACA,iBAAa,KAAK;AAClB,WAAO,QAAQ;AAAA,EACjB;AACF;",
6
+ "names": []
7
+ }
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": [],
4
+ "sourcesContent": [],
5
+ "mappings": "",
6
+ "names": []
7
+ }
@@ -0,0 +1,51 @@
1
+ const CHALLENGE_MARKERS = [
2
+ "cf-browser-verification",
3
+ "cf_chl_opt",
4
+ "just a moment",
5
+ "checking your browser",
6
+ "enable cookies to continue",
7
+ "ddos protection by",
8
+ "access denied",
9
+ "captcha",
10
+ "are you a robot"
11
+ ];
12
+ const JS_REQUIRED_MARKERS = [
13
+ "enable javascript",
14
+ "javascript is required",
15
+ "javascript is disabled",
16
+ "doesn't work properly without javascript",
17
+ "you need to enable javascript to run this app"
18
+ ];
19
+ const EMPTY_ROOT = /<div[^<>]{0,200}\bid=["']?(root|__next|app|__nuxt)["']?[^<>]{0,200}>\s*<\/div>/i;
20
+ const MIN_MEANINGFUL_TEXT = 200;
21
+ const SHELL_HTML_THRESHOLD = 4e3;
22
+ function classifyPage(input) {
23
+ const haystack = input.html.slice(0, 2e5).toLowerCase();
24
+ if (input.status === 403 || input.status === 429 || input.status === 503) {
25
+ return { verdict: "blocked", reason: `HTTP ${input.status}` };
26
+ }
27
+ const text = input.text.trim();
28
+ if (text.length === 0 && input.html.trim().length === 0) {
29
+ return { verdict: "empty", reason: "no response body" };
30
+ }
31
+ if (text.length < MIN_MEANINGFUL_TEXT) {
32
+ const challenge = CHALLENGE_MARKERS.find((marker) => haystack.includes(marker));
33
+ if (challenge) return { verdict: "blocked", reason: `anti-bot challenge (${challenge})` };
34
+ }
35
+ const jsMarker = JS_REQUIRED_MARKERS.find((marker) => haystack.includes(marker));
36
+ if (jsMarker && text.length < MIN_MEANINGFUL_TEXT) {
37
+ return { verdict: "js-shell", reason: `page requires JavaScript (${jsMarker})` };
38
+ }
39
+ if (EMPTY_ROOT.test(input.html) && text.length < MIN_MEANINGFUL_TEXT) {
40
+ return { verdict: "js-shell", reason: "empty client-side render root" };
41
+ }
42
+ if (text.length < MIN_MEANINGFUL_TEXT && input.html.length > SHELL_HTML_THRESHOLD) {
43
+ return { verdict: "js-shell", reason: `only ${text.length} chars of text in ${input.html.length} of markup` };
44
+ }
45
+ if (text.length === 0) return { verdict: "empty", reason: "no extractable text" };
46
+ return { verdict: "ok" };
47
+ }
48
+ export {
49
+ classifyPage
50
+ };
51
+ //# sourceMappingURL=classify.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/extract/classify.ts"],
4
+ "sourcesContent": ["import type { PageVerdict } from '../contract/results'\n\nexport type PageClassification = {\n readonly verdict: PageVerdict\n readonly reason?: string\n}\n\nexport type ClassifyInput = {\n readonly status: number\n readonly contentType: string | null\n readonly html: string\n readonly text: string\n}\n\nconst CHALLENGE_MARKERS = [\n 'cf-browser-verification',\n 'cf_chl_opt',\n 'just a moment',\n 'checking your browser',\n 'enable cookies to continue',\n 'ddos protection by',\n 'access denied',\n 'captcha',\n 'are you a robot',\n]\n\nconst JS_REQUIRED_MARKERS = [\n 'enable javascript',\n 'javascript is required',\n 'javascript is disabled',\n \"doesn't work properly without javascript\",\n 'you need to enable javascript to run this app',\n]\n\nconst EMPTY_ROOT = /<div[^<>]{0,200}\\bid=[\"']?(root|__next|app|__nuxt)[\"']?[^<>]{0,200}>\\s*<\\/div>/i\n\n/** Below this, a text-bearing HTML page is almost certainly a shell, not content. */\nconst MIN_MEANINGFUL_TEXT = 200\nconst SHELL_HTML_THRESHOLD = 4_000\n\n/**\n * Decides whether an HTTP read actually produced readable content. The verdict\n * drives browser escalation in code rather than leaving the judgement to the\n * model, which costs a full round-trip and answers differently each time.\n */\nexport function classifyPage(input: ClassifyInput): PageClassification {\n const haystack = input.html.slice(0, 200_000).toLowerCase()\n\n if (input.status === 403 || input.status === 429 || input.status === 503) {\n return { verdict: 'blocked', reason: `HTTP ${input.status}` }\n }\n\n const text = input.text.trim()\n if (text.length === 0 && input.html.trim().length === 0) {\n return { verdict: 'empty', reason: 'no response body' }\n }\n\n // Marker matching runs over raw markup, including script bundles, so plenty of\n // ordinary pages mention \"captcha\" or \"access denied\" without being one. A\n // challenge page is content-free by definition, so the markers only count when\n // the page also failed to yield readable text \u2014 otherwise a working page gets\n // flagged and triggers a pointless browser escalation.\n if (text.length < MIN_MEANINGFUL_TEXT) {\n const challenge = CHALLENGE_MARKERS.find((marker) => haystack.includes(marker))\n if (challenge) return { verdict: 'blocked', reason: `anti-bot challenge (${challenge})` }\n }\n\n const jsMarker = JS_REQUIRED_MARKERS.find((marker) => haystack.includes(marker))\n if (jsMarker && text.length < MIN_MEANINGFUL_TEXT) {\n return { verdict: 'js-shell', reason: `page requires JavaScript (${jsMarker})` }\n }\n if (EMPTY_ROOT.test(input.html) && text.length < MIN_MEANINGFUL_TEXT) {\n return { verdict: 'js-shell', reason: 'empty client-side render root' }\n }\n if (text.length < MIN_MEANINGFUL_TEXT && input.html.length > SHELL_HTML_THRESHOLD) {\n return { verdict: 'js-shell', reason: `only ${text.length} chars of text in ${input.html.length} of markup` }\n }\n if (text.length === 0) return { verdict: 'empty', reason: 'no extractable text' }\n\n return { verdict: 'ok' }\n}\n"],
5
+ "mappings": "AAcA,MAAM,oBAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,sBAAsB;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,aAAa;AAGnB,MAAM,sBAAsB;AAC5B,MAAM,uBAAuB;AAOtB,SAAS,aAAa,OAA0C;AACrE,QAAM,WAAW,MAAM,KAAK,MAAM,GAAG,GAAO,EAAE,YAAY;AAE1D,MAAI,MAAM,WAAW,OAAO,MAAM,WAAW,OAAO,MAAM,WAAW,KAAK;AACxE,WAAO,EAAE,SAAS,WAAW,QAAQ,QAAQ,MAAM,MAAM,GAAG;AAAA,EAC9D;AAEA,QAAM,OAAO,MAAM,KAAK,KAAK;AAC7B,MAAI,KAAK,WAAW,KAAK,MAAM,KAAK,KAAK,EAAE,WAAW,GAAG;AACvD,WAAO,EAAE,SAAS,SAAS,QAAQ,mBAAmB;AAAA,EACxD;AAOA,MAAI,KAAK,SAAS,qBAAqB;AACrC,UAAM,YAAY,kBAAkB,KAAK,CAAC,WAAW,SAAS,SAAS,MAAM,CAAC;AAC9E,QAAI,UAAW,QAAO,EAAE,SAAS,WAAW,QAAQ,uBAAuB,SAAS,IAAI;AAAA,EAC1F;AAEA,QAAM,WAAW,oBAAoB,KAAK,CAAC,WAAW,SAAS,SAAS,MAAM,CAAC;AAC/E,MAAI,YAAY,KAAK,SAAS,qBAAqB;AACjD,WAAO,EAAE,SAAS,YAAY,QAAQ,6BAA6B,QAAQ,IAAI;AAAA,EACjF;AACA,MAAI,WAAW,KAAK,MAAM,IAAI,KAAK,KAAK,SAAS,qBAAqB;AACpE,WAAO,EAAE,SAAS,YAAY,QAAQ,gCAAgC;AAAA,EACxE;AACA,MAAI,KAAK,SAAS,uBAAuB,MAAM,KAAK,SAAS,sBAAsB;AACjF,WAAO,EAAE,SAAS,YAAY,QAAQ,QAAQ,KAAK,MAAM,qBAAqB,MAAM,KAAK,MAAM,aAAa;AAAA,EAC9G;AACA,MAAI,KAAK,WAAW,EAAG,QAAO,EAAE,SAAS,SAAS,QAAQ,sBAAsB;AAEhF,SAAO,EAAE,SAAS,KAAK;AACzB;",
6
+ "names": []
7
+ }
@@ -0,0 +1,104 @@
1
+ import { BLOCK_ELEMENTS, NON_CONTENT_ELEMENTS, normalizeWhitespace } from "./text.js";
2
+ import { tokenizeHtml } from "./tokenizer.js";
3
+ const CANDIDATE_ELEMENTS = /* @__PURE__ */ new Set(["article", "main", "section", "div", "td", "body"]);
4
+ const SEMANTIC_BONUS = /* @__PURE__ */ new Set(["article", "main"]);
5
+ const NEGATIVE_HINT = /(^|[\s_-])(nav|navigation|menu|sidebar|side-bar|footer|masthead|comment|share|social|advert|promo|banner|cookie|consent|related|breadcrumb|pagination|widget|newsletter|subscribe)([\s_-]|$)/i;
6
+ const POSITIVE_HINT = /(^|[\s_-])(article|content|post|story|entry|body-?text|main)([\s_-]|$)/i;
7
+ const MIN_CONTENT_CHARS = 200;
8
+ function hintScoreOf(attributes) {
9
+ const descriptor = `${attributes.get("class") ?? ""} ${attributes.get("id") ?? ""} ${attributes.get("role") ?? ""}`;
10
+ if (descriptor.trim().length === 0) return 0;
11
+ let score = 0;
12
+ if (NEGATIVE_HINT.test(descriptor)) score -= 600;
13
+ if (POSITIVE_HINT.test(descriptor)) score += 300;
14
+ return score;
15
+ }
16
+ function scoreOf(frame) {
17
+ const semantic = SEMANTIC_BONUS.has(frame.name) ? 400 : 0;
18
+ return frame.textLength - 2 * frame.linkTextLength + 25 * frame.paragraphs + frame.hintScore + semantic;
19
+ }
20
+ function extractMainContent(html) {
21
+ const parts = [];
22
+ const stack = [];
23
+ const best = { current: null };
24
+ let linkDepth = 0;
25
+ let skipName = null;
26
+ let skipDepth = 0;
27
+ const closeFrame = () => {
28
+ const frame = stack.pop();
29
+ if (!frame) return;
30
+ const score = scoreOf(frame);
31
+ if (frame.textLength >= MIN_CONTENT_CHARS && (best.current === null || score > best.current.score)) {
32
+ best.current = { startPart: frame.startPart, endPart: parts.length, score };
33
+ }
34
+ const parent = stack[stack.length - 1];
35
+ if (parent) {
36
+ parent.textLength += frame.textLength;
37
+ parent.linkTextLength += frame.linkTextLength;
38
+ parent.paragraphs += frame.paragraphs;
39
+ }
40
+ };
41
+ for (const token of tokenizeHtml(html)) {
42
+ if (token.kind === "open") {
43
+ if (skipName !== null) {
44
+ if (token.name === skipName) skipDepth += 1;
45
+ continue;
46
+ }
47
+ if (NON_CONTENT_ELEMENTS.has(token.name)) {
48
+ skipName = token.name;
49
+ skipDepth = 1;
50
+ continue;
51
+ }
52
+ if (BLOCK_ELEMENTS.has(token.name)) parts.push("\n");
53
+ if (token.name === "a") linkDepth += 1;
54
+ if (token.name === "p") {
55
+ const frame2 = stack[stack.length - 1];
56
+ if (frame2) frame2.paragraphs += 1;
57
+ }
58
+ if (CANDIDATE_ELEMENTS.has(token.name) && !token.selfClosing) {
59
+ stack.push({
60
+ name: token.name,
61
+ startPart: parts.length,
62
+ hintScore: hintScoreOf(token.attributes),
63
+ textLength: 0,
64
+ linkTextLength: 0,
65
+ paragraphs: 0
66
+ });
67
+ }
68
+ continue;
69
+ }
70
+ if (token.kind === "close") {
71
+ if (skipName !== null) {
72
+ if (token.name === skipName) {
73
+ skipDepth -= 1;
74
+ if (skipDepth <= 0) skipName = null;
75
+ }
76
+ continue;
77
+ }
78
+ if (BLOCK_ELEMENTS.has(token.name)) parts.push("\n");
79
+ if (token.name === "a" && linkDepth > 0) linkDepth -= 1;
80
+ if (CANDIDATE_ELEMENTS.has(token.name)) {
81
+ const index = stack.findLastIndex((frame2) => frame2.name === token.name);
82
+ if (index !== -1) {
83
+ while (stack.length > index) closeFrame();
84
+ }
85
+ }
86
+ continue;
87
+ }
88
+ if (skipName !== null) continue;
89
+ parts.push(token.value);
90
+ const frame = stack[stack.length - 1];
91
+ if (!frame) continue;
92
+ const length = token.value.trim().length;
93
+ frame.textLength += length;
94
+ if (linkDepth > 0) frame.linkTextLength += length;
95
+ }
96
+ while (stack.length > 0) closeFrame();
97
+ const winner = best.current;
98
+ const selected = winner === null ? parts : parts.slice(winner.startPart, winner.endPart);
99
+ return normalizeWhitespace(selected.join(""));
100
+ }
101
+ export {
102
+ extractMainContent
103
+ };
104
+ //# sourceMappingURL=content.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/extract/content.ts"],
4
+ "sourcesContent": ["import { BLOCK_ELEMENTS, NON_CONTENT_ELEMENTS, normalizeWhitespace } from './text'\nimport { tokenizeHtml } from './tokenizer'\n\nconst CANDIDATE_ELEMENTS = new Set(['article', 'main', 'section', 'div', 'td', 'body'])\nconst SEMANTIC_BONUS = new Set(['article', 'main'])\n\nconst NEGATIVE_HINT =\n /(^|[\\s_-])(nav|navigation|menu|sidebar|side-bar|footer|masthead|comment|share|social|advert|promo|banner|cookie|consent|related|breadcrumb|pagination|widget|newsletter|subscribe)([\\s_-]|$)/i\nconst POSITIVE_HINT = /(^|[\\s_-])(article|content|post|story|entry|body-?text|main)([\\s_-]|$)/i\n\n/** Below this a candidate is treated as chrome rather than an article. */\nconst MIN_CONTENT_CHARS = 200\n\ntype BestRange = {\n readonly startPart: number\n readonly endPart: number\n readonly score: number\n}\n\ntype Frame = {\n readonly name: string\n readonly startPart: number\n readonly hintScore: number\n textLength: number\n linkTextLength: number\n paragraphs: number\n}\n\nfunction hintScoreOf(attributes: ReadonlyMap<string, string>): number {\n const descriptor = `${attributes.get('class') ?? ''} ${attributes.get('id') ?? ''} ${attributes.get('role') ?? ''}`\n if (descriptor.trim().length === 0) return 0\n let score = 0\n if (NEGATIVE_HINT.test(descriptor)) score -= 600\n if (POSITIVE_HINT.test(descriptor)) score += 300\n return score\n}\n\nfunction scoreOf(frame: Frame): number {\n const semantic = SEMANTIC_BONUS.has(frame.name) ? 400 : 0\n return frame.textLength - 2 * frame.linkTextLength + 25 * frame.paragraphs + frame.hintScore + semantic\n}\n\n/**\n * Picks the subtree that reads like the article, using text-to-link density plus\n * paragraph count and class/id hints. Counters are accumulated during the single\n * tokenizer pass and only the winning range is materialized, so the scan stays\n * linear in time and the output allocates once.\n */\nexport function extractMainContent(html: string): string {\n const parts: string[] = []\n const stack: Frame[] = []\n const best: { current: BestRange | null } = { current: null }\n let linkDepth = 0\n let skipName: string | null = null\n let skipDepth = 0\n\n const closeFrame = (): void => {\n const frame = stack.pop()\n if (!frame) return\n const score = scoreOf(frame)\n if (frame.textLength >= MIN_CONTENT_CHARS && (best.current === null || score > best.current.score)) {\n best.current = { startPart: frame.startPart, endPart: parts.length, score }\n }\n const parent = stack[stack.length - 1]\n if (parent) {\n parent.textLength += frame.textLength\n parent.linkTextLength += frame.linkTextLength\n parent.paragraphs += frame.paragraphs\n }\n }\n\n for (const token of tokenizeHtml(html)) {\n if (token.kind === 'open') {\n if (skipName !== null) {\n if (token.name === skipName) skipDepth += 1\n continue\n }\n if (NON_CONTENT_ELEMENTS.has(token.name)) {\n skipName = token.name\n skipDepth = 1\n continue\n }\n if (BLOCK_ELEMENTS.has(token.name)) parts.push('\\n')\n if (token.name === 'a') linkDepth += 1\n if (token.name === 'p') {\n const frame = stack[stack.length - 1]\n if (frame) frame.paragraphs += 1\n }\n if (CANDIDATE_ELEMENTS.has(token.name) && !token.selfClosing) {\n stack.push({\n name: token.name,\n startPart: parts.length,\n hintScore: hintScoreOf(token.attributes),\n textLength: 0,\n linkTextLength: 0,\n paragraphs: 0,\n })\n }\n continue\n }\n\n if (token.kind === 'close') {\n if (skipName !== null) {\n if (token.name === skipName) {\n skipDepth -= 1\n if (skipDepth <= 0) skipName = null\n }\n continue\n }\n if (BLOCK_ELEMENTS.has(token.name)) parts.push('\\n')\n if (token.name === 'a' && linkDepth > 0) linkDepth -= 1\n if (CANDIDATE_ELEMENTS.has(token.name)) {\n // Unbalanced markup is common; only unwind to the nearest matching frame.\n const index = stack.findLastIndex((frame) => frame.name === token.name)\n if (index !== -1) {\n while (stack.length > index) closeFrame()\n }\n }\n continue\n }\n\n if (skipName !== null) continue\n parts.push(token.value)\n const frame = stack[stack.length - 1]\n if (!frame) continue\n const length = token.value.trim().length\n frame.textLength += length\n if (linkDepth > 0) frame.linkTextLength += length\n }\n\n while (stack.length > 0) closeFrame()\n\n const winner = best.current\n const selected = winner === null ? parts : parts.slice(winner.startPart, winner.endPart)\n return normalizeWhitespace(selected.join(''))\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["frame"]
7
+ }
@@ -0,0 +1,95 @@
1
+ const NAMED = {
2
+ amp: "&",
3
+ lt: "<",
4
+ gt: ">",
5
+ quot: '"',
6
+ apos: "'",
7
+ nbsp: " ",
8
+ ensp: " ",
9
+ emsp: " ",
10
+ thinsp: " ",
11
+ shy: "",
12
+ hellip: "\u2026",
13
+ mdash: "\u2014",
14
+ ndash: "\u2013",
15
+ lsquo: "\u2018",
16
+ rsquo: "\u2019",
17
+ ldquo: "\u201C",
18
+ rdquo: "\u201D",
19
+ laquo: "\xAB",
20
+ raquo: "\xBB",
21
+ bull: "\u2022",
22
+ middot: "\xB7",
23
+ copy: "\xA9",
24
+ reg: "\xAE",
25
+ trade: "\u2122",
26
+ deg: "\xB0",
27
+ euro: "\u20AC",
28
+ pound: "\xA3",
29
+ yen: "\xA5",
30
+ cent: "\xA2",
31
+ sect: "\xA7",
32
+ para: "\xB6",
33
+ dagger: "\u2020",
34
+ permil: "\u2030",
35
+ prime: "\u2032",
36
+ Prime: "\u2033",
37
+ times: "\xD7",
38
+ divide: "\xF7",
39
+ minus: "\u2212",
40
+ plusmn: "\xB1",
41
+ frac12: "\xBD",
42
+ frac14: "\xBC",
43
+ frac34: "\xBE",
44
+ larr: "\u2190",
45
+ rarr: "\u2192",
46
+ harr: "\u2194",
47
+ darr: "\u2193",
48
+ uarr: "\u2191"
49
+ };
50
+ function codePointToString(codePoint) {
51
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 1114111) return "";
52
+ if (codePoint >= 55296 && codePoint <= 57343) return "\uFFFD";
53
+ return String.fromCodePoint(codePoint);
54
+ }
55
+ function decodeEntities(input) {
56
+ if (!input.includes("&")) return input;
57
+ let output = "";
58
+ let position = 0;
59
+ while (position < input.length) {
60
+ const start = input.indexOf("&", position);
61
+ if (start === -1) {
62
+ output += input.slice(position);
63
+ break;
64
+ }
65
+ output += input.slice(position, start);
66
+ const end = input.indexOf(";", start + 1);
67
+ if (end === -1 || end - start > 32) {
68
+ output += "&";
69
+ position = start + 1;
70
+ continue;
71
+ }
72
+ const reference = input.slice(start + 1, end);
73
+ if (reference.startsWith("#")) {
74
+ const isHex = reference[1] === "x" || reference[1] === "X";
75
+ const digits = isHex ? reference.slice(2) : reference.slice(1);
76
+ const valid = isHex ? /^[0-9a-fA-F]+$/.test(digits) : /^[0-9]+$/.test(digits);
77
+ if (valid) {
78
+ output += codePointToString(Number.parseInt(digits, isHex ? 16 : 10));
79
+ position = end + 1;
80
+ continue;
81
+ }
82
+ } else if (Object.hasOwn(NAMED, reference)) {
83
+ output += NAMED[reference];
84
+ position = end + 1;
85
+ continue;
86
+ }
87
+ output += "&";
88
+ position = start + 1;
89
+ }
90
+ return output;
91
+ }
92
+ export {
93
+ decodeEntities
94
+ };
95
+ //# sourceMappingURL=entities.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/extract/entities.ts"],
4
+ "sourcesContent": ["const NAMED: Readonly<Record<string, string>> = {\n amp: '&',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n nbsp: ' ',\n ensp: ' ',\n emsp: ' ',\n thinsp: ' ',\n shy: '',\n hellip: '\u2026',\n mdash: '\u2014',\n ndash: '\u2013',\n lsquo: '\u2018',\n rsquo: '\u2019',\n ldquo: '\u201C',\n rdquo: '\u201D',\n laquo: '\u00AB',\n raquo: '\u00BB',\n bull: '\u2022',\n middot: '\u00B7',\n copy: '\u00A9',\n reg: '\u00AE',\n trade: '\u2122',\n deg: '\u00B0',\n euro: '\u20AC',\n pound: '\u00A3',\n yen: '\u00A5',\n cent: '\u00A2',\n sect: '\u00A7',\n para: '\u00B6',\n dagger: '\u2020',\n permil: '\u2030',\n prime: '\u2032',\n Prime: '\u2033',\n times: '\u00D7',\n divide: '\u00F7',\n minus: '\u2212',\n plusmn: '\u00B1',\n frac12: '\u00BD',\n frac14: '\u00BC',\n frac34: '\u00BE',\n larr: '\u2190',\n rarr: '\u2192',\n harr: '\u2194',\n darr: '\u2193',\n uarr: '\u2191',\n}\n\nfunction codePointToString(codePoint: number): string {\n if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return ''\n // Surrogate halves are not valid standalone scalar values.\n if (codePoint >= 0xd800 && codePoint <= 0xdfff) return '\uFFFD'\n return String.fromCodePoint(codePoint)\n}\n\n/**\n * Decodes HTML character references in a single forward pass. Unterminated or\n * unknown references are emitted verbatim, matching how browsers treat them.\n */\nexport function decodeEntities(input: string): string {\n if (!input.includes('&')) return input\n let output = ''\n let position = 0\n while (position < input.length) {\n const start = input.indexOf('&', position)\n if (start === -1) {\n output += input.slice(position)\n break\n }\n output += input.slice(position, start)\n const end = input.indexOf(';', start + 1)\n if (end === -1 || end - start > 32) {\n output += '&'\n position = start + 1\n continue\n }\n const reference = input.slice(start + 1, end)\n if (reference.startsWith('#')) {\n const isHex = reference[1] === 'x' || reference[1] === 'X'\n const digits = isHex ? reference.slice(2) : reference.slice(1)\n const valid = isHex ? /^[0-9a-fA-F]+$/.test(digits) : /^[0-9]+$/.test(digits)\n if (valid) {\n output += codePointToString(Number.parseInt(digits, isHex ? 16 : 10))\n position = end + 1\n continue\n }\n } else if (Object.hasOwn(NAMED, reference)) {\n output += NAMED[reference]\n position = end + 1\n continue\n }\n output += '&'\n position = start + 1\n }\n return output\n}\n"],
5
+ "mappings": "AAAA,MAAM,QAA0C;AAAA,EAC9C,KAAK;AAAA,EACL,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,KAAK;AAAA,EACL,OAAO;AAAA,EACP,KAAK;AAAA,EACL,MAAM;AAAA,EACN,OAAO;AAAA,EACP,KAAK;AAAA,EACL,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AACR;AAEA,SAAS,kBAAkB,WAA2B;AACpD,MAAI,CAAC,OAAO,SAAS,SAAS,KAAK,YAAY,KAAK,YAAY,QAAU,QAAO;AAEjF,MAAI,aAAa,SAAU,aAAa,MAAQ,QAAO;AACvD,SAAO,OAAO,cAAc,SAAS;AACvC;AAMO,SAAS,eAAe,OAAuB;AACpD,MAAI,CAAC,MAAM,SAAS,GAAG,EAAG,QAAO;AACjC,MAAI,SAAS;AACb,MAAI,WAAW;AACf,SAAO,WAAW,MAAM,QAAQ;AAC9B,UAAM,QAAQ,MAAM,QAAQ,KAAK,QAAQ;AACzC,QAAI,UAAU,IAAI;AAChB,gBAAU,MAAM,MAAM,QAAQ;AAC9B;AAAA,IACF;AACA,cAAU,MAAM,MAAM,UAAU,KAAK;AACrC,UAAM,MAAM,MAAM,QAAQ,KAAK,QAAQ,CAAC;AACxC,QAAI,QAAQ,MAAM,MAAM,QAAQ,IAAI;AAClC,gBAAU;AACV,iBAAW,QAAQ;AACnB;AAAA,IACF;AACA,UAAM,YAAY,MAAM,MAAM,QAAQ,GAAG,GAAG;AAC5C,QAAI,UAAU,WAAW,GAAG,GAAG;AAC7B,YAAM,QAAQ,UAAU,CAAC,MAAM,OAAO,UAAU,CAAC,MAAM;AACvD,YAAM,SAAS,QAAQ,UAAU,MAAM,CAAC,IAAI,UAAU,MAAM,CAAC;AAC7D,YAAM,QAAQ,QAAQ,iBAAiB,KAAK,MAAM,IAAI,WAAW,KAAK,MAAM;AAC5E,UAAI,OAAO;AACT,kBAAU,kBAAkB,OAAO,SAAS,QAAQ,QAAQ,KAAK,EAAE,CAAC;AACpE,mBAAW,MAAM;AACjB;AAAA,MACF;AAAA,IACF,WAAW,OAAO,OAAO,OAAO,SAAS,GAAG;AAC1C,gBAAU,MAAM,SAAS;AACzB,iBAAW,MAAM;AACjB;AAAA,IACF;AACA,cAAU;AACV,eAAW,QAAQ;AAAA,EACrB;AACA,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -0,0 +1,24 @@
1
+ import { decodeEntities } from "./entities.js";
2
+ import { classifyPage } from "./classify.js";
3
+ import { extractMainContent } from "./content.js";
4
+ import {
5
+ BLOCK_ELEMENTS,
6
+ NON_CONTENT_ELEMENTS,
7
+ extractTitle,
8
+ htmlToText,
9
+ normalizeWhitespace
10
+ } from "./text.js";
11
+ import { MAX_HTML_INPUT, tokenizeHtml } from "./tokenizer.js";
12
+ export {
13
+ BLOCK_ELEMENTS,
14
+ MAX_HTML_INPUT,
15
+ NON_CONTENT_ELEMENTS,
16
+ classifyPage,
17
+ decodeEntities,
18
+ extractMainContent,
19
+ extractTitle,
20
+ htmlToText,
21
+ normalizeWhitespace,
22
+ tokenizeHtml
23
+ };
24
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/extract/index.ts"],
4
+ "sourcesContent": ["export { decodeEntities } from './entities'\nexport { classifyPage, type ClassifyInput, type PageClassification } from './classify'\nexport { extractMainContent } from './content'\nexport {\n BLOCK_ELEMENTS,\n NON_CONTENT_ELEMENTS,\n extractTitle,\n htmlToText,\n normalizeWhitespace,\n} from './text'\nexport { MAX_HTML_INPUT, tokenizeHtml, type HtmlToken } from './tokenizer'\n"],
5
+ "mappings": "AAAA,SAAS,sBAAsB;AAC/B,SAAS,oBAAiE;AAC1E,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB,oBAAoC;",
6
+ "names": []
7
+ }
@@ -0,0 +1,114 @@
1
+ import { tokenizeHtml } from "./tokenizer.js";
2
+ const NON_CONTENT_ELEMENTS = /* @__PURE__ */ new Set([
3
+ "script",
4
+ "style",
5
+ "noscript",
6
+ "template",
7
+ "svg",
8
+ "math",
9
+ "title",
10
+ "iframe",
11
+ "object"
12
+ ]);
13
+ const BLOCK_ELEMENTS = /* @__PURE__ */ new Set([
14
+ "address",
15
+ "article",
16
+ "aside",
17
+ "blockquote",
18
+ "br",
19
+ "dd",
20
+ "div",
21
+ "dl",
22
+ "dt",
23
+ "figcaption",
24
+ "figure",
25
+ "footer",
26
+ "form",
27
+ "h1",
28
+ "h2",
29
+ "h3",
30
+ "h4",
31
+ "h5",
32
+ "h6",
33
+ "header",
34
+ "hr",
35
+ "li",
36
+ "main",
37
+ "nav",
38
+ "ol",
39
+ "p",
40
+ "pre",
41
+ "section",
42
+ "table",
43
+ "tbody",
44
+ "td",
45
+ "tfoot",
46
+ "th",
47
+ "thead",
48
+ "tr",
49
+ "ul"
50
+ ]);
51
+ const UNICODE_SPACES = new RegExp("[\\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000]", "g");
52
+ function normalizeWhitespace(text) {
53
+ return text.replace(UNICODE_SPACES, " ").replace(/[ \t\f\r\v]+/g, " ").replace(/ ?\n ?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
54
+ }
55
+ function htmlToText(html) {
56
+ const parts = [];
57
+ let skipName = null;
58
+ let skipDepth = 0;
59
+ for (const token of tokenizeHtml(html)) {
60
+ if (token.kind === "open") {
61
+ if (skipName !== null) {
62
+ if (token.name === skipName) skipDepth += 1;
63
+ continue;
64
+ }
65
+ if (NON_CONTENT_ELEMENTS.has(token.name)) {
66
+ skipName = token.name;
67
+ skipDepth = 1;
68
+ continue;
69
+ }
70
+ if (BLOCK_ELEMENTS.has(token.name)) parts.push("\n");
71
+ continue;
72
+ }
73
+ if (token.kind === "close") {
74
+ if (skipName !== null) {
75
+ if (token.name === skipName) {
76
+ skipDepth -= 1;
77
+ if (skipDepth <= 0) skipName = null;
78
+ }
79
+ continue;
80
+ }
81
+ if (BLOCK_ELEMENTS.has(token.name)) parts.push("\n");
82
+ continue;
83
+ }
84
+ if (skipName !== null) continue;
85
+ parts.push(token.value);
86
+ }
87
+ return normalizeWhitespace(parts.join(""));
88
+ }
89
+ function extractTitle(html) {
90
+ let inTitle = false;
91
+ let title = "";
92
+ for (const token of tokenizeHtml(html)) {
93
+ if (token.kind === "open") {
94
+ if (token.name === "title") inTitle = true;
95
+ else if (token.name === "body") break;
96
+ continue;
97
+ }
98
+ if (token.kind === "close") {
99
+ if (token.name === "title") break;
100
+ continue;
101
+ }
102
+ if (inTitle) title += token.value;
103
+ }
104
+ const normalized = title.replace(/\s+/g, " ").trim();
105
+ return normalized.length > 0 ? normalized : null;
106
+ }
107
+ export {
108
+ BLOCK_ELEMENTS,
109
+ NON_CONTENT_ELEMENTS,
110
+ extractTitle,
111
+ htmlToText,
112
+ normalizeWhitespace
113
+ };
114
+ //# sourceMappingURL=text.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/extract/text.ts"],
4
+ "sourcesContent": ["import { tokenizeHtml } from './tokenizer'\n\n/** Elements whose text content is never part of the readable document. */\nexport const NON_CONTENT_ELEMENTS = new Set([\n 'script',\n 'style',\n 'noscript',\n 'template',\n 'svg',\n 'math',\n 'title',\n 'iframe',\n 'object',\n])\n\nexport const BLOCK_ELEMENTS = new Set([\n 'address',\n 'article',\n 'aside',\n 'blockquote',\n 'br',\n 'dd',\n 'div',\n 'dl',\n 'dt',\n 'figcaption',\n 'figure',\n 'footer',\n 'form',\n 'h1',\n 'h2',\n 'h3',\n 'h4',\n 'h5',\n 'h6',\n 'header',\n 'hr',\n 'li',\n 'main',\n 'nav',\n 'ol',\n 'p',\n 'pre',\n 'section',\n 'table',\n 'tbody',\n 'td',\n 'tfoot',\n 'th',\n 'thead',\n 'tr',\n 'ul',\n])\n\n/** Non-breaking and typographic spaces that must fold into an ordinary space. */\nconst UNICODE_SPACES = new RegExp('[\\\\u00A0\\\\u1680\\\\u2000-\\\\u200A\\\\u202F\\\\u205F\\\\u3000]', 'g')\n\nexport function normalizeWhitespace(text: string): string {\n return text\n .replace(UNICODE_SPACES, ' ')\n .replace(/[ \\t\\f\\r\\v]+/g, ' ')\n // The collapse above leaves at most one space on either side of a newline,\n // so ` ?` is equivalent to ` *` here \u2014 and, unlike ` *`, does not backtrack\n // quadratically over long runs of spaces (CodeQL polynomial ReDoS).\n .replace(/ ?\\n ?/g, '\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\nexport function htmlToText(html: string): string {\n const parts: string[] = []\n let skipName: string | null = null\n let skipDepth = 0\n\n for (const token of tokenizeHtml(html)) {\n if (token.kind === 'open') {\n if (skipName !== null) {\n if (token.name === skipName) skipDepth += 1\n continue\n }\n if (NON_CONTENT_ELEMENTS.has(token.name)) {\n skipName = token.name\n skipDepth = 1\n continue\n }\n if (BLOCK_ELEMENTS.has(token.name)) parts.push('\\n')\n continue\n }\n if (token.kind === 'close') {\n if (skipName !== null) {\n if (token.name === skipName) {\n skipDepth -= 1\n if (skipDepth <= 0) skipName = null\n }\n continue\n }\n if (BLOCK_ELEMENTS.has(token.name)) parts.push('\\n')\n continue\n }\n if (skipName !== null) continue\n parts.push(token.value)\n }\n\n return normalizeWhitespace(parts.join(''))\n}\n\nexport function extractTitle(html: string): string | null {\n let inTitle = false\n let title = ''\n for (const token of tokenizeHtml(html)) {\n if (token.kind === 'open') {\n if (token.name === 'title') inTitle = true\n else if (token.name === 'body') break\n continue\n }\n if (token.kind === 'close') {\n if (token.name === 'title') break\n continue\n }\n if (inTitle) title += token.value\n }\n const normalized = title.replace(/\\s+/g, ' ').trim()\n return normalized.length > 0 ? normalized : null\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAGtB,MAAM,uBAAuB,oBAAI,IAAI;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,MAAM,iBAAiB,oBAAI,IAAI;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAGD,MAAM,iBAAiB,IAAI,OAAO,wDAAwD,GAAG;AAEtF,SAAS,oBAAoB,MAAsB;AACxD,SAAO,KACJ,QAAQ,gBAAgB,GAAG,EAC3B,QAAQ,iBAAiB,GAAG,EAI5B,QAAQ,WAAW,IAAI,EACvB,QAAQ,WAAW,MAAM,EACzB,KAAK;AACV;AAEO,SAAS,WAAW,MAAsB;AAC/C,QAAM,QAAkB,CAAC;AACzB,MAAI,WAA0B;AAC9B,MAAI,YAAY;AAEhB,aAAW,SAAS,aAAa,IAAI,GAAG;AACtC,QAAI,MAAM,SAAS,QAAQ;AACzB,UAAI,aAAa,MAAM;AACrB,YAAI,MAAM,SAAS,SAAU,cAAa;AAC1C;AAAA,MACF;AACA,UAAI,qBAAqB,IAAI,MAAM,IAAI,GAAG;AACxC,mBAAW,MAAM;AACjB,oBAAY;AACZ;AAAA,MACF;AACA,UAAI,eAAe,IAAI,MAAM,IAAI,EAAG,OAAM,KAAK,IAAI;AACnD;AAAA,IACF;AACA,QAAI,MAAM,SAAS,SAAS;AAC1B,UAAI,aAAa,MAAM;AACrB,YAAI,MAAM,SAAS,UAAU;AAC3B,uBAAa;AACb,cAAI,aAAa,EAAG,YAAW;AAAA,QACjC;AACA;AAAA,MACF;AACA,UAAI,eAAe,IAAI,MAAM,IAAI,EAAG,OAAM,KAAK,IAAI;AACnD;AAAA,IACF;AACA,QAAI,aAAa,KAAM;AACvB,UAAM,KAAK,MAAM,KAAK;AAAA,EACxB;AAEA,SAAO,oBAAoB,MAAM,KAAK,EAAE,CAAC;AAC3C;AAEO,SAAS,aAAa,MAA6B;AACxD,MAAI,UAAU;AACd,MAAI,QAAQ;AACZ,aAAW,SAAS,aAAa,IAAI,GAAG;AACtC,QAAI,MAAM,SAAS,QAAQ;AACzB,UAAI,MAAM,SAAS,QAAS,WAAU;AAAA,eAC7B,MAAM,SAAS,OAAQ;AAChC;AAAA,IACF;AACA,QAAI,MAAM,SAAS,SAAS;AAC1B,UAAI,MAAM,SAAS,QAAS;AAC5B;AAAA,IACF;AACA,QAAI,QAAS,UAAS,MAAM;AAAA,EAC9B;AACA,QAAM,aAAa,MAAM,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACnD,SAAO,WAAW,SAAS,IAAI,aAAa;AAC9C;",
6
+ "names": []
7
+ }
@@ -0,0 +1,141 @@
1
+ import { decodeEntities } from "./entities.js";
2
+ const MAX_HTML_INPUT = 8 * 1024 * 1024;
3
+ const VOID_ELEMENTS = /* @__PURE__ */ new Set([
4
+ "area",
5
+ "base",
6
+ "br",
7
+ "col",
8
+ "embed",
9
+ "hr",
10
+ "img",
11
+ "input",
12
+ "link",
13
+ "meta",
14
+ "param",
15
+ "source",
16
+ "track",
17
+ "wbr"
18
+ ]);
19
+ const RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set(["script", "style"]);
20
+ function isNameStart(character) {
21
+ return character >= "a" && character <= "z" || character >= "A" && character <= "Z";
22
+ }
23
+ function isWhitespace(character) {
24
+ return character === " " || character === " " || character === "\n" || character === "\r" || character === "\f";
25
+ }
26
+ function readStartTag(html, start) {
27
+ let position = start;
28
+ while (position < html.length && !isWhitespace(html[position]) && html[position] !== ">" && html[position] !== "/") {
29
+ position += 1;
30
+ }
31
+ const name = html.slice(start, position).toLowerCase();
32
+ if (name.length === 0) return null;
33
+ const attributes = /* @__PURE__ */ new Map();
34
+ let selfClosing = false;
35
+ while (position < html.length) {
36
+ while (position < html.length && isWhitespace(html[position])) position += 1;
37
+ if (position >= html.length) break;
38
+ if (html[position] === ">") {
39
+ position += 1;
40
+ break;
41
+ }
42
+ if (html[position] === "/") {
43
+ selfClosing = true;
44
+ position += 1;
45
+ continue;
46
+ }
47
+ const nameStart = position;
48
+ while (position < html.length && !isWhitespace(html[position]) && html[position] !== "=" && html[position] !== ">" && html[position] !== "/") {
49
+ position += 1;
50
+ }
51
+ const attributeName = html.slice(nameStart, position).toLowerCase();
52
+ if (attributeName.length === 0) {
53
+ position += 1;
54
+ continue;
55
+ }
56
+ while (position < html.length && isWhitespace(html[position])) position += 1;
57
+ if (html[position] !== "=") {
58
+ attributes.set(attributeName, "");
59
+ continue;
60
+ }
61
+ position += 1;
62
+ while (position < html.length && isWhitespace(html[position])) position += 1;
63
+ const quote = html[position];
64
+ if (quote === '"' || quote === "'") {
65
+ const valueStart2 = position + 1;
66
+ const valueEnd = html.indexOf(quote, valueStart2);
67
+ if (valueEnd === -1) {
68
+ attributes.set(attributeName, decodeEntities(html.slice(valueStart2)));
69
+ position = html.length;
70
+ break;
71
+ }
72
+ attributes.set(attributeName, decodeEntities(html.slice(valueStart2, valueEnd)));
73
+ position = valueEnd + 1;
74
+ continue;
75
+ }
76
+ const valueStart = position;
77
+ while (position < html.length && !isWhitespace(html[position]) && html[position] !== ">") position += 1;
78
+ attributes.set(attributeName, decodeEntities(html.slice(valueStart, position)));
79
+ }
80
+ return { name, attributes, selfClosing: selfClosing || VOID_ELEMENTS.has(name), end: position };
81
+ }
82
+ function* tokenizeHtml(rawHtml) {
83
+ const html = rawHtml.length > MAX_HTML_INPUT ? rawHtml.slice(0, MAX_HTML_INPUT) : rawHtml;
84
+ let lowerHtml = null;
85
+ let position = 0;
86
+ while (position < html.length) {
87
+ const angle = html.indexOf("<", position);
88
+ if (angle === -1) {
89
+ const text = html.slice(position);
90
+ if (text.length > 0) yield { kind: "text", value: decodeEntities(text) };
91
+ return;
92
+ }
93
+ if (angle > position) {
94
+ yield { kind: "text", value: decodeEntities(html.slice(position, angle)) };
95
+ }
96
+ const next = html[angle + 1];
97
+ if (next === "!") {
98
+ if (html.startsWith("<!--", angle)) {
99
+ const close2 = html.indexOf("-->", angle + 4);
100
+ position = close2 === -1 ? html.length : close2 + 3;
101
+ continue;
102
+ }
103
+ const close = html.indexOf(">", angle + 2);
104
+ position = close === -1 ? html.length : close + 1;
105
+ continue;
106
+ }
107
+ if (next === "/") {
108
+ const close = html.indexOf(">", angle + 2);
109
+ const end = close === -1 ? html.length : close;
110
+ const name = html.slice(angle + 2, end).trim().toLowerCase();
111
+ if (name.length > 0) yield { kind: "close", name };
112
+ position = close === -1 ? html.length : close + 1;
113
+ continue;
114
+ }
115
+ if (next === void 0 || !isNameStart(next)) {
116
+ yield { kind: "text", value: "<" };
117
+ position = angle + 1;
118
+ continue;
119
+ }
120
+ const tag = readStartTag(html, angle + 1);
121
+ if (tag === null) {
122
+ yield { kind: "text", value: "<" };
123
+ position = angle + 1;
124
+ continue;
125
+ }
126
+ yield { kind: "open", name: tag.name, attributes: tag.attributes, selfClosing: tag.selfClosing };
127
+ position = tag.end;
128
+ if (RAW_TEXT_ELEMENTS.has(tag.name) && !tag.selfClosing) {
129
+ lowerHtml ??= html.toLowerCase();
130
+ const close = lowerHtml.indexOf(`</${tag.name}`, position);
131
+ position = close === -1 ? html.length : close;
132
+ continue;
133
+ }
134
+ if (tag.selfClosing) yield { kind: "close", name: tag.name };
135
+ }
136
+ }
137
+ export {
138
+ MAX_HTML_INPUT,
139
+ tokenizeHtml
140
+ };
141
+ //# sourceMappingURL=tokenizer.js.map