@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,22 @@
1
+ export {
2
+ buildCacheKey,
3
+ createSingleFlight,
4
+ type CacheEntry,
5
+ type ResultCache,
6
+ } from './cache'
7
+ export { DEADLINE_REACHED, deadlineRace, linkSignals, sleep, type Cancellable } from './deadline'
8
+ export { createSearchEngine } from './engine'
9
+ export { outcomeReason, outcomeToStatus, runAdapter, type AdapterRunResult } from './runAdapter'
10
+ export type {
11
+ AdapterDiagnostic,
12
+ AdapterDiagnosticStatus,
13
+ AdapterHealthReport,
14
+ AdapterResultSet,
15
+ EngineAdapterEntry,
16
+ HealthOptions,
17
+ RunOptions,
18
+ SearchDiagnostics,
19
+ SearchEngine,
20
+ SearchEngineOptions,
21
+ SearchEngineResult,
22
+ } from './types'
@@ -0,0 +1,89 @@
1
+ import type { AdapterContext } from '../contract/adapter'
2
+ import { errorMessage } from '../contract/errors'
3
+ import type { HttpClient, Logger } from '../contract/http'
4
+ import { failed, type SearchOutcome } from '../contract/outcomes'
5
+ import type { SearchRequest } from '../contract/results'
6
+ import type { SearchStepEvent, SearchStepMetrics } from '../contract/steps'
7
+ import { linkSignals } from './deadline'
8
+ import type { AdapterDiagnosticStatus, EngineAdapterEntry } from './types'
9
+
10
+ export type AdapterRunResult = {
11
+ readonly entry: EngineAdapterEntry
12
+ readonly outcome: SearchOutcome
13
+ readonly latencyMs: number
14
+ }
15
+
16
+ export type AdapterRunDeps = {
17
+ readonly http: HttpClient
18
+ readonly logger: Logger
19
+ readonly now: () => number
20
+ readonly report: (adapterId: string, event: SearchStepEvent, detail?: string, metrics?: SearchStepMetrics) => void
21
+ readonly parentSignal: AbortSignal
22
+ readonly deadlineAt: number
23
+ readonly adapterTimeoutMs: number
24
+ }
25
+
26
+ export function outcomeToStatus(outcome: SearchOutcome): AdapterDiagnosticStatus {
27
+ switch (outcome.status) {
28
+ case 'ok':
29
+ return 'ok'
30
+ case 'empty':
31
+ return 'empty'
32
+ case 'unavailable':
33
+ return 'unavailable'
34
+ case 'blocked':
35
+ return 'blocked'
36
+ case 'timeout':
37
+ return 'timeout'
38
+ case 'error':
39
+ return 'error'
40
+ }
41
+ }
42
+
43
+ export function outcomeReason(outcome: SearchOutcome): string | undefined {
44
+ if (outcome.status === 'ok' || outcome.status === 'empty') return undefined
45
+ return outcome.reason
46
+ }
47
+
48
+ /**
49
+ * Runs one adapter under its own timeout and abort linkage.
50
+ *
51
+ * The contract says adapters return outcomes rather than throwing, but a
52
+ * third-party adapter is not trusted to honour that — a throw here is converted
53
+ * into an `error` outcome so one misbehaving package cannot abort the whole run.
54
+ */
55
+ export async function runAdapter(
56
+ entry: EngineAdapterEntry,
57
+ request: SearchRequest,
58
+ deps: AdapterRunDeps,
59
+ ): Promise<AdapterRunResult> {
60
+ const startedAt = deps.now()
61
+ const allowance = entry.timeoutMs ?? deps.adapterTimeoutMs
62
+ const budget = Math.max(0, Math.min(allowance, deps.deadlineAt - startedAt))
63
+ const controller = new AbortController()
64
+ const timer = setTimeout(() => controller.abort(), budget)
65
+ timer.unref?.()
66
+ const linked = linkSignals([deps.parentSignal, controller.signal])
67
+
68
+ const context: AdapterContext = {
69
+ signal: linked.signal,
70
+ http: deps.http,
71
+ report: (event, detail, metrics) => deps.report(entry.adapter.id, event, detail, metrics),
72
+ deadlineAt: startedAt + budget,
73
+ logger: deps.logger,
74
+ }
75
+
76
+ deps.report(entry.adapter.id, 'started')
77
+
78
+ try {
79
+ const outcome = await entry.adapter.search(request, context)
80
+ return { entry, outcome, latencyMs: deps.now() - startedAt }
81
+ } catch (error) {
82
+ const detail = `adapter threw instead of returning an outcome: ${errorMessage(error)}`
83
+ deps.logger.warn(`[web-research] ${entry.adapter.id} ${detail}`)
84
+ return { entry, outcome: failed(detail, false), latencyMs: deps.now() - startedAt }
85
+ } finally {
86
+ clearTimeout(timer)
87
+ linked.dispose()
88
+ }
89
+ }
@@ -0,0 +1,137 @@
1
+ import type { AdapterHealth, ProbeCost, SearchAdapter } from '../contract/adapter'
2
+ import type { HttpClient, Logger } from '../contract/http'
3
+ import type { FetchOutcome } from '../contract/outcomes'
4
+ import type { SearchPolicy } from '../contract/policy'
5
+ import type { FetchRequest, RawResult, SearchRequestInput, SearchResult } from '../contract/results'
6
+ import type { StepSink } from '../contract/steps'
7
+ import type { ResultCache } from './cache'
8
+
9
+ export type AdapterDiagnosticStatus =
10
+ | 'ok'
11
+ | 'empty'
12
+ | 'unavailable'
13
+ | 'blocked'
14
+ | 'timeout'
15
+ | 'error'
16
+ | 'skipped'
17
+ | 'cancelled'
18
+
19
+ export type AdapterDiagnostic = {
20
+ readonly id: string
21
+ readonly status: AdapterDiagnosticStatus
22
+ readonly latencyMs: number
23
+ readonly resultCount: number
24
+ readonly reason?: string
25
+ }
26
+
27
+ export type SearchDiagnostics = {
28
+ readonly adapters: readonly AdapterDiagnostic[]
29
+ readonly degraded: boolean
30
+ readonly cached: boolean
31
+ readonly escalated: boolean
32
+ readonly elapsedMs: number
33
+ /** Pages fetched for inline content; what the host charges a fetch budget for. */
34
+ readonly pagesRead: number
35
+ }
36
+
37
+ /**
38
+ * One adapter's own answer, before fusion touched it.
39
+ *
40
+ * Fusion is lossy on purpose: dedup, the per-domain cap and the limit all drop
41
+ * hits, and only the first prose answer survives. That is right for an agent,
42
+ * which wants one ranked list rather than N to reconcile — but it leaves an
43
+ * operator comparing sources with nothing to compare. This is that view, and it
44
+ * is opt-in for the same reason it is not the default.
45
+ */
46
+ export type AdapterResultSet = {
47
+ readonly adapterId: string
48
+ readonly weight: number
49
+ readonly answer: string | null
50
+ /** Exactly what the adapter returned, in its own order. */
51
+ readonly results: readonly RawResult[]
52
+ }
53
+
54
+ export type SearchEngineResult = {
55
+ readonly results: readonly SearchResult[]
56
+ /** Prose synthesis when an adapter produced one; never fabricated by the engine. */
57
+ readonly answer: string | null
58
+ readonly diagnostics: SearchDiagnostics
59
+ /** Present only when the caller asked for `includeAdapterResults`. */
60
+ readonly byAdapter?: readonly AdapterResultSet[]
61
+ }
62
+
63
+ export type AdapterHealthReport = AdapterHealth & {
64
+ readonly id: string
65
+ readonly ready: boolean
66
+ /** What probing this adapter costs, resolved through the engine's default. */
67
+ readonly probeCost?: ProbeCost
68
+ /** False when the row reports configuration only, with no call made. */
69
+ readonly probed?: boolean
70
+ }
71
+
72
+ export type EngineAdapterEntry = {
73
+ readonly adapter: SearchAdapter
74
+ readonly weight: number
75
+ readonly order: number
76
+ /**
77
+ * Whether the normal waves may use it. A disabled adapter is still reachable
78
+ * as `policy.lastResort`, which is what lets an operator keep an expensive
79
+ * source out of every query while retaining it as the safety net.
80
+ */
81
+ readonly enabled: boolean
82
+ /** Overrides `policy.adapterTimeoutMs` for this adapter alone. */
83
+ readonly timeoutMs?: number
84
+ }
85
+
86
+ export type SearchEngineOptions = {
87
+ readonly policy: SearchPolicy
88
+ readonly adapters: readonly EngineAdapterEntry[]
89
+ readonly http: HttpClient
90
+ readonly logger?: Logger
91
+ readonly cache?: ResultCache<SearchEngineResult>
92
+ readonly onStep?: StepSink
93
+ readonly now?: () => number
94
+ }
95
+
96
+ export type RunOptions = {
97
+ readonly signal?: AbortSignal
98
+ readonly onStep?: StepSink
99
+ /**
100
+ * Also return each adapter's untouched result list and prose. An operator
101
+ * comparison aid, deliberately separate from `settleMode`: that decides when
102
+ * to stop, this decides what to hand back. Pair it with `exhaustive` or the
103
+ * scheduler will cancel the very adapters you meant to compare.
104
+ */
105
+ readonly includeAdapterResults?: boolean
106
+ }
107
+
108
+ export type HealthOptions = RunOptions & {
109
+ /**
110
+ * Whether to actually call `healthCheck()` on each adapter.
111
+ *
112
+ * Off means readiness only, which is synchronous and I/O-free. A live probe is
113
+ * not free for every adapter — a metered source spends a real search credit and
114
+ * the browser tier spawns a process — so a screen that merely displays status
115
+ * must be able to ask without billing anyone.
116
+ */
117
+ readonly probe?: boolean
118
+ /**
119
+ * Ceiling on what may be spent probing. `'free'` calls only the adapters whose
120
+ * probe costs nothing; the rest come back `probed: false` with their readiness
121
+ * intact. Ignored when `probe` is false. Defaults to `'billable'`, which is the
122
+ * behaviour every existing caller already gets.
123
+ */
124
+ readonly maxProbeCost?: ProbeCost
125
+ /**
126
+ * Restrict probing to these adapter ids. Everything else still reports
127
+ * readiness, so a targeted re-test of one adapter cannot bill the others.
128
+ */
129
+ readonly only?: readonly string[]
130
+ }
131
+
132
+ export interface SearchEngine {
133
+ search(input: SearchRequestInput, options?: RunOptions): Promise<SearchEngineResult>
134
+ fetch(request: FetchRequest, options?: RunOptions): Promise<FetchOutcome>
135
+ health(options?: HealthOptions): Promise<readonly AdapterHealthReport[]>
136
+ dispose(): Promise<void>
137
+ }
@@ -0,0 +1,81 @@
1
+ import { classifyPage } from '../classify'
2
+ import { extractMainContent } from '../content'
3
+
4
+ const paragraph = (marker: string): string =>
5
+ `<p>${marker} ${'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod. '.repeat(4)}</p>`
6
+
7
+ describe('extractMainContent', () => {
8
+ it('prefers the article over navigation chrome', () => {
9
+ const html = `
10
+ <body>
11
+ <nav class="site-nav"><a href="/a">Home</a><a href="/b">Products</a><a href="/c">Pricing</a></nav>
12
+ <div class="sidebar"><a href="/x">Related one</a><a href="/y">Related two</a></div>
13
+ <article class="post-body">${paragraph('ARTICLE')}${paragraph('ARTICLE')}</article>
14
+ <footer class="site-footer"><a href="/tos">Terms</a></footer>
15
+ </body>`
16
+ const text = extractMainContent(html)
17
+ expect(text).toContain('ARTICLE')
18
+ expect(text).not.toContain('Terms')
19
+ expect(text).not.toContain('Pricing')
20
+ })
21
+
22
+ it('penalizes a link-dense block against a prose block of similar length', () => {
23
+ const links = `<div class="menu">${'<a href="/l">navigation link text here</a>'.repeat(30)}</div>`
24
+ const prose = `<div class="entry-content">${paragraph('PROSE')}</div>`
25
+ const text = extractMainContent(`<body>${links}${prose}</body>`)
26
+ expect(text).toContain('PROSE')
27
+ })
28
+
29
+ it('falls back to the whole document when nothing scores well', () => {
30
+ expect(extractMainContent('<body><p>tiny</p></body>')).toBe('tiny')
31
+ })
32
+
33
+ it('tolerates unbalanced markup', () => {
34
+ const html = `<body><div class="content"><article>${paragraph('X')}</div></body>`
35
+ expect(() => extractMainContent(html)).not.toThrow()
36
+ expect(extractMainContent(html)).toContain('X')
37
+ })
38
+ })
39
+
40
+ describe('classifyPage', () => {
41
+ const base = { status: 200, contentType: 'text/html' }
42
+
43
+ it('accepts a page with real text', () => {
44
+ const text = 'a'.repeat(500)
45
+ expect(classifyPage({ ...base, html: `<p>${text}</p>`, text }).verdict).toBe('ok')
46
+ })
47
+
48
+ it.each([403, 429, 503])('treats HTTP %s as blocked', (status) => {
49
+ expect(classifyPage({ ...base, status, html: '<html></html>', text: '' }).verdict).toBe('blocked')
50
+ })
51
+
52
+ it('detects an anti-bot interstitial', () => {
53
+ const html = '<html><body><h1>Just a moment...</h1><div id="cf_chl_opt"></div></body></html>'
54
+ expect(classifyPage({ ...base, html, text: 'Just a moment...' }).verdict).toBe('blocked')
55
+ })
56
+
57
+ // Observed against a real LinkedIn company page: 12,987 chars of usable text,
58
+ // but the word "captcha" appears in a bundled script. Flagging that as blocked
59
+ // triggers a browser render for a page that already worked.
60
+ it('does not call a content-bearing page blocked just because its markup mentions a challenge', () => {
61
+ const text = 'Stripe. Technology, Information and Internet. '.repeat(40)
62
+ const html = `<html><body><script>var x="captcha";</script><article><p>${text}</p></article></body></html>`
63
+ expect(classifyPage({ ...base, html, text }).verdict).toBe('ok')
64
+ })
65
+
66
+ it('detects a client-side render shell', () => {
67
+ const html = `<html><body><div id="root"></div>${'<script>x</script>'.repeat(400)}</body></html>`
68
+ expect(classifyPage({ ...base, html, text: '' }).verdict).toBe('js-shell')
69
+ })
70
+
71
+ it('detects a JavaScript-required notice', () => {
72
+ const html = '<html><body><noscript>You need to enable JavaScript to run this app.</noscript></body></html>'
73
+ const result = classifyPage({ ...base, html, text: '' })
74
+ expect(result.verdict).toBe('js-shell')
75
+ expect(result.reason).toContain('JavaScript')
76
+ })
77
+
78
+ it('reports an empty body as empty rather than blocked', () => {
79
+ expect(classifyPage({ ...base, html: '', text: '' }).verdict).toBe('empty')
80
+ })
81
+ })
@@ -0,0 +1,61 @@
1
+ import { decodeEntities } from '../entities'
2
+ import { extractTitle, htmlToText, normalizeWhitespace } from '../text'
3
+
4
+ describe('decodeEntities', () => {
5
+ it('decodes named, decimal and hex references', () => {
6
+ expect(decodeEntities('a&amp;b &#65; &#x42;')).toBe('a&b A B')
7
+ })
8
+
9
+ it('leaves unknown or unterminated references alone', () => {
10
+ expect(decodeEntities('&notareal; &amp')).toBe('&notareal; &amp')
11
+ })
12
+
13
+ it('maps nbsp to a plain space', () => {
14
+ expect(decodeEntities('a&nbsp;b')).toBe('a b')
15
+ })
16
+
17
+ it('rejects lone surrogates', () => {
18
+ expect(decodeEntities('&#xD800;')).toBe('�')
19
+ })
20
+ })
21
+
22
+ describe('htmlToText', () => {
23
+ it('strips markup and keeps readable text', () => {
24
+ expect(htmlToText('<p>Hello <b>world</b></p>')).toBe('Hello world')
25
+ })
26
+
27
+ it('drops script and style content', () => {
28
+ const html = '<style>.a{color:red}</style><script>alert(1)</script><p>Body</p>'
29
+ expect(htmlToText(html)).toBe('Body')
30
+ })
31
+
32
+ it('does not leak the document title into body text', () => {
33
+ expect(htmlToText('<html><head><title>Tab</title></head><body><p>Body</p></body></html>')).toBe('Body')
34
+ })
35
+
36
+ it('separates paragraphs with a blank line', () => {
37
+ expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\n\ntwo')
38
+ })
39
+
40
+ it('keeps list items on separate lines', () => {
41
+ expect(htmlToText('<ul><li>a</li><li>b</li></ul>')).toBe('a\n\nb')
42
+ })
43
+
44
+ it('collapses runs of whitespace', () => {
45
+ expect(normalizeWhitespace('a \t b\n\n\n\nc')).toBe('a b\n\nc')
46
+ })
47
+ })
48
+
49
+ describe('extractTitle', () => {
50
+ it('reads the document title', () => {
51
+ expect(extractTitle('<html><head><title> Hello World </title></head></html>')).toBe('Hello World')
52
+ })
53
+
54
+ it('returns null when there is no title', () => {
55
+ expect(extractTitle('<html><body><p>x</p></body></html>')).toBeNull()
56
+ })
57
+
58
+ it('does not scan past the start of the body', () => {
59
+ expect(extractTitle('<body><title>late</title></body>')).toBeNull()
60
+ })
61
+ })
@@ -0,0 +1,85 @@
1
+ import { tokenizeHtml, type HtmlToken } from '../tokenizer'
2
+
3
+ const collect = (html: string): HtmlToken[] => [...tokenizeHtml(html)]
4
+
5
+ describe('tokenizeHtml', () => {
6
+ it('emits open, text and close tokens in document order', () => {
7
+ expect(collect('<p>hi</p>')).toEqual([
8
+ { kind: 'open', name: 'p', attributes: new Map(), selfClosing: false },
9
+ { kind: 'text', value: 'hi' },
10
+ { kind: 'close', name: 'p' },
11
+ ])
12
+ })
13
+
14
+ it('lowercases tag and attribute names', () => {
15
+ const [token] = collect('<DIV CLASS="Body">')
16
+ expect(token).toMatchObject({ kind: 'open', name: 'div' })
17
+ if (token.kind !== 'open') throw new Error('expected an open token')
18
+ expect(token.attributes.get('class')).toBe('Body')
19
+ })
20
+
21
+ it('parses single-quoted, double-quoted and bare attribute values', () => {
22
+ const [token] = collect(`<a href='/x' title="y" data-n=3>`)
23
+ if (token.kind !== 'open') throw new Error('expected an open token')
24
+ expect(token.attributes.get('href')).toBe('/x')
25
+ expect(token.attributes.get('title')).toBe('y')
26
+ expect(token.attributes.get('data-n')).toBe('3')
27
+ })
28
+
29
+ it('does not truncate a tag on a > inside a quoted attribute value', () => {
30
+ const [token] = collect('<a title="a > b" href="/ok">text</a>')
31
+ if (token.kind !== 'open') throw new Error('expected an open token')
32
+ expect(token.attributes.get('title')).toBe('a > b')
33
+ expect(token.attributes.get('href')).toBe('/ok')
34
+ })
35
+
36
+ it('decodes entities in attribute values and text', () => {
37
+ const tokens = collect('<a title="a&amp;b">x&lt;y</a>')
38
+ const [open, text] = tokens
39
+ if (open.kind !== 'open') throw new Error('expected an open token')
40
+ expect(open.attributes.get('title')).toBe('a&b')
41
+ expect(text).toEqual({ kind: 'text', value: 'x<y' })
42
+ })
43
+
44
+ it('skips comments and doctypes', () => {
45
+ expect(collect('<!DOCTYPE html><!-- note --><p>a</p>').map((token) => token.kind)).toEqual([
46
+ 'open',
47
+ 'text',
48
+ 'close',
49
+ ])
50
+ })
51
+
52
+ it('drops raw text inside script and style', () => {
53
+ const tokens = collect('<script>var a = "<p>no</p>";</script><p>yes</p>')
54
+ const text = tokens.filter((token) => token.kind === 'text').map((token) => token.value)
55
+ expect(text).toEqual(['yes'])
56
+ })
57
+
58
+ it('synthesizes a close token for void elements', () => {
59
+ expect(collect('<br>').map((token) => token.kind)).toEqual(['open', 'close'])
60
+ })
61
+
62
+ it('treats a stray angle bracket as text', () => {
63
+ expect(collect('5 < 7')).toEqual([
64
+ { kind: 'text', value: '5 ' },
65
+ { kind: 'text', value: '<' },
66
+ { kind: 'text', value: ' 7' },
67
+ ])
68
+ })
69
+
70
+ it('survives unterminated markup without hanging', () => {
71
+ expect(() => collect('<div class="x')).not.toThrow()
72
+ expect(() => collect('<!-- never closed')).not.toThrow()
73
+ expect(() => collect('<script>forever')).not.toThrow()
74
+ })
75
+
76
+ it('runs in linear time on adversarial nesting', () => {
77
+ // A regex-based extractor with lazy quantifiers stalls on this shape; the
78
+ // linear scan must not.
79
+ const hostile = `${'<div class="a">'.repeat(20_000)}text`
80
+ const started = Date.now()
81
+ const tokens = collect(hostile)
82
+ expect(tokens.length).toBeGreaterThan(20_000)
83
+ expect(Date.now() - started).toBeLessThan(2_000)
84
+ })
85
+ })
@@ -0,0 +1,81 @@
1
+ import type { PageVerdict } from '../contract/results'
2
+
3
+ export type PageClassification = {
4
+ readonly verdict: PageVerdict
5
+ readonly reason?: string
6
+ }
7
+
8
+ export type ClassifyInput = {
9
+ readonly status: number
10
+ readonly contentType: string | null
11
+ readonly html: string
12
+ readonly text: string
13
+ }
14
+
15
+ const CHALLENGE_MARKERS = [
16
+ 'cf-browser-verification',
17
+ 'cf_chl_opt',
18
+ 'just a moment',
19
+ 'checking your browser',
20
+ 'enable cookies to continue',
21
+ 'ddos protection by',
22
+ 'access denied',
23
+ 'captcha',
24
+ 'are you a robot',
25
+ ]
26
+
27
+ const JS_REQUIRED_MARKERS = [
28
+ 'enable javascript',
29
+ 'javascript is required',
30
+ 'javascript is disabled',
31
+ "doesn't work properly without javascript",
32
+ 'you need to enable javascript to run this app',
33
+ ]
34
+
35
+ const EMPTY_ROOT = /<div[^<>]{0,200}\bid=["']?(root|__next|app|__nuxt)["']?[^<>]{0,200}>\s*<\/div>/i
36
+
37
+ /** Below this, a text-bearing HTML page is almost certainly a shell, not content. */
38
+ const MIN_MEANINGFUL_TEXT = 200
39
+ const SHELL_HTML_THRESHOLD = 4_000
40
+
41
+ /**
42
+ * Decides whether an HTTP read actually produced readable content. The verdict
43
+ * drives browser escalation in code rather than leaving the judgement to the
44
+ * model, which costs a full round-trip and answers differently each time.
45
+ */
46
+ export function classifyPage(input: ClassifyInput): PageClassification {
47
+ const haystack = input.html.slice(0, 200_000).toLowerCase()
48
+
49
+ if (input.status === 403 || input.status === 429 || input.status === 503) {
50
+ return { verdict: 'blocked', reason: `HTTP ${input.status}` }
51
+ }
52
+
53
+ const text = input.text.trim()
54
+ if (text.length === 0 && input.html.trim().length === 0) {
55
+ return { verdict: 'empty', reason: 'no response body' }
56
+ }
57
+
58
+ // Marker matching runs over raw markup, including script bundles, so plenty of
59
+ // ordinary pages mention "captcha" or "access denied" without being one. A
60
+ // challenge page is content-free by definition, so the markers only count when
61
+ // the page also failed to yield readable text — otherwise a working page gets
62
+ // flagged and triggers a pointless browser escalation.
63
+ if (text.length < MIN_MEANINGFUL_TEXT) {
64
+ const challenge = CHALLENGE_MARKERS.find((marker) => haystack.includes(marker))
65
+ if (challenge) return { verdict: 'blocked', reason: `anti-bot challenge (${challenge})` }
66
+ }
67
+
68
+ const jsMarker = JS_REQUIRED_MARKERS.find((marker) => haystack.includes(marker))
69
+ if (jsMarker && text.length < MIN_MEANINGFUL_TEXT) {
70
+ return { verdict: 'js-shell', reason: `page requires JavaScript (${jsMarker})` }
71
+ }
72
+ if (EMPTY_ROOT.test(input.html) && text.length < MIN_MEANINGFUL_TEXT) {
73
+ return { verdict: 'js-shell', reason: 'empty client-side render root' }
74
+ }
75
+ if (text.length < MIN_MEANINGFUL_TEXT && input.html.length > SHELL_HTML_THRESHOLD) {
76
+ return { verdict: 'js-shell', reason: `only ${text.length} chars of text in ${input.html.length} of markup` }
77
+ }
78
+ if (text.length === 0) return { verdict: 'empty', reason: 'no extractable text' }
79
+
80
+ return { verdict: 'ok' }
81
+ }