crawlforge-extractors 1.6.5 → 1.8.0
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/README.md +51 -0
- package/index.d.ts +92 -0
- package/index.js +4 -0
- package/package.json +2 -2
- package/src/blockedPage.js +168 -0
- package/src/highlights.js +342 -0
package/README.md
CHANGED
|
@@ -186,6 +186,57 @@ JSON-LD carries no per-variant stock count, compare-at price or option names,
|
|
|
186
186
|
so those fields are null. Pass the page URL after redirects: a `/collections/`
|
|
187
187
|
URL is reported in `reason` as a retired handle.
|
|
188
188
|
|
|
189
|
+
### Recognising a blocked page
|
|
190
|
+
|
|
191
|
+
`documentVerdict` says what a fetched document is: the page, a bot-wall
|
|
192
|
+
interstitial (Cloudflare, Amazon, DataDome, PerimeterX, Akamai, Vercel), an
|
|
193
|
+
HTTP error page, an empty shell, or a short error-titled placeholder. A wall
|
|
194
|
+
arrives as HTTP 200 with a title and prose of its own, so a fetch that only
|
|
195
|
+
checks the status reports it as a success — producthunt.com came back
|
|
196
|
+
`success: true, title: "Just a moment..."` for three regression rounds.
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
import { documentVerdict } from 'crawlforge-extractors';
|
|
200
|
+
|
|
201
|
+
const verdict = documentVerdict(
|
|
202
|
+
{ url: response.url, status: response.status, title, text, html },
|
|
203
|
+
{ fetcher: 'a plain fetch', rendered: false, contentReturned: false }
|
|
204
|
+
);
|
|
205
|
+
// { success: false, status: 200, blocked: { vendor: 'cloudflare', evidence: 'title "Just a moment..."' }, error: '…' }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`detectChallengePage` is the vendor check alone. Both are pure: the caller
|
|
209
|
+
keeps or drops the content, and decides what to try next.
|
|
210
|
+
|
|
211
|
+
### Finding the units that match a query
|
|
212
|
+
|
|
213
|
+
`segmentUnits` cuts the markdown a scrape returned into sentences, table rows
|
|
214
|
+
and fenced code blocks; `rankUnits` scores them against a query with BM25 and
|
|
215
|
+
returns the best few verbatim. It is how a caller gets the one table row that
|
|
216
|
+
answers "enterprise price per month" without paying to read the whole page,
|
|
217
|
+
and without a model paraphrasing it — nothing here can say something the page
|
|
218
|
+
does not.
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
import { segmentUnits, rankUnits } from 'crawlforge-extractors';
|
|
222
|
+
|
|
223
|
+
const units = segmentUnits(markdown);
|
|
224
|
+
const best = rankUnits(units, 'enterprise price per month', { maxUnits: 5 });
|
|
225
|
+
// [{ text: 'Enterprise is priced at $499 per month, billed annually, and includes a dedicated success engineer.',
|
|
226
|
+
// kind: 'sentence', offset: 815, length: 99, heading: 'Enterprise', score: 6.612 },
|
|
227
|
+
// { text: '| Price per month | $29 | $99 | $499 |',
|
|
228
|
+
// kind: 'table_row', offset: 257, length: 38, heading: 'Compare plans', score: 5.809 }, …]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Offsets are JS string indexes into the exact string passed in, and every unit
|
|
232
|
+
keeps `markdown.slice(offset, offset + length) === text`: trimming and list
|
|
233
|
+
markers move the offset, nothing rewrites the text. Headings are not units;
|
|
234
|
+
each unit carries the heading above it. The sentence splitter shares the MCP
|
|
235
|
+
server's terminator rules — `。!?` and the danda split on a zero-width
|
|
236
|
+
boundary, and an ASCII period does not split after `Dr.`, `e.g.`, `Node.js`
|
|
237
|
+
or `3.14`. A CJK query matches by character bigrams, so no segmenter is
|
|
238
|
+
needed.
|
|
239
|
+
|
|
189
240
|
## Templates
|
|
190
241
|
|
|
191
242
|
**Pages and products.** `shopify-product` · `shopify-collection` ·
|
package/index.d.ts
CHANGED
|
@@ -273,3 +273,95 @@ export declare function shopifyProductFromJsonLd(
|
|
|
273
273
|
): { found: true; data: ShopifyJsonLdProduct } | { found: false; reason: string };
|
|
274
274
|
|
|
275
275
|
export default TemplateRegistry;
|
|
276
|
+
|
|
277
|
+
/** The bot-defence vendor whose interstitial a document is, and what gave it away. */
|
|
278
|
+
export interface ChallengeVerdict {
|
|
279
|
+
vendor: 'cloudflare' | 'amazon' | 'datadome' | 'perimeterx' | 'akamai' | 'vercel';
|
|
280
|
+
evidence: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Recognise a bot-wall interstitial served as a page (HTTP 200, a title, some
|
|
285
|
+
* prose and a challenge script). A title match is definitive; a script or
|
|
286
|
+
* form marker is definitive only on a short page, because a real page can
|
|
287
|
+
* legitimately embed a Turnstile widget.
|
|
288
|
+
*/
|
|
289
|
+
export declare function detectChallengePage(page: {
|
|
290
|
+
title?: string;
|
|
291
|
+
html?: string;
|
|
292
|
+
text?: string;
|
|
293
|
+
}): ChallengeVerdict | null;
|
|
294
|
+
|
|
295
|
+
export interface DocumentVerdict {
|
|
296
|
+
success: boolean;
|
|
297
|
+
/** The navigation's HTTP status when the caller had one, else null. */
|
|
298
|
+
status: number | null;
|
|
299
|
+
/** Present on every failure: what the document is and what to do. */
|
|
300
|
+
error?: string;
|
|
301
|
+
/** Present when the failure is a challenge wall. */
|
|
302
|
+
blocked?: ChallengeVerdict;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* What a fetched document is: the page, a challenge wall, an HTTP error page,
|
|
307
|
+
* an empty shell, or a short error-titled placeholder. The defaults describe
|
|
308
|
+
* a stealth-browser caller: a browser `rendered` the document, `fetcher`
|
|
309
|
+
* names it in the messages, `waitedMs` is the extra render wait it gave an
|
|
310
|
+
* empty document, and the failure result still carries the content
|
|
311
|
+
* (`contentReturned`). A plain fetch passes `rendered: false` and
|
|
312
|
+
* `contentReturned: false`.
|
|
313
|
+
*/
|
|
314
|
+
export declare function documentVerdict(
|
|
315
|
+
scraped: { url?: string; title?: string; text?: string; html?: string; status?: number | null },
|
|
316
|
+
options?: {
|
|
317
|
+
waitedMs?: number;
|
|
318
|
+
allowEmpty?: boolean;
|
|
319
|
+
fetcher?: string;
|
|
320
|
+
rendered?: boolean;
|
|
321
|
+
contentReturned?: boolean;
|
|
322
|
+
}
|
|
323
|
+
): DocumentVerdict;
|
|
324
|
+
|
|
325
|
+
/** A document with this much text or less and an error title is a placeholder. */
|
|
326
|
+
export declare const SOFT_ERROR_MAX_CHARS: number;
|
|
327
|
+
|
|
328
|
+
/** One scoreable piece of a page's markdown: a sentence, a table row or a fenced code block. */
|
|
329
|
+
export interface HighlightUnit {
|
|
330
|
+
/** Verbatim from the markdown: `markdown.slice(offset, offset + length) === text`. */
|
|
331
|
+
text: string;
|
|
332
|
+
kind: 'sentence' | 'table_row' | 'code_block';
|
|
333
|
+
/** JS string index into the markdown segmentUnits was given — not a byte offset. */
|
|
334
|
+
offset: number;
|
|
335
|
+
length: number;
|
|
336
|
+
/** The nearest heading above the unit, without its `#` marks; null before the first heading. */
|
|
337
|
+
heading: string | null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface RankedHighlightUnit extends HighlightUnit {
|
|
341
|
+
/** BM25 with the phrase boost applied and the heading's terms counted at half weight, rounded to 3 decimals; always above `minScore`. */
|
|
342
|
+
score: number;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Cut the markdown a scrape returned into sentences, table rows and fenced
|
|
347
|
+
* code blocks, each with its offset into that same string. Headings are not
|
|
348
|
+
* units; they label the units that follow. List and quote markers are
|
|
349
|
+
* skipped by moving the offset, never by rewriting the text. The sentence
|
|
350
|
+
* splitter carries the MCP server's rules: 。!? and the danda split on a
|
|
351
|
+
* zero-width boundary, and an ASCII period does not split after an
|
|
352
|
+
* abbreviation, a word with internal periods, a decimal or an initial.
|
|
353
|
+
*/
|
|
354
|
+
export declare function segmentUnits(markdown: string): HighlightUnit[];
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The units that answer a query, best first: BM25 over the units as the
|
|
358
|
+
* corpus, each unit inheriting its heading's terms at half weight, ×1.5 when
|
|
359
|
+
* a unit contains the whole query. Units scoring at or below `minScore` (default 0: no
|
|
360
|
+
* term in common) are dropped; at most `maxUnits` (default 10, clamped to at
|
|
361
|
+
* least 1) come back, ties broken by offset. The input is not modified.
|
|
362
|
+
*/
|
|
363
|
+
export declare function rankUnits(
|
|
364
|
+
units: HighlightUnit[],
|
|
365
|
+
query: string,
|
|
366
|
+
options?: { maxUnits?: number; minScore?: number }
|
|
367
|
+
): RankedHighlightUnit[];
|
package/index.js
CHANGED
|
@@ -31,3 +31,7 @@ export { extractEmbeddedState } from './src/embeddedState.js';
|
|
|
31
31
|
export { parseJsonPath, selectJsonPath } from './src/jsonPath.js';
|
|
32
32
|
|
|
33
33
|
export { shopifyProductFromJsonLd } from './src/shopifyJsonLd.js';
|
|
34
|
+
|
|
35
|
+
export { detectChallengePage, documentVerdict, SOFT_ERROR_MAX_CHARS } from './src/blockedPage.js';
|
|
36
|
+
|
|
37
|
+
export { segmentUnits, rankUnits } from './src/highlights.js';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-extractors",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting,
|
|
3
|
+
"version": "1.8.0",
|
|
4
|
+
"description": "Extraction logic shared by the CrawlForge MCP server and REST API — scrape templates, charset-correct capped body reading, structural fingerprinting, embedded-state extraction, and query-scoped highlights. One implementation, so the two surfaces cannot drift apart.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blockedPage.js — decide whether a fetched document is the page or a wall.
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare, Amazon, DataDome, PerimeterX, Akamai and Vercel all answer a
|
|
5
|
+
* blocked request with HTTP 200 and a page of their own: a title, some prose
|
|
6
|
+
* and a challenge script. Reported as a successful scrape, that page hides
|
|
7
|
+
* the block — producthunt.com came back "success:true, title: Just a
|
|
8
|
+
* moment..." for three regression rounds (R10 Q1 → R15, 2026-09-04). The
|
|
9
|
+
* MCP server's stealth path learned to name these in 5.6.2 and to name HTTP
|
|
10
|
+
* error pages and short error-titled placeholders in 5.6.9; the plain
|
|
11
|
+
* `scrape` path on both surfaces never looked. The tables live here so the
|
|
12
|
+
* two surfaces reach one verdict.
|
|
13
|
+
*
|
|
14
|
+
* A title match is definitive; a script or form marker is definitive only
|
|
15
|
+
* on a short page, because a real page can legitimately embed a Turnstile
|
|
16
|
+
* widget.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SHORT_PAGE_CHARS = 4000;
|
|
20
|
+
|
|
21
|
+
const CHALLENGES = [
|
|
22
|
+
{
|
|
23
|
+
vendor: 'cloudflare',
|
|
24
|
+
title: /^just a moment/i,
|
|
25
|
+
markers: /challenges\.cloudflare\.com|cf-chl-|_cf_chl_opt|cf_chl_rc_|window\._cf_chl/i,
|
|
26
|
+
evidence: 'a Cloudflare challenge script'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
vendor: 'amazon',
|
|
30
|
+
// The robot check is the only Amazon page whose form posts to validateCaptcha.
|
|
31
|
+
definitive: /action="[^"]*validateCaptcha/i,
|
|
32
|
+
evidence: 'the validateCaptcha form'
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
vendor: 'datadome',
|
|
36
|
+
markers: /captcha-delivery\.com\/captcha|geo\.captcha-delivery\.com|dd\.captcha/i,
|
|
37
|
+
evidence: 'a DataDome captcha frame'
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
vendor: 'perimeterx',
|
|
41
|
+
markers: /px-captcha|_pxCaptcha|human-challenge/i,
|
|
42
|
+
evidence: 'a PerimeterX / HUMAN challenge element'
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
vendor: 'akamai',
|
|
46
|
+
title: /^access denied/i,
|
|
47
|
+
markers: /errors\.edgesuite\.net/i,
|
|
48
|
+
evidence: 'an Akamai access-denied page'
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
// Vercel's Attack Challenge Mode answers with HTTP 429, an
|
|
52
|
+
// x-vercel-mitigated: challenge header and a JavaScript interstitial
|
|
53
|
+
// titled "Vercel Security Checkpoint" (lesswrong.com, hashicorp.com,
|
|
54
|
+
// bombas.com, R17 2026-09-04). Chromium solves it and reloads; camoufox
|
|
55
|
+
// was left on the interstitial, which then read as a successful scrape.
|
|
56
|
+
vendor: 'vercel',
|
|
57
|
+
title: /^vercel security checkpoint/i,
|
|
58
|
+
markers: /vercel\.link\/security-checkpoint|_vercel\/challenge|x-vercel-challenge-token/i,
|
|
59
|
+
evidence: 'a Vercel Security Checkpoint page'
|
|
60
|
+
}
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {{ title?: string, html?: string, text?: string }} page
|
|
65
|
+
* @returns {{ vendor: string, evidence: string } | null}
|
|
66
|
+
*/
|
|
67
|
+
export function detectChallengePage({ title = '', html = '', text = '' } = {}) {
|
|
68
|
+
const cleanTitle = (title || '').trim();
|
|
69
|
+
const visible = (text || '').replace(/\s+/g, ' ').trim();
|
|
70
|
+
const shortPage = visible.length < SHORT_PAGE_CHARS;
|
|
71
|
+
for (const challenge of CHALLENGES) {
|
|
72
|
+
if (challenge.title && challenge.title.test(cleanTitle)) {
|
|
73
|
+
return { vendor: challenge.vendor, evidence: `title "${cleanTitle}"` };
|
|
74
|
+
}
|
|
75
|
+
if (challenge.definitive && challenge.definitive.test(html)) {
|
|
76
|
+
return { vendor: challenge.vendor, evidence: challenge.evidence };
|
|
77
|
+
}
|
|
78
|
+
if (shortPage && challenge.markers && challenge.markers.test(html)) {
|
|
79
|
+
return { vendor: challenge.vendor, evidence: `${challenge.evidence} on a ${visible.length}-character page` };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A document this short with one of these titles is an error placeholder,
|
|
86
|
+
// not a page. Real pages with these words in a longer title (a news story
|
|
87
|
+
// about an outage) carry far more text than the cap.
|
|
88
|
+
const ERROR_TITLE = /^(?:(?:\d{3}\s*[-–—|:]\s*)?(?:error(?: page)?|access denied|forbidden|(?:page )?not found|service unavailable|internal server error|bad gateway|something went wrong|oops!?[^\n]{0,60}))$/i;
|
|
89
|
+
export const SOFT_ERROR_MAX_CHARS = 1500;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a fetched document is: the page, a challenge wall, an HTTP error
|
|
93
|
+
* page, an empty shell, or a short error-titled placeholder. The content is
|
|
94
|
+
* for the caller to keep or drop — this only says what it is.
|
|
95
|
+
*
|
|
96
|
+
* The defaults describe the MCP server's stealth path, the original caller:
|
|
97
|
+
* a browser `rendered` the document, `fetcher` names it in the messages,
|
|
98
|
+
* `waitedMs` is the extra render wait it gave an empty document, and the
|
|
99
|
+
* failure result still carries the content (`contentReturned`). A plain
|
|
100
|
+
* fetch passes `rendered: false` (its empty shell or placeholder cannot be
|
|
101
|
+
* waited out — only a browser paints it) and `contentReturned: false` (it
|
|
102
|
+
* drops the document on a failure).
|
|
103
|
+
*
|
|
104
|
+
* @param {{ url?: string, title?: string, text?: string, html?: string, status?: number|null }} scraped
|
|
105
|
+
* @param {{ waitedMs?: number, allowEmpty?: boolean, fetcher?: string, rendered?: boolean, contentReturned?: boolean }} [options]
|
|
106
|
+
* @returns {{ success: boolean, status: number|null, error?: string, blocked?: { vendor: string, evidence: string } }}
|
|
107
|
+
*/
|
|
108
|
+
export function documentVerdict(scraped, { waitedMs = 0, allowEmpty = false, fetcher = 'the stealth browser', rendered = true, contentReturned = true } = {}) {
|
|
109
|
+
const status = Number.isInteger(scraped?.status) ? scraped.status : null;
|
|
110
|
+
const url = scraped?.url || '';
|
|
111
|
+
const title = String(scraped?.title || '').trim();
|
|
112
|
+
const text = String(scraped?.text || '').trim();
|
|
113
|
+
|
|
114
|
+
const challenge = detectChallengePage(scraped || {});
|
|
115
|
+
if (challenge) {
|
|
116
|
+
return {
|
|
117
|
+
success: false,
|
|
118
|
+
status,
|
|
119
|
+
blocked: challenge,
|
|
120
|
+
error: `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); ${fetcher} did not pass it.`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (status !== null && status >= 400) {
|
|
125
|
+
const why = status === 403
|
|
126
|
+
? 'A 403 with no challenge vendor on the page is an IP-reputation or WAF block; the site will not serve this network.'
|
|
127
|
+
: status === 404
|
|
128
|
+
? 'The site says the URL does not exist — check the path.'
|
|
129
|
+
: status === 429
|
|
130
|
+
? 'The site is rate-limiting this network; wait before retrying.'
|
|
131
|
+
: 'Retry later; the server, not the page, failed.';
|
|
132
|
+
return {
|
|
133
|
+
success: false,
|
|
134
|
+
status,
|
|
135
|
+
error: `HTTP ${status}: ${url} answered with an error page${title ? ` titled "${title}"` : ''}, not the resource${contentReturned ? '; the content returned is that page' : ''}. ${why}`
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!title && !text) {
|
|
140
|
+
if (allowEmpty) return { success: true, status };
|
|
141
|
+
const reached = fetcher.charAt(0).toUpperCase() + fetcher.slice(1);
|
|
142
|
+
const bytes = (scraped?.html || '').length;
|
|
143
|
+
return {
|
|
144
|
+
success: false,
|
|
145
|
+
status,
|
|
146
|
+
error: rendered
|
|
147
|
+
? `${reached} reached ${url} but the document rendered no title and no text` +
|
|
148
|
+
` after ${waitedMs}ms of extra wait (${bytes} bytes of HTML).` +
|
|
149
|
+
' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.'
|
|
150
|
+
: `${reached} reached ${url} but the document has no title and no text (${bytes} bytes of HTML).` +
|
|
151
|
+
' The page is rendered by JavaScript or the server sent an empty shell; only a browser renders it.'
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (text.length < SOFT_ERROR_MAX_CHARS && ERROR_TITLE.test(title)) {
|
|
156
|
+
return {
|
|
157
|
+
success: false,
|
|
158
|
+
status,
|
|
159
|
+
error:
|
|
160
|
+
`${url} rendered an error page titled "${title}" (${text.length} characters of text) instead of the resource` +
|
|
161
|
+
(rendered
|
|
162
|
+
? ' — a soft block or an application error. Retry later, or with a longer wait_for if the site paints content after a placeholder.'
|
|
163
|
+
: ' — a soft block or an application error. Retry later; if the site paints content after a placeholder, only a browser renders it.')
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { success: true, status };
|
|
168
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* highlights.js — the units of a scraped page that answer a query, verbatim.
|
|
3
|
+
*
|
|
4
|
+
* A caller asking "what does the enterprise plan cost" does not want the
|
|
5
|
+
* whole markdown of a pricing page in its context window, and it does not
|
|
6
|
+
* want a model's paraphrase of it either — the first is expensive, the
|
|
7
|
+
* second can be wrong. This module is the extractive middle: cut the
|
|
8
|
+
* markdown a scrape already returned into units (sentences, table rows,
|
|
9
|
+
* fenced code blocks), score each against the query with BM25, and hand
|
|
10
|
+
* back the top few exactly as they appear on the page, with character
|
|
11
|
+
* offsets into that same string so the caller can quote with a locator.
|
|
12
|
+
* Both surfaces run it, so a highlight is the same highlight over MCP and
|
|
13
|
+
* REST.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is a pure function over the markdown string. Offsets are
|
|
16
|
+
* JS string indexes, and the invariant every unit keeps is
|
|
17
|
+
* `markdown.slice(offset, offset + length) === text`. Trimming moves the
|
|
18
|
+
* offsets; nothing rewrites the text.
|
|
19
|
+
*
|
|
20
|
+
* The sentence splitter ports the MCP server's sentenceUtils.js rules: the
|
|
21
|
+
* CJK / fullwidth / Devanagari terminators split on a zero-width boundary
|
|
22
|
+
* and are never judged by the ASCII checks; an ASCII `.` followed by
|
|
23
|
+
* whitespace does not split after an abbreviation (Dr., etc.), a word with
|
|
24
|
+
* internal periods (Node.js, e.g.), a decimal (3.14) or a single-letter
|
|
25
|
+
* initial. The one departure from the server: those four checks only guard
|
|
26
|
+
* `.`, because "What is Node.js? It is a runtime." is two sentences.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const CJK_TERMINATORS = '。.!?;।॥';
|
|
30
|
+
const ASCII_TERMINATORS = '.!?';
|
|
31
|
+
// Closing punctuation a terminator may carry with it: "…end."), 'so.', and
|
|
32
|
+
// the emphasis markers of a bold FAQ question — "**Can I cancel?** Yes."
|
|
33
|
+
const CLOSERS = '"\')\\]”’»*_';
|
|
34
|
+
|
|
35
|
+
const ABBREVIATIONS = new Set([
|
|
36
|
+
'mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'st', 'ave', 'blvd',
|
|
37
|
+
'vs', 'etc', 'inc', 'ltd', 'corp', 'dept', 'univ', 'assn',
|
|
38
|
+
'approx', 'appt', 'apt', 'est', 'min', 'max',
|
|
39
|
+
'govt', 'lib', 'misc', 'natl', 'intl',
|
|
40
|
+
'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
|
|
41
|
+
'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun',
|
|
42
|
+
'fig', 'eq', 'ref', 'vol', 'no', 'pp', 'ed', 'rev',
|
|
43
|
+
'e', 'i' // e.g. and i.e.
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const MIN_UNIT_CHARS = 2;
|
|
47
|
+
|
|
48
|
+
const FENCE = /^(`{3,}|~{3,})/;
|
|
49
|
+
const FENCE_CLOSER = /^(?:`+|~+)$/;
|
|
50
|
+
const HEADING = /^#{1,6}(?:\s+(.*?))?\s*#*\s*$/;
|
|
51
|
+
const TABLE_DELIMITER = /^\|?[\s:|-]*-[\s:|-]*\|?$/;
|
|
52
|
+
const HORIZONTAL_RULE = /^(?:-\s*){3,}$|^(?:\*\s*){3,}$|^(?:_\s*){3,}$/;
|
|
53
|
+
const HTML_COMMENT_LINE = /^<!--[\s\S]*-->$/;
|
|
54
|
+
const IMAGE_ONLY_LINE = /^!\[[^\]]*\]\([^)]*\)$/;
|
|
55
|
+
const BLOCKQUOTE_MARKER = /^>\s?/;
|
|
56
|
+
const LIST_MARKER = /^(?:[-*+]|\d{1,9}[.)])\s+/;
|
|
57
|
+
|
|
58
|
+
function isSpace(ch) {
|
|
59
|
+
return /\s/.test(ch);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Move [start, end) inward past whitespace at both ends. */
|
|
63
|
+
function trimRange(markdown, start, end) {
|
|
64
|
+
while (start < end && isSpace(markdown[start])) start++;
|
|
65
|
+
while (end > start && isSpace(markdown[end - 1])) end--;
|
|
66
|
+
return [start, end];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function hasEnoughText(text) {
|
|
70
|
+
let count = 0;
|
|
71
|
+
for (const ch of text) {
|
|
72
|
+
if (!isSpace(ch) && ++count >= MIN_UNIT_CHARS) return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cut the markdown a scrape returned into the units a query can be scored
|
|
79
|
+
* against. Headings are not units — they label the units that follow.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} markdown
|
|
82
|
+
* @returns {Array<{ text: string, kind: 'sentence' | 'table_row' | 'code_block', offset: number, length: number, heading: string | null }>}
|
|
83
|
+
*/
|
|
84
|
+
export function segmentUnits(markdown) {
|
|
85
|
+
if (typeof markdown !== 'string' || markdown.length === 0) return [];
|
|
86
|
+
|
|
87
|
+
const units = [];
|
|
88
|
+
let heading = null;
|
|
89
|
+
|
|
90
|
+
function push(kind, start, end) {
|
|
91
|
+
[start, end] = trimRange(markdown, start, end);
|
|
92
|
+
const text = markdown.slice(start, end);
|
|
93
|
+
if (!hasEnoughText(text)) return;
|
|
94
|
+
units.push({ text, kind, offset: start, length: end - start, heading });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A paragraph is a contiguous range: consecutive prose lines, the first
|
|
98
|
+
// one's list or quote marker already skipped. Line breaks inside it are
|
|
99
|
+
// whitespace, so a sentence may carry a "\n" verbatim.
|
|
100
|
+
let paragraphStart = -1;
|
|
101
|
+
let paragraphEnd = -1;
|
|
102
|
+
function flushParagraph() {
|
|
103
|
+
if (paragraphStart >= 0) splitSentences(markdown, paragraphStart, paragraphEnd, push);
|
|
104
|
+
paragraphStart = -1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let fence = null; // { marker, contentStart }
|
|
108
|
+
|
|
109
|
+
const length = markdown.length;
|
|
110
|
+
let lineStart = 0;
|
|
111
|
+
while (lineStart <= length) {
|
|
112
|
+
let lineEnd = markdown.indexOf('\n', lineStart);
|
|
113
|
+
if (lineEnd === -1) lineEnd = length;
|
|
114
|
+
const [s, e] = trimRange(markdown, lineStart, lineEnd);
|
|
115
|
+
const line = markdown.slice(s, e);
|
|
116
|
+
let match;
|
|
117
|
+
|
|
118
|
+
if (fence) {
|
|
119
|
+
if (line[0] === fence.marker[0] && line.length >= fence.marker.length && FENCE_CLOSER.test(line)) {
|
|
120
|
+
push('code_block', fence.contentStart, Math.max(fence.contentStart, lineStart));
|
|
121
|
+
fence = null;
|
|
122
|
+
}
|
|
123
|
+
} else if (line === '') {
|
|
124
|
+
flushParagraph();
|
|
125
|
+
} else if ((match = FENCE.exec(line))) {
|
|
126
|
+
flushParagraph();
|
|
127
|
+
fence = { marker: match[1], contentStart: Math.min(lineEnd + 1, length) };
|
|
128
|
+
} else if ((match = HEADING.exec(line))) {
|
|
129
|
+
flushParagraph();
|
|
130
|
+
heading = (match[1] || '').trim() || null;
|
|
131
|
+
} else if (line[0] === '|') {
|
|
132
|
+
flushParagraph();
|
|
133
|
+
if (!TABLE_DELIMITER.test(line)) push('table_row', s, e);
|
|
134
|
+
} else if (HORIZONTAL_RULE.test(line) || HTML_COMMENT_LINE.test(line) || IMAGE_ONLY_LINE.test(line)) {
|
|
135
|
+
flushParagraph();
|
|
136
|
+
} else {
|
|
137
|
+
let start = s;
|
|
138
|
+
let marked = false;
|
|
139
|
+
while ((match = BLOCKQUOTE_MARKER.exec(markdown.slice(start, e)))) {
|
|
140
|
+
start += match[0].length;
|
|
141
|
+
marked = true;
|
|
142
|
+
}
|
|
143
|
+
if ((match = LIST_MARKER.exec(markdown.slice(start, e)))) {
|
|
144
|
+
start += match[0].length;
|
|
145
|
+
marked = true;
|
|
146
|
+
}
|
|
147
|
+
// A marker starts its own paragraph: two list items are two units,
|
|
148
|
+
// and "- " never lands inside a sentence's text.
|
|
149
|
+
if (marked) flushParagraph();
|
|
150
|
+
if (paragraphStart < 0) paragraphStart = start;
|
|
151
|
+
paragraphEnd = e;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lineStart = lineEnd + 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A fence nobody closed: the rest of the document is that block.
|
|
158
|
+
if (fence) push('code_block', fence.contentStart, length);
|
|
159
|
+
flushParagraph();
|
|
160
|
+
|
|
161
|
+
return units;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Split the paragraph at [start, end) into sentences, calling `emit` with
|
|
166
|
+
* each one's range. Whitespace between sentences belongs to neither.
|
|
167
|
+
*/
|
|
168
|
+
function splitSentences(markdown, start, end, emit) {
|
|
169
|
+
let sentenceStart = start;
|
|
170
|
+
let i = start;
|
|
171
|
+
while (i < end) {
|
|
172
|
+
const ch = markdown[i];
|
|
173
|
+
if (CJK_TERMINATORS.includes(ch)) {
|
|
174
|
+
// Unambiguous, and CJK text puts no whitespace after them.
|
|
175
|
+
emit('sentence', sentenceStart, i + 1);
|
|
176
|
+
sentenceStart = i + 1;
|
|
177
|
+
i++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (!ASCII_TERMINATORS.includes(ch)) {
|
|
181
|
+
i++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Consume the run: "?!", "...", then any closing quote or bracket.
|
|
185
|
+
let j = i;
|
|
186
|
+
while (j < end && ASCII_TERMINATORS.includes(markdown[j])) j++;
|
|
187
|
+
while (j < end && CLOSERS.includes(markdown[j])) j++;
|
|
188
|
+
if (j < end && !isSpace(markdown[j])) {
|
|
189
|
+
// "Node.js", "3.14", "e.g." — a period glued to the next word.
|
|
190
|
+
i = j;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (ch === '.' && isFalseStop(markdown, sentenceStart, i)) {
|
|
194
|
+
i = j;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
emit('sentence', sentenceStart, j);
|
|
198
|
+
sentenceStart = j;
|
|
199
|
+
i = j;
|
|
200
|
+
}
|
|
201
|
+
if (sentenceStart < end) emit('sentence', sentenceStart, end);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Whether the period at `dot` ends an abbreviation, a word with internal
|
|
206
|
+
* periods, a decimal or a single-letter initial — the server's four checks,
|
|
207
|
+
* applied to the whitespace-delimited word before the period.
|
|
208
|
+
*/
|
|
209
|
+
function isFalseStop(markdown, from, dot) {
|
|
210
|
+
let wordStart = dot;
|
|
211
|
+
while (wordStart > from && !isSpace(markdown[wordStart - 1])) wordStart--;
|
|
212
|
+
const word = markdown.slice(wordStart, dot);
|
|
213
|
+
if (word === '') return false;
|
|
214
|
+
if (ABBREVIATIONS.has(word.toLowerCase().replace(/[^a-z]/g, ''))) return true;
|
|
215
|
+
if (/\w\.\w/.test(word)) return true;
|
|
216
|
+
if (/\d\.\d/.test(word)) return true;
|
|
217
|
+
return /^[A-Z]$/.test(word);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Letters and digits of the scripts that put no spaces between words (Han,
|
|
221
|
+
// Hiragana, Katakana, Hangul) form one kind of run; every other letter,
|
|
222
|
+
// digit or combining mark forms the other — marks, because a Devanagari
|
|
223
|
+
// vowel sign is a mark, and without them "कीमत" is two fragments.
|
|
224
|
+
// Script_Extensions keeps "ー" and "々" with their runs; the letter/number
|
|
225
|
+
// class keeps "、" and "。" out of them.
|
|
226
|
+
const CJK_CHAR = '[\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}]';
|
|
227
|
+
const TOKEN = new RegExp(`(?:(?=${CJK_CHAR})[\\p{L}\\p{N}])+|(?:(?!${CJK_CHAR})[\\p{L}\\p{N}\\p{M}])+`, 'gu');
|
|
228
|
+
const CJK_START = new RegExp(`^${CJK_CHAR}`, 'u');
|
|
229
|
+
const SUFFIXES = ['ing', 'ed', 'es', 's'];
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Lowercase word tokens. A CJK run becomes character bigrams so a query in
|
|
233
|
+
* one of those scripts can match without a segmenter; a light suffix strip
|
|
234
|
+
* puts "pricing", "prices" and "price" on one stem ("pric").
|
|
235
|
+
* @param {string} text
|
|
236
|
+
* @returns {string[]}
|
|
237
|
+
*/
|
|
238
|
+
function tokenize(text) {
|
|
239
|
+
const tokens = [];
|
|
240
|
+
for (const [run] of text.toLowerCase().matchAll(TOKEN)) {
|
|
241
|
+
if (CJK_START.test(run)) {
|
|
242
|
+
const chars = Array.from(run);
|
|
243
|
+
if (chars.length === 1) tokens.push(run);
|
|
244
|
+
for (let i = 0; i + 1 < chars.length; i++) tokens.push(chars[i] + chars[i + 1]);
|
|
245
|
+
} else {
|
|
246
|
+
tokens.push(stem(run));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return tokens;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function stem(token) {
|
|
253
|
+
if (token.length <= 4) return token;
|
|
254
|
+
for (const suffix of SUFFIXES) {
|
|
255
|
+
if (token.endsWith(suffix)) {
|
|
256
|
+
token = token.slice(0, -suffix.length);
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return token.length > 4 && token.endsWith('e') ? token.slice(0, -1) : token;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const K1 = 1.2;
|
|
264
|
+
const B = 0.75;
|
|
265
|
+
const PHRASE_BOOST = 1.5;
|
|
266
|
+
// A unit inherits the terms of the heading it sits under, at half the weight
|
|
267
|
+
// of a term in its own text: on a card-style pricing page the plan name is
|
|
268
|
+
// the heading and "$83/month" is the unit, and without the heading the price
|
|
269
|
+
// line shares nothing with "professional plan price per month". Heading
|
|
270
|
+
// terms count toward a unit's term frequency only — not toward document
|
|
271
|
+
// frequency, or the plan name would look common and lose its weight.
|
|
272
|
+
const HEADING_WEIGHT = 0.5;
|
|
273
|
+
// BM25's length normalisation rewards short documents, and a page's shortest
|
|
274
|
+
// units are its buttons: "Choose Professional" outranked every price line on
|
|
275
|
+
// a live pricing page. A unit is scored as if it had at least this many
|
|
276
|
+
// tokens, so a two-word call to action carries no length advantage.
|
|
277
|
+
const MIN_DOC_LENGTH = 4;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The units that answer a query, best first: BM25 over the units as the
|
|
281
|
+
* corpus, with each unit inheriting its heading's terms at half weight, and a
|
|
282
|
+
* phrase boost when a unit contains the whole query. The input units are not
|
|
283
|
+
* touched; every returned unit is a new object.
|
|
284
|
+
*
|
|
285
|
+
* @template {{ text: string, heading?: string | null, offset: number }} U
|
|
286
|
+
* @param {U[]} units
|
|
287
|
+
* @param {string} query
|
|
288
|
+
* @param {{ maxUnits?: number, minScore?: number }} [options]
|
|
289
|
+
* @returns {Array<U & { score: number }>}
|
|
290
|
+
*/
|
|
291
|
+
export function rankUnits(units, query, { maxUnits = 10, minScore = 0 } = {}) {
|
|
292
|
+
if (!Array.isArray(units) || units.length === 0 || typeof query !== 'string') return [];
|
|
293
|
+
const phrase = query.trim().toLowerCase();
|
|
294
|
+
if (phrase === '') return [];
|
|
295
|
+
const terms = [...new Set(tokenize(phrase))];
|
|
296
|
+
if (terms.length === 0) return [];
|
|
297
|
+
|
|
298
|
+
const limit = Number.isFinite(maxUnits) ? Math.max(1, Math.floor(maxUnits)) : 10;
|
|
299
|
+
const floor = Number.isFinite(minScore) ? minScore : 0;
|
|
300
|
+
|
|
301
|
+
const docs = units.map((unit) => {
|
|
302
|
+
const counts = new Map();
|
|
303
|
+
const tokens = tokenize(String(unit.text ?? ''));
|
|
304
|
+
for (const token of tokens) counts.set(token, (counts.get(token) || 0) + 1);
|
|
305
|
+
const own = new Set(counts.keys());
|
|
306
|
+
let length = tokens.length;
|
|
307
|
+
if (unit.heading) {
|
|
308
|
+
for (const token of new Set(tokenize(String(unit.heading)))) {
|
|
309
|
+
if (own.has(token)) continue;
|
|
310
|
+
counts.set(token, HEADING_WEIGHT);
|
|
311
|
+
length += HEADING_WEIGHT;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { counts, own, length: Math.max(length, MIN_DOC_LENGTH) };
|
|
315
|
+
});
|
|
316
|
+
const n = docs.length;
|
|
317
|
+
const avgdl = docs.reduce((sum, doc) => sum + doc.length, 0) / n || 1;
|
|
318
|
+
const idf = new Map(terms.map((term) => {
|
|
319
|
+
const df = docs.reduce((count, doc) => count + (doc.own.has(term) ? 1 : 0), 0);
|
|
320
|
+
return [term, Math.log(1 + (n - df + 0.5) / (df + 0.5))];
|
|
321
|
+
}));
|
|
322
|
+
|
|
323
|
+
const ranked = [];
|
|
324
|
+
units.forEach((unit, index) => {
|
|
325
|
+
const doc = docs[index];
|
|
326
|
+
let score = 0;
|
|
327
|
+
for (const term of terms) {
|
|
328
|
+
const tf = doc.counts.get(term);
|
|
329
|
+
if (!tf) continue;
|
|
330
|
+
score += idf.get(term) * (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * doc.length / avgdl));
|
|
331
|
+
}
|
|
332
|
+
if (score === 0) return;
|
|
333
|
+
if (String(unit.text).toLowerCase().includes(phrase)) score *= PHRASE_BOOST;
|
|
334
|
+
// Round before the threshold, so a score handed back as minScore means
|
|
335
|
+
// what the caller saw.
|
|
336
|
+
score = Math.round(score * 1000) / 1000;
|
|
337
|
+
if (score > floor) ranked.push({ ...unit, score });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
ranked.sort((a, b) => b.score - a.score || a.offset - b.offset);
|
|
341
|
+
return ranked.slice(0, limit);
|
|
342
|
+
}
|