@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +67 -0
- package/build.mjs +10 -0
- package/dist/contract/adapter.js +13 -0
- package/dist/contract/adapter.js.map +7 -0
- package/dist/contract/errors.js +28 -0
- package/dist/contract/errors.js.map +7 -0
- package/dist/contract/http.js +7 -0
- package/dist/contract/http.js.map +7 -0
- package/dist/contract/index.js +9 -0
- package/dist/contract/index.js.map +7 -0
- package/dist/contract/outcomes.js +58 -0
- package/dist/contract/outcomes.js.map +7 -0
- package/dist/contract/policy.js +91 -0
- package/dist/contract/policy.js.map +7 -0
- package/dist/contract/results.js +13 -0
- package/dist/contract/results.js.map +7 -0
- package/dist/contract/steps.js +1 -0
- package/dist/contract/steps.js.map +7 -0
- package/dist/contract/version.js +11 -0
- package/dist/contract/version.js.map +7 -0
- package/dist/engine/cache.js +29 -0
- package/dist/engine/cache.js.map +7 -0
- package/dist/engine/deadline.js +54 -0
- package/dist/engine/deadline.js.map +7 -0
- package/dist/engine/engine.js +481 -0
- package/dist/engine/engine.js.map +7 -0
- package/dist/engine/index.js +20 -0
- package/dist/engine/index.js.map +7 -0
- package/dist/engine/runAdapter.js +57 -0
- package/dist/engine/runAdapter.js.map +7 -0
- package/dist/engine/types.js +1 -0
- package/dist/engine/types.js.map +7 -0
- package/dist/extract/classify.js +51 -0
- package/dist/extract/classify.js.map +7 -0
- package/dist/extract/content.js +104 -0
- package/dist/extract/content.js.map +7 -0
- package/dist/extract/entities.js +95 -0
- package/dist/extract/entities.js.map +7 -0
- package/dist/extract/index.js +24 -0
- package/dist/extract/index.js.map +7 -0
- package/dist/extract/text.js +114 -0
- package/dist/extract/text.js.map +7 -0
- package/dist/extract/tokenizer.js +141 -0
- package/dist/extract/tokenizer.js.map +7 -0
- package/dist/fusion/canonicalize.js +88 -0
- package/dist/fusion/canonicalize.js.map +7 -0
- package/dist/fusion/fuse.js +88 -0
- package/dist/fusion/fuse.js.map +7 -0
- package/dist/fusion/index.js +11 -0
- package/dist/fusion/index.js.map +7 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +7 -0
- package/dist/loader/describe.js +92 -0
- package/dist/loader/describe.js.map +7 -0
- package/dist/loader/index.js +31 -0
- package/dist/loader/index.js.map +7 -0
- package/dist/loader/manifest.js +32 -0
- package/dist/loader/manifest.js.map +7 -0
- package/dist/loader/registry.js +98 -0
- package/dist/loader/registry.js.map +7 -0
- package/dist/net/client.js +298 -0
- package/dist/net/client.js.map +7 -0
- package/dist/net/ssrf.js +165 -0
- package/dist/net/ssrf.js.map +7 -0
- package/dist/net/url.js +9 -0
- package/dist/net/url.js.map +7 -0
- package/dist/testing/contract.js +118 -0
- package/dist/testing/contract.js.map +7 -0
- package/dist/testing/fakeAdapter.js +44 -0
- package/dist/testing/fakeAdapter.js.map +7 -0
- package/dist/testing/index.js +15 -0
- package/dist/testing/index.js.map +7 -0
- package/jest.config.cjs +24 -0
- package/package.json +65 -0
- package/src/contract/adapter.ts +100 -0
- package/src/contract/errors.ts +48 -0
- package/src/contract/http.ts +46 -0
- package/src/contract/index.ts +8 -0
- package/src/contract/outcomes.ts +91 -0
- package/src/contract/policy.ts +157 -0
- package/src/contract/results.ts +79 -0
- package/src/contract/steps.ts +45 -0
- package/src/contract/version.ts +25 -0
- package/src/engine/__tests__/engine.test.ts +597 -0
- package/src/engine/cache.ts +46 -0
- package/src/engine/deadline.ts +65 -0
- package/src/engine/engine.ts +633 -0
- package/src/engine/index.ts +22 -0
- package/src/engine/runAdapter.ts +89 -0
- package/src/engine/types.ts +137 -0
- package/src/extract/__tests__/content.test.ts +81 -0
- package/src/extract/__tests__/text.test.ts +61 -0
- package/src/extract/__tests__/tokenizer.test.ts +85 -0
- package/src/extract/classify.ts +81 -0
- package/src/extract/content.ts +136 -0
- package/src/extract/entities.ts +98 -0
- package/src/extract/index.ts +11 -0
- package/src/extract/text.ts +124 -0
- package/src/extract/tokenizer.ts +197 -0
- package/src/fusion/__tests__/fusion.test.ts +198 -0
- package/src/fusion/canonicalize.ts +119 -0
- package/src/fusion/fuse.ts +145 -0
- package/src/fusion/index.ts +8 -0
- package/src/index.ts +15 -0
- package/src/loader/__tests__/describe.test.ts +119 -0
- package/src/loader/__tests__/registry.test.ts +134 -0
- package/src/loader/describe.ts +145 -0
- package/src/loader/index.ts +25 -0
- package/src/loader/manifest.ts +44 -0
- package/src/loader/registry.ts +156 -0
- package/src/net/__tests__/client.test.ts +273 -0
- package/src/net/__tests__/ssrf.test.ts +165 -0
- package/src/net/client.ts +434 -0
- package/src/net/ssrf.ts +235 -0
- package/src/net/url.ts +13 -0
- package/src/testing/contract.ts +172 -0
- package/src/testing/fakeAdapter.ts +69 -0
- package/src/testing/index.ts +9 -0
- package/tsconfig.json +10 -0
- 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&b A B')).toBe('a&b A B')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('leaves unknown or unterminated references alone', () => {
|
|
10
|
+
expect(decodeEntities('¬areal; &')).toBe('¬areal; &')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('maps nbsp to a plain space', () => {
|
|
14
|
+
expect(decodeEntities('a b')).toBe('a b')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('rejects lone surrogates', () => {
|
|
18
|
+
expect(decodeEntities('�')).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&b">x<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
|
+
}
|