@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,136 @@
|
|
|
1
|
+
import { BLOCK_ELEMENTS, NON_CONTENT_ELEMENTS, normalizeWhitespace } from './text'
|
|
2
|
+
import { tokenizeHtml } from './tokenizer'
|
|
3
|
+
|
|
4
|
+
const CANDIDATE_ELEMENTS = new Set(['article', 'main', 'section', 'div', 'td', 'body'])
|
|
5
|
+
const SEMANTIC_BONUS = new Set(['article', 'main'])
|
|
6
|
+
|
|
7
|
+
const NEGATIVE_HINT =
|
|
8
|
+
/(^|[\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
|
|
9
|
+
const POSITIVE_HINT = /(^|[\s_-])(article|content|post|story|entry|body-?text|main)([\s_-]|$)/i
|
|
10
|
+
|
|
11
|
+
/** Below this a candidate is treated as chrome rather than an article. */
|
|
12
|
+
const MIN_CONTENT_CHARS = 200
|
|
13
|
+
|
|
14
|
+
type BestRange = {
|
|
15
|
+
readonly startPart: number
|
|
16
|
+
readonly endPart: number
|
|
17
|
+
readonly score: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Frame = {
|
|
21
|
+
readonly name: string
|
|
22
|
+
readonly startPart: number
|
|
23
|
+
readonly hintScore: number
|
|
24
|
+
textLength: number
|
|
25
|
+
linkTextLength: number
|
|
26
|
+
paragraphs: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hintScoreOf(attributes: ReadonlyMap<string, string>): number {
|
|
30
|
+
const descriptor = `${attributes.get('class') ?? ''} ${attributes.get('id') ?? ''} ${attributes.get('role') ?? ''}`
|
|
31
|
+
if (descriptor.trim().length === 0) return 0
|
|
32
|
+
let score = 0
|
|
33
|
+
if (NEGATIVE_HINT.test(descriptor)) score -= 600
|
|
34
|
+
if (POSITIVE_HINT.test(descriptor)) score += 300
|
|
35
|
+
return score
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scoreOf(frame: Frame): number {
|
|
39
|
+
const semantic = SEMANTIC_BONUS.has(frame.name) ? 400 : 0
|
|
40
|
+
return frame.textLength - 2 * frame.linkTextLength + 25 * frame.paragraphs + frame.hintScore + semantic
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Picks the subtree that reads like the article, using text-to-link density plus
|
|
45
|
+
* paragraph count and class/id hints. Counters are accumulated during the single
|
|
46
|
+
* tokenizer pass and only the winning range is materialized, so the scan stays
|
|
47
|
+
* linear in time and the output allocates once.
|
|
48
|
+
*/
|
|
49
|
+
export function extractMainContent(html: string): string {
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
const stack: Frame[] = []
|
|
52
|
+
const best: { current: BestRange | null } = { current: null }
|
|
53
|
+
let linkDepth = 0
|
|
54
|
+
let skipName: string | null = null
|
|
55
|
+
let skipDepth = 0
|
|
56
|
+
|
|
57
|
+
const closeFrame = (): void => {
|
|
58
|
+
const frame = stack.pop()
|
|
59
|
+
if (!frame) return
|
|
60
|
+
const score = scoreOf(frame)
|
|
61
|
+
if (frame.textLength >= MIN_CONTENT_CHARS && (best.current === null || score > best.current.score)) {
|
|
62
|
+
best.current = { startPart: frame.startPart, endPart: parts.length, score }
|
|
63
|
+
}
|
|
64
|
+
const parent = stack[stack.length - 1]
|
|
65
|
+
if (parent) {
|
|
66
|
+
parent.textLength += frame.textLength
|
|
67
|
+
parent.linkTextLength += frame.linkTextLength
|
|
68
|
+
parent.paragraphs += frame.paragraphs
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const token of tokenizeHtml(html)) {
|
|
73
|
+
if (token.kind === 'open') {
|
|
74
|
+
if (skipName !== null) {
|
|
75
|
+
if (token.name === skipName) skipDepth += 1
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (NON_CONTENT_ELEMENTS.has(token.name)) {
|
|
79
|
+
skipName = token.name
|
|
80
|
+
skipDepth = 1
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (BLOCK_ELEMENTS.has(token.name)) parts.push('\n')
|
|
84
|
+
if (token.name === 'a') linkDepth += 1
|
|
85
|
+
if (token.name === 'p') {
|
|
86
|
+
const frame = stack[stack.length - 1]
|
|
87
|
+
if (frame) frame.paragraphs += 1
|
|
88
|
+
}
|
|
89
|
+
if (CANDIDATE_ELEMENTS.has(token.name) && !token.selfClosing) {
|
|
90
|
+
stack.push({
|
|
91
|
+
name: token.name,
|
|
92
|
+
startPart: parts.length,
|
|
93
|
+
hintScore: hintScoreOf(token.attributes),
|
|
94
|
+
textLength: 0,
|
|
95
|
+
linkTextLength: 0,
|
|
96
|
+
paragraphs: 0,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (token.kind === 'close') {
|
|
103
|
+
if (skipName !== null) {
|
|
104
|
+
if (token.name === skipName) {
|
|
105
|
+
skipDepth -= 1
|
|
106
|
+
if (skipDepth <= 0) skipName = null
|
|
107
|
+
}
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (BLOCK_ELEMENTS.has(token.name)) parts.push('\n')
|
|
111
|
+
if (token.name === 'a' && linkDepth > 0) linkDepth -= 1
|
|
112
|
+
if (CANDIDATE_ELEMENTS.has(token.name)) {
|
|
113
|
+
// Unbalanced markup is common; only unwind to the nearest matching frame.
|
|
114
|
+
const index = stack.findLastIndex((frame) => frame.name === token.name)
|
|
115
|
+
if (index !== -1) {
|
|
116
|
+
while (stack.length > index) closeFrame()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (skipName !== null) continue
|
|
123
|
+
parts.push(token.value)
|
|
124
|
+
const frame = stack[stack.length - 1]
|
|
125
|
+
if (!frame) continue
|
|
126
|
+
const length = token.value.trim().length
|
|
127
|
+
frame.textLength += length
|
|
128
|
+
if (linkDepth > 0) frame.linkTextLength += length
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
while (stack.length > 0) closeFrame()
|
|
132
|
+
|
|
133
|
+
const winner = best.current
|
|
134
|
+
const selected = winner === null ? parts : parts.slice(winner.startPart, winner.endPart)
|
|
135
|
+
return normalizeWhitespace(selected.join(''))
|
|
136
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const NAMED: Readonly<Record<string, string>> = {
|
|
2
|
+
amp: '&',
|
|
3
|
+
lt: '<',
|
|
4
|
+
gt: '>',
|
|
5
|
+
quot: '"',
|
|
6
|
+
apos: "'",
|
|
7
|
+
nbsp: ' ',
|
|
8
|
+
ensp: ' ',
|
|
9
|
+
emsp: ' ',
|
|
10
|
+
thinsp: ' ',
|
|
11
|
+
shy: '',
|
|
12
|
+
hellip: '…',
|
|
13
|
+
mdash: '—',
|
|
14
|
+
ndash: '–',
|
|
15
|
+
lsquo: '‘',
|
|
16
|
+
rsquo: '’',
|
|
17
|
+
ldquo: '“',
|
|
18
|
+
rdquo: '”',
|
|
19
|
+
laquo: '«',
|
|
20
|
+
raquo: '»',
|
|
21
|
+
bull: '•',
|
|
22
|
+
middot: '·',
|
|
23
|
+
copy: '©',
|
|
24
|
+
reg: '®',
|
|
25
|
+
trade: '™',
|
|
26
|
+
deg: '°',
|
|
27
|
+
euro: '€',
|
|
28
|
+
pound: '£',
|
|
29
|
+
yen: '¥',
|
|
30
|
+
cent: '¢',
|
|
31
|
+
sect: '§',
|
|
32
|
+
para: '¶',
|
|
33
|
+
dagger: '†',
|
|
34
|
+
permil: '‰',
|
|
35
|
+
prime: '′',
|
|
36
|
+
Prime: '″',
|
|
37
|
+
times: '×',
|
|
38
|
+
divide: '÷',
|
|
39
|
+
minus: '−',
|
|
40
|
+
plusmn: '±',
|
|
41
|
+
frac12: '½',
|
|
42
|
+
frac14: '¼',
|
|
43
|
+
frac34: '¾',
|
|
44
|
+
larr: '←',
|
|
45
|
+
rarr: '→',
|
|
46
|
+
harr: '↔',
|
|
47
|
+
darr: '↓',
|
|
48
|
+
uarr: '↑',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function codePointToString(codePoint: number): string {
|
|
52
|
+
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return ''
|
|
53
|
+
// Surrogate halves are not valid standalone scalar values.
|
|
54
|
+
if (codePoint >= 0xd800 && codePoint <= 0xdfff) return '�'
|
|
55
|
+
return String.fromCodePoint(codePoint)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decodes HTML character references in a single forward pass. Unterminated or
|
|
60
|
+
* unknown references are emitted verbatim, matching how browsers treat them.
|
|
61
|
+
*/
|
|
62
|
+
export function decodeEntities(input: string): string {
|
|
63
|
+
if (!input.includes('&')) return input
|
|
64
|
+
let output = ''
|
|
65
|
+
let position = 0
|
|
66
|
+
while (position < input.length) {
|
|
67
|
+
const start = input.indexOf('&', position)
|
|
68
|
+
if (start === -1) {
|
|
69
|
+
output += input.slice(position)
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
output += input.slice(position, start)
|
|
73
|
+
const end = input.indexOf(';', start + 1)
|
|
74
|
+
if (end === -1 || end - start > 32) {
|
|
75
|
+
output += '&'
|
|
76
|
+
position = start + 1
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const reference = input.slice(start + 1, end)
|
|
80
|
+
if (reference.startsWith('#')) {
|
|
81
|
+
const isHex = reference[1] === 'x' || reference[1] === 'X'
|
|
82
|
+
const digits = isHex ? reference.slice(2) : reference.slice(1)
|
|
83
|
+
const valid = isHex ? /^[0-9a-fA-F]+$/.test(digits) : /^[0-9]+$/.test(digits)
|
|
84
|
+
if (valid) {
|
|
85
|
+
output += codePointToString(Number.parseInt(digits, isHex ? 16 : 10))
|
|
86
|
+
position = end + 1
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
} else if (Object.hasOwn(NAMED, reference)) {
|
|
90
|
+
output += NAMED[reference]
|
|
91
|
+
position = end + 1
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
output += '&'
|
|
95
|
+
position = start + 1
|
|
96
|
+
}
|
|
97
|
+
return output
|
|
98
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { decodeEntities } from './entities'
|
|
2
|
+
export { classifyPage, type ClassifyInput, type PageClassification } from './classify'
|
|
3
|
+
export { extractMainContent } from './content'
|
|
4
|
+
export {
|
|
5
|
+
BLOCK_ELEMENTS,
|
|
6
|
+
NON_CONTENT_ELEMENTS,
|
|
7
|
+
extractTitle,
|
|
8
|
+
htmlToText,
|
|
9
|
+
normalizeWhitespace,
|
|
10
|
+
} from './text'
|
|
11
|
+
export { MAX_HTML_INPUT, tokenizeHtml, type HtmlToken } from './tokenizer'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { tokenizeHtml } from './tokenizer'
|
|
2
|
+
|
|
3
|
+
/** Elements whose text content is never part of the readable document. */
|
|
4
|
+
export const NON_CONTENT_ELEMENTS = new Set([
|
|
5
|
+
'script',
|
|
6
|
+
'style',
|
|
7
|
+
'noscript',
|
|
8
|
+
'template',
|
|
9
|
+
'svg',
|
|
10
|
+
'math',
|
|
11
|
+
'title',
|
|
12
|
+
'iframe',
|
|
13
|
+
'object',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
export const BLOCK_ELEMENTS = new Set([
|
|
17
|
+
'address',
|
|
18
|
+
'article',
|
|
19
|
+
'aside',
|
|
20
|
+
'blockquote',
|
|
21
|
+
'br',
|
|
22
|
+
'dd',
|
|
23
|
+
'div',
|
|
24
|
+
'dl',
|
|
25
|
+
'dt',
|
|
26
|
+
'figcaption',
|
|
27
|
+
'figure',
|
|
28
|
+
'footer',
|
|
29
|
+
'form',
|
|
30
|
+
'h1',
|
|
31
|
+
'h2',
|
|
32
|
+
'h3',
|
|
33
|
+
'h4',
|
|
34
|
+
'h5',
|
|
35
|
+
'h6',
|
|
36
|
+
'header',
|
|
37
|
+
'hr',
|
|
38
|
+
'li',
|
|
39
|
+
'main',
|
|
40
|
+
'nav',
|
|
41
|
+
'ol',
|
|
42
|
+
'p',
|
|
43
|
+
'pre',
|
|
44
|
+
'section',
|
|
45
|
+
'table',
|
|
46
|
+
'tbody',
|
|
47
|
+
'td',
|
|
48
|
+
'tfoot',
|
|
49
|
+
'th',
|
|
50
|
+
'thead',
|
|
51
|
+
'tr',
|
|
52
|
+
'ul',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
/** Non-breaking and typographic spaces that must fold into an ordinary space. */
|
|
56
|
+
const UNICODE_SPACES = new RegExp('[\\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000]', 'g')
|
|
57
|
+
|
|
58
|
+
export function normalizeWhitespace(text: string): string {
|
|
59
|
+
return text
|
|
60
|
+
.replace(UNICODE_SPACES, ' ')
|
|
61
|
+
.replace(/[ \t\f\r\v]+/g, ' ')
|
|
62
|
+
// The collapse above leaves at most one space on either side of a newline,
|
|
63
|
+
// so ` ?` is equivalent to ` *` here — and, unlike ` *`, does not backtrack
|
|
64
|
+
// quadratically over long runs of spaces (CodeQL polynomial ReDoS).
|
|
65
|
+
.replace(/ ?\n ?/g, '\n')
|
|
66
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
67
|
+
.trim()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function htmlToText(html: string): string {
|
|
71
|
+
const parts: string[] = []
|
|
72
|
+
let skipName: string | null = null
|
|
73
|
+
let skipDepth = 0
|
|
74
|
+
|
|
75
|
+
for (const token of tokenizeHtml(html)) {
|
|
76
|
+
if (token.kind === 'open') {
|
|
77
|
+
if (skipName !== null) {
|
|
78
|
+
if (token.name === skipName) skipDepth += 1
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (NON_CONTENT_ELEMENTS.has(token.name)) {
|
|
82
|
+
skipName = token.name
|
|
83
|
+
skipDepth = 1
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (BLOCK_ELEMENTS.has(token.name)) parts.push('\n')
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (token.kind === 'close') {
|
|
90
|
+
if (skipName !== null) {
|
|
91
|
+
if (token.name === skipName) {
|
|
92
|
+
skipDepth -= 1
|
|
93
|
+
if (skipDepth <= 0) skipName = null
|
|
94
|
+
}
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (BLOCK_ELEMENTS.has(token.name)) parts.push('\n')
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (skipName !== null) continue
|
|
101
|
+
parts.push(token.value)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return normalizeWhitespace(parts.join(''))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function extractTitle(html: string): string | null {
|
|
108
|
+
let inTitle = false
|
|
109
|
+
let title = ''
|
|
110
|
+
for (const token of tokenizeHtml(html)) {
|
|
111
|
+
if (token.kind === 'open') {
|
|
112
|
+
if (token.name === 'title') inTitle = true
|
|
113
|
+
else if (token.name === 'body') break
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
if (token.kind === 'close') {
|
|
117
|
+
if (token.name === 'title') break
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
if (inTitle) title += token.value
|
|
121
|
+
}
|
|
122
|
+
const normalized = title.replace(/\s+/g, ' ').trim()
|
|
123
|
+
return normalized.length > 0 ? normalized : null
|
|
124
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { decodeEntities } from './entities'
|
|
2
|
+
|
|
3
|
+
export type HtmlToken =
|
|
4
|
+
| { readonly kind: 'text'; readonly value: string }
|
|
5
|
+
| {
|
|
6
|
+
readonly kind: 'open'
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly attributes: ReadonlyMap<string, string>
|
|
9
|
+
readonly selfClosing: boolean
|
|
10
|
+
}
|
|
11
|
+
| { readonly kind: 'close'; readonly name: string }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bounds memory for a pathological document. The tokenizer itself is linear and
|
|
15
|
+
* cannot stall the event loop, so this is an allocation guard rather than the
|
|
16
|
+
* ReDoS mitigation a regex-based extractor would need it to be.
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_HTML_INPUT = 8 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
const VOID_ELEMENTS = new Set([
|
|
21
|
+
'area',
|
|
22
|
+
'base',
|
|
23
|
+
'br',
|
|
24
|
+
'col',
|
|
25
|
+
'embed',
|
|
26
|
+
'hr',
|
|
27
|
+
'img',
|
|
28
|
+
'input',
|
|
29
|
+
'link',
|
|
30
|
+
'meta',
|
|
31
|
+
'param',
|
|
32
|
+
'source',
|
|
33
|
+
'track',
|
|
34
|
+
'wbr',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const RAW_TEXT_ELEMENTS = new Set(['script', 'style'])
|
|
38
|
+
|
|
39
|
+
function isNameStart(character: string): boolean {
|
|
40
|
+
return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isWhitespace(character: string): boolean {
|
|
44
|
+
return character === ' ' || character === '\t' || character === '\n' || character === '\r' || character === '\f'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ParsedTag = {
|
|
48
|
+
readonly name: string
|
|
49
|
+
readonly attributes: Map<string, string>
|
|
50
|
+
readonly selfClosing: boolean
|
|
51
|
+
readonly end: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads one start tag beginning at the character after `<`. Quote-aware so an
|
|
56
|
+
* attribute value containing `>` does not truncate the tag.
|
|
57
|
+
*/
|
|
58
|
+
function readStartTag(html: string, start: number): ParsedTag | null {
|
|
59
|
+
let position = start
|
|
60
|
+
while (position < html.length && !isWhitespace(html[position]) && html[position] !== '>' && html[position] !== '/') {
|
|
61
|
+
position += 1
|
|
62
|
+
}
|
|
63
|
+
const name = html.slice(start, position).toLowerCase()
|
|
64
|
+
if (name.length === 0) return null
|
|
65
|
+
|
|
66
|
+
const attributes = new Map<string, string>()
|
|
67
|
+
let selfClosing = false
|
|
68
|
+
|
|
69
|
+
while (position < html.length) {
|
|
70
|
+
while (position < html.length && isWhitespace(html[position])) position += 1
|
|
71
|
+
if (position >= html.length) break
|
|
72
|
+
if (html[position] === '>') {
|
|
73
|
+
position += 1
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
if (html[position] === '/') {
|
|
77
|
+
selfClosing = true
|
|
78
|
+
position += 1
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nameStart = position
|
|
83
|
+
while (
|
|
84
|
+
position < html.length &&
|
|
85
|
+
!isWhitespace(html[position]) &&
|
|
86
|
+
html[position] !== '=' &&
|
|
87
|
+
html[position] !== '>' &&
|
|
88
|
+
html[position] !== '/'
|
|
89
|
+
) {
|
|
90
|
+
position += 1
|
|
91
|
+
}
|
|
92
|
+
const attributeName = html.slice(nameStart, position).toLowerCase()
|
|
93
|
+
if (attributeName.length === 0) {
|
|
94
|
+
position += 1
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
while (position < html.length && isWhitespace(html[position])) position += 1
|
|
99
|
+
if (html[position] !== '=') {
|
|
100
|
+
attributes.set(attributeName, '')
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
position += 1
|
|
104
|
+
while (position < html.length && isWhitespace(html[position])) position += 1
|
|
105
|
+
|
|
106
|
+
const quote = html[position]
|
|
107
|
+
if (quote === '"' || quote === "'") {
|
|
108
|
+
const valueStart = position + 1
|
|
109
|
+
const valueEnd = html.indexOf(quote, valueStart)
|
|
110
|
+
if (valueEnd === -1) {
|
|
111
|
+
attributes.set(attributeName, decodeEntities(html.slice(valueStart)))
|
|
112
|
+
position = html.length
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
attributes.set(attributeName, decodeEntities(html.slice(valueStart, valueEnd)))
|
|
116
|
+
position = valueEnd + 1
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const valueStart = position
|
|
121
|
+
while (position < html.length && !isWhitespace(html[position]) && html[position] !== '>') position += 1
|
|
122
|
+
attributes.set(attributeName, decodeEntities(html.slice(valueStart, position)))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { name, attributes, selfClosing: selfClosing || VOID_ELEMENTS.has(name), end: position }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Single forward scan over HTML emitting structural tokens. Every advance is
|
|
130
|
+
* driven by `indexOf` or a bounded character walk, so there is no backtracking
|
|
131
|
+
* and runtime is linear in the input length.
|
|
132
|
+
*/
|
|
133
|
+
export function* tokenizeHtml(rawHtml: string): Generator<HtmlToken> {
|
|
134
|
+
const html = rawHtml.length > MAX_HTML_INPUT ? rawHtml.slice(0, MAX_HTML_INPUT) : rawHtml
|
|
135
|
+
// Lowercased once: searching for a raw-text close tag inside the loop would
|
|
136
|
+
// otherwise copy the whole document per <script>/<style>.
|
|
137
|
+
let lowerHtml: string | null = null
|
|
138
|
+
let position = 0
|
|
139
|
+
|
|
140
|
+
while (position < html.length) {
|
|
141
|
+
const angle = html.indexOf('<', position)
|
|
142
|
+
if (angle === -1) {
|
|
143
|
+
const text = html.slice(position)
|
|
144
|
+
if (text.length > 0) yield { kind: 'text', value: decodeEntities(text) }
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (angle > position) {
|
|
148
|
+
yield { kind: 'text', value: decodeEntities(html.slice(position, angle)) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const next = html[angle + 1]
|
|
152
|
+
|
|
153
|
+
if (next === '!') {
|
|
154
|
+
if (html.startsWith('<!--', angle)) {
|
|
155
|
+
const close = html.indexOf('-->', angle + 4)
|
|
156
|
+
position = close === -1 ? html.length : close + 3
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
const close = html.indexOf('>', angle + 2)
|
|
160
|
+
position = close === -1 ? html.length : close + 1
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (next === '/') {
|
|
165
|
+
const close = html.indexOf('>', angle + 2)
|
|
166
|
+
const end = close === -1 ? html.length : close
|
|
167
|
+
const name = html.slice(angle + 2, end).trim().toLowerCase()
|
|
168
|
+
if (name.length > 0) yield { kind: 'close', name }
|
|
169
|
+
position = close === -1 ? html.length : close + 1
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (next === undefined || !isNameStart(next)) {
|
|
174
|
+
yield { kind: 'text', value: '<' }
|
|
175
|
+
position = angle + 1
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const tag = readStartTag(html, angle + 1)
|
|
180
|
+
if (tag === null) {
|
|
181
|
+
yield { kind: 'text', value: '<' }
|
|
182
|
+
position = angle + 1
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
yield { kind: 'open', name: tag.name, attributes: tag.attributes, selfClosing: tag.selfClosing }
|
|
186
|
+
position = tag.end
|
|
187
|
+
|
|
188
|
+
if (RAW_TEXT_ELEMENTS.has(tag.name) && !tag.selfClosing) {
|
|
189
|
+
lowerHtml ??= html.toLowerCase()
|
|
190
|
+
const close = lowerHtml.indexOf(`</${tag.name}`, position)
|
|
191
|
+
position = close === -1 ? html.length : close
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (tag.selfClosing) yield { kind: 'close', name: tag.name }
|
|
196
|
+
}
|
|
197
|
+
}
|