crawlforge-mcp-server 6.22.2 → 6.23.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 +10 -1
- package/package.json +1 -1
- package/server.js +14 -7
- package/src/core/ActionExecutor.js +117 -12
- package/src/core/AgentOrchestrator.js +133 -23
- package/src/core/AuthManager.js +7 -1
- package/src/core/ChangeTracker.js +52 -4
- package/src/core/LLMsTxtAnalyzer.js +21 -9
- package/src/core/LocalizationManager.js +20 -4
- package/src/core/ResearchOrchestrator.js +29 -3
- package/src/core/StealthBrowserManager.js +10 -5
- package/src/core/analysis/ContentAnalyzer.js +157 -29
- package/src/core/browser/guardedProxy.js +286 -0
- package/src/core/browser/snapshot.js +22 -0
- package/src/core/crawlers/BFSCrawler.js +10 -4
- package/src/core/processing/BrowserProcessor.js +5 -3
- package/src/core/processing/ContentProcessor.js +2 -1
- package/src/core/research/claimFilters.js +20 -1
- package/src/core/workers/worker.js +2 -0
- package/src/schemas/toolOutputSchemas.js +3 -2
- package/src/server/fallbackHints.js +5 -1
- package/src/server/inlineThreshold.js +19 -11
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
- package/src/tools/agent/sourceChecks.js +103 -0
- package/src/tools/basic/extractMetadata.js +9 -4
- package/src/tools/basic/extractText.js +6 -4
- package/src/tools/basic/fetchUrl.js +3 -0
- package/src/tools/basic/scrapeStructured.js +66 -21
- package/src/tools/crawl/mapSite.js +18 -3
- package/src/tools/extract/extractStructured.js +134 -1
- package/src/tools/extract/processDocument.js +48 -5
- package/src/tools/extract/summarizeContent.js +123 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
- package/src/tools/result/readResult.js +79 -10
- package/src/tools/scrape/_mainContent.js +97 -6
- package/src/tools/search/adapters/dataforseoSearch.js +4 -1
- package/src/tools/search/searchWeb.js +46 -15
- package/src/tools/search/serpRank.js +1 -1
- package/src/tools/tracking/trackChanges/index.js +1 -0
- package/src/tools/tracking/trackChanges/schema.js +2 -1
- package/src/utils/htmlToMarkdown.js +77 -1
- package/src/utils/localFilePolicy.js +65 -0
- package/src/utils/provenance.js +102 -1
- package/src/utils/ssrfGuard.js +4 -3
- package/src/utils/ssrfProtection.js +3 -0
|
@@ -28,6 +28,7 @@ const ChangeTrackingSchema = z.object({
|
|
|
28
28
|
trackLinks: z.boolean().default(true),
|
|
29
29
|
ignoreWhitespace: z.boolean().default(true),
|
|
30
30
|
ignoreCase: z.boolean().default(false),
|
|
31
|
+
includeMarkupChanges: z.boolean().default(false),
|
|
31
32
|
customSelectors: z.array(z.string()).optional(),
|
|
32
33
|
excludeSelectors: z.array(z.string()).optional().default([
|
|
33
34
|
'script', 'style', 'noscript', '.advertisement', '.ad'
|
|
@@ -344,10 +345,12 @@ export class ChangeTracker extends EventEmitter {
|
|
|
344
345
|
const currentAnalysis = await this.analyzeContent(currentContent, baseline.options);
|
|
345
346
|
|
|
346
347
|
// Perform comprehensive change detection
|
|
348
|
+
// includeMarkupChanges only shapes the response, so unlike the analysis
|
|
349
|
+
// options it may be asked for at compare time.
|
|
347
350
|
const changeAnalysis = await this.detectChanges(
|
|
348
351
|
baseline.analysis,
|
|
349
352
|
currentAnalysis,
|
|
350
|
-
baseline.options
|
|
353
|
+
{ ...baseline.options, includeMarkupChanges: options.includeMarkupChanges ?? baseline.options.includeMarkupChanges }
|
|
351
354
|
);
|
|
352
355
|
|
|
353
356
|
// Calculate change significance
|
|
@@ -410,7 +413,16 @@ export class ChangeTracker extends EventEmitter {
|
|
|
410
413
|
metrics: changeRecord.metrics,
|
|
411
414
|
recommendations: this.generateChangeRecommendations(changeRecord),
|
|
412
415
|
...(alerts.length ? { alerts } : {}),
|
|
416
|
+
// ignoredOptions is the structured form of the warning: each entry
|
|
417
|
+
// names what the caller asked for and what the baseline applied, so
|
|
418
|
+
// a compare at granularity "text" against a "section" baseline says
|
|
419
|
+
// so in a field, not only in prose (6.12).
|
|
413
420
|
...(ignoredOptions.length ? {
|
|
421
|
+
ignoredOptions: ignoredOptions.map(option => ({
|
|
422
|
+
option,
|
|
423
|
+
requested: options[option],
|
|
424
|
+
applied: baseline.options[option] ?? null
|
|
425
|
+
})),
|
|
414
426
|
warnings: [
|
|
415
427
|
`${ignoredOptions.join(', ')} passed to this compare ${ignoredOptions.length === 1 ? 'was' : 'were'} ignored — ` +
|
|
416
428
|
`the baseline's options are applied to both sides of the diff. Recreate the baseline to change them.`
|
|
@@ -615,13 +627,23 @@ export class ChangeTracker extends EventEmitter {
|
|
|
615
627
|
changes.modifiedElements.push(...elementChanges.modified);
|
|
616
628
|
}
|
|
617
629
|
|
|
618
|
-
// Detect text changes
|
|
630
|
+
// Detect text changes. Diffed over the visible text, not the raw HTML,
|
|
631
|
+
// so a class rename or icon markup no longer reads as a text change
|
|
632
|
+
// (live sweep 4.1). The raw markup diff is kept apart in markupChanges
|
|
633
|
+
// and only computed when the caller asks for it.
|
|
619
634
|
if (options.trackText) {
|
|
620
635
|
changes.textChanges = await this.detectTextChanges(
|
|
621
|
-
baseline.originalContent,
|
|
622
|
-
current.originalContent,
|
|
636
|
+
this.visibleText(baseline.originalContent),
|
|
637
|
+
this.visibleText(current.originalContent),
|
|
623
638
|
options
|
|
624
639
|
);
|
|
640
|
+
if (options.includeMarkupChanges) {
|
|
641
|
+
changes.markupChanges = await this.detectTextChanges(
|
|
642
|
+
baseline.originalContent,
|
|
643
|
+
current.originalContent,
|
|
644
|
+
options
|
|
645
|
+
);
|
|
646
|
+
}
|
|
625
647
|
}
|
|
626
648
|
|
|
627
649
|
// Detect monetary value changes. Scored by magnitude rather than by how
|
|
@@ -1038,6 +1060,32 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1038
1060
|
return changes;
|
|
1039
1061
|
}
|
|
1040
1062
|
|
|
1063
|
+
/**
|
|
1064
|
+
* The text a reader sees: text nodes in document order, one per line, with
|
|
1065
|
+
* script, style, noscript and template content dropped. Input with no
|
|
1066
|
+
* markup (a plain-text content param) is returned unchanged.
|
|
1067
|
+
* @param {string} content - HTML or plain text
|
|
1068
|
+
* @returns {string} - Visible text
|
|
1069
|
+
*/
|
|
1070
|
+
visibleText(content = '') {
|
|
1071
|
+
if (!/<[a-z!/]/i.test(content)) return content;
|
|
1072
|
+
const $ = load(content);
|
|
1073
|
+
$('script, style, noscript, template').remove();
|
|
1074
|
+
const parts = [];
|
|
1075
|
+
const walk = nodes => {
|
|
1076
|
+
for (const node of nodes) {
|
|
1077
|
+
if (node.type === 'text') {
|
|
1078
|
+
const text = node.data.trim();
|
|
1079
|
+
if (text) parts.push(text);
|
|
1080
|
+
} else if (node.children) {
|
|
1081
|
+
walk(node.children);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
walk($.root().contents().toArray());
|
|
1086
|
+
return parts.join('\n');
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1041
1089
|
async detectTextChanges(baselineContent, currentContent, options = {}) {
|
|
1042
1090
|
const textChanges = [];
|
|
1043
1091
|
|
|
@@ -240,8 +240,10 @@ export class LLMsTxtAnalyzer {
|
|
|
240
240
|
// "https://site.com/" + "/json" was listed as "https://site.com//json" (R25 1.8).
|
|
241
241
|
const apiUrl = new URL(path, baseUrl).toString();
|
|
242
242
|
const response = await this.fetchWithTimeout(apiUrl, { timeout: 5000 });
|
|
243
|
-
|
|
244
|
-
|
|
243
|
+
const contentType = response.headers.get('content-type') || '';
|
|
244
|
+
// A 200 alone is not an API: gov.uk's /api redirects to an HTML
|
|
245
|
+
// help page (live sweep 4.4). Only machine-readable answers count.
|
|
246
|
+
if (response.ok && /json|xml/i.test(contentType)) {
|
|
245
247
|
return {
|
|
246
248
|
url: apiUrl,
|
|
247
249
|
type: this.determineAPIType(apiUrl, contentType),
|
|
@@ -739,23 +741,33 @@ export class LLMsTxtAnalyzer {
|
|
|
739
741
|
const html = await readBody(response);
|
|
740
742
|
const $ = load(html);
|
|
741
743
|
|
|
742
|
-
// Check for forms
|
|
743
|
-
|
|
744
|
+
// Check for forms. A site-wide search box or a hidden feedback form
|
|
745
|
+
// is page chrome, not the page's purpose: counting them made every
|
|
746
|
+
// docs.python.org and gov.uk page a "forms" page (live sweep 4.4).
|
|
747
|
+
const forms = $('form').filter((_, el) => {
|
|
748
|
+
const form = $(el);
|
|
749
|
+
return form.attr('hidden') === undefined
|
|
750
|
+
&& form.closest('[role="search"]').length === 0
|
|
751
|
+
&& form.find('input[type="search"]').length === 0;
|
|
752
|
+
});
|
|
753
|
+
if (forms.length > 0) {
|
|
744
754
|
return {
|
|
745
755
|
category: 'forms',
|
|
746
756
|
type: 'interactive',
|
|
747
757
|
confidence: 0.9,
|
|
748
|
-
metadata: { formCount:
|
|
758
|
+
metadata: { formCount: forms.length, title: pageTitle($) }
|
|
749
759
|
};
|
|
750
760
|
}
|
|
751
761
|
|
|
752
|
-
// Check for login/auth indicators
|
|
753
|
-
|
|
762
|
+
// Check for login/auth indicators. Substring checks for "login" and
|
|
763
|
+
// "password" matched link hrefs (gov.uk's /student-finance-register-login).
|
|
764
|
+
if ($('input[type="password"]').length > 0) {
|
|
754
765
|
return { category: 'restricted', type: 'authentication', confidence: 0.8 };
|
|
755
766
|
}
|
|
756
767
|
|
|
757
|
-
// Check for dynamic content indicators
|
|
758
|
-
|
|
768
|
+
// Check for dynamic content indicators (the response, not a
|
|
769
|
+
// <script type="application/json"> inside an HTML page)
|
|
770
|
+
if (contentType.includes('json')) {
|
|
759
771
|
return { category: 'dynamic', type: 'api', confidence: 0.9 };
|
|
760
772
|
}
|
|
761
773
|
|
|
@@ -45,6 +45,20 @@ const SUPPORTED_COUNTRIES = {
|
|
|
45
45
|
// "zh-Hans-CN"). "klingon" used to come back as "klingon-DE" (R24).
|
|
46
46
|
const LANGUAGE_TAG = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
|
|
47
47
|
|
|
48
|
+
// A BCP 47 tag that always carries a region, in canonical case: a bare "de"
|
|
49
|
+
// for DE comes back "de-DE" (it used to stay "de" while JP's default answered
|
|
50
|
+
// "ja-JP"), "zh-Hans" for CN becomes "zh-Hans-CN", "EN-gb" becomes "en-GB".
|
|
51
|
+
function withRegion(language, countryCode) {
|
|
52
|
+
const [lang, ...rest] = String(language).split('-');
|
|
53
|
+
const script = rest.find(part => part.length === 4);
|
|
54
|
+
const region = rest.find(part => part.length !== 4) || countryCode;
|
|
55
|
+
return [
|
|
56
|
+
lang.toLowerCase(),
|
|
57
|
+
script && script[0].toUpperCase() + script.slice(1).toLowerCase(),
|
|
58
|
+
region.toUpperCase()
|
|
59
|
+
].filter(Boolean).join('-');
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
// Common language mappings for Accept-Language headers with cultural preferences
|
|
49
63
|
const LANGUAGE_MAPPINGS = {
|
|
50
64
|
'en': 'en-US,en;q=0.9',
|
|
@@ -261,16 +275,17 @@ export class LocalizationManager extends EventEmitter {
|
|
|
261
275
|
}
|
|
262
276
|
|
|
263
277
|
const countryData = SUPPORTED_COUNTRIES[validatedInput.countryCode];
|
|
278
|
+
const language = withRegion(validatedInput.language || countryData.language, validatedInput.countryCode);
|
|
264
279
|
|
|
265
280
|
// Merge country defaults with custom options
|
|
266
281
|
const localizationConfig = {
|
|
267
282
|
countryCode: validatedInput.countryCode,
|
|
268
|
-
language
|
|
283
|
+
language,
|
|
269
284
|
timezone: validatedInput.timezone || countryData.timezone,
|
|
270
285
|
currency: validatedInput.currency || countryData.currency,
|
|
271
286
|
searchDomain: countryData.searchDomain,
|
|
272
287
|
acceptLanguage: validatedInput.acceptLanguage
|
|
273
|
-
|| this.buildAcceptLanguageHeader(
|
|
288
|
+
|| this.buildAcceptLanguageHeader(language, validatedInput.countryCode),
|
|
274
289
|
customHeaders: validatedInput.customHeaders || {},
|
|
275
290
|
geoLocation: validatedInput.geoLocation,
|
|
276
291
|
proxySettings: validatedInput.proxySettings,
|
|
@@ -1291,10 +1306,11 @@ export class LocalizationManager extends EventEmitter {
|
|
|
1291
1306
|
return domainMappings[domain];
|
|
1292
1307
|
}
|
|
1293
1308
|
|
|
1294
|
-
// TLD-based suggestions
|
|
1309
|
+
// TLD-based suggestions. ".uk" is the one ccTLD that is not its ISO
|
|
1310
|
+
// code: a .co.uk site used to fall through to the US-first defaults.
|
|
1295
1311
|
const tldMatch = domain.match(/\.([a-z]{2})$/);
|
|
1296
1312
|
if (tldMatch) {
|
|
1297
|
-
const tld = tldMatch[1].toUpperCase();
|
|
1313
|
+
const tld = tldMatch[1] === 'uk' ? 'GB' : tldMatch[1].toUpperCase();
|
|
1298
1314
|
if (SUPPORTED_COUNTRIES[tld]) {
|
|
1299
1315
|
return [tld];
|
|
1300
1316
|
}
|
|
@@ -12,6 +12,7 @@ import { safeFetch, safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard
|
|
|
12
12
|
import { serverStealthProxies } from '../constants/config.js';
|
|
13
13
|
import { preflightFetch, browserPreflight, redirectGate } from '../utils/robotsGate.js';
|
|
14
14
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
15
|
+
import { guardedProxyFor } from './browser/guardedProxy.js';
|
|
15
16
|
import { noteRetryAfter } from '../utils/hostRateLimiter.js';
|
|
16
17
|
import { load } from 'cheerio';
|
|
17
18
|
import { flattenText } from 'crawlforge-extractors';
|
|
@@ -120,6 +121,25 @@ export function claimStatements(point) {
|
|
|
120
121
|
.filter(Boolean);
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The first `max` characters of a page's text, ended at the last sentence or
|
|
126
|
+
* line end inside them. A plain cut leaves the last sentence half written, and
|
|
127
|
+
* the summarizer took it as a key point: "…the same hostname can range from no
|
|
128
|
+
* bot pro" was a finding in the R25 re-check (2026-10-19). Cut as before when
|
|
129
|
+
* no end falls in the second half.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} text
|
|
132
|
+
* @param {number} max
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function cutAtSentence(text, max) {
|
|
136
|
+
if (text.length <= max) return text;
|
|
137
|
+
const cut = text.slice(0, max);
|
|
138
|
+
const ends = [...cut.matchAll(/[.!?]["'\u201D\u2019)]*(?=\s)|\n/g)];
|
|
139
|
+
const end = ends.length > 0 ? ends[ends.length - 1].index + ends[ends.length - 1][0].length : 0;
|
|
140
|
+
return end > max / 2 ? cut.slice(0, end).trimEnd() : cut;
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
/**
|
|
124
144
|
* Page text for the plain-fetch fallback, one line per block element. The
|
|
125
145
|
* regex tag-strip it replaces ran every block together on one line.
|
|
@@ -896,7 +916,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
896
916
|
const textContent = fallbackPageText(html);
|
|
897
917
|
if (textContent.length > 50) {
|
|
898
918
|
contentData = {
|
|
899
|
-
content: textContent
|
|
919
|
+
content: cutAtSentence(textContent, 5000),
|
|
900
920
|
metadata: { title: source.title || '' },
|
|
901
921
|
structuredData: {},
|
|
902
922
|
fallback: true
|
|
@@ -1078,6 +1098,9 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1078
1098
|
launchOptions.geoip = true;
|
|
1079
1099
|
}
|
|
1080
1100
|
this._stealthBrowser = await camoufox.Camoufox(launchOptions);
|
|
1101
|
+
// Each context goes out through the SSRF-guarded proxy, chained to
|
|
1102
|
+
// this one (src/core/browser/guardedProxy.js).
|
|
1103
|
+
this._stealthUpstream = proxy ? { server: proxy.url } : null;
|
|
1081
1104
|
this._stealthEngineActive = 'camoufox';
|
|
1082
1105
|
this.logger.info('Stealth fallback using Camoufox (Firefox) engine', {
|
|
1083
1106
|
proxy: proxy ? proxy.redacted : null
|
|
@@ -1188,7 +1211,10 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1188
1211
|
if (this._stealthEngineActive === 'camoufox') {
|
|
1189
1212
|
// viewport:null skips Browser.setDefaultViewport, whose playwright-core
|
|
1190
1213
|
// 1.62 payload (isMobile etc.) camoufox's older Firefox build rejects.
|
|
1191
|
-
camoufoxContext = await this._stealthBrowser.newContext({
|
|
1214
|
+
camoufoxContext = await this._stealthBrowser.newContext({
|
|
1215
|
+
viewport: null,
|
|
1216
|
+
proxy: await guardedProxyFor(this._stealthUpstream)
|
|
1217
|
+
});
|
|
1192
1218
|
page = await camoufoxContext.newPage();
|
|
1193
1219
|
} else {
|
|
1194
1220
|
const { contextId } = await this._stealthManager.createStealthContext({ level: this.stealthLevel });
|
|
@@ -1425,7 +1451,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1425
1451
|
try {
|
|
1426
1452
|
if (!source.extractedContent) continue;
|
|
1427
1453
|
|
|
1428
|
-
const content = source.extractedContent
|
|
1454
|
+
const content = cutAtSentence(source.extractedContent, 5000); // Limit content length
|
|
1429
1455
|
|
|
1430
1456
|
// Use summarization to extract key points
|
|
1431
1457
|
const summary = await this.summarizeTool.execute({
|
|
@@ -23,6 +23,7 @@ import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
|
23
23
|
import { serverStealthProxies } from '../constants/config.js';
|
|
24
24
|
import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
|
|
25
25
|
import { readWindowState } from './browser/windowState.js';
|
|
26
|
+
import { guardedProxyFor } from './browser/guardedProxy.js';
|
|
26
27
|
|
|
27
28
|
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
28
29
|
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
@@ -873,9 +874,6 @@ export class StealthBrowserManager {
|
|
|
873
874
|
const proxy = this._launchedEngine === 'camoufox'
|
|
874
875
|
? (this.browser[CAMOUFOX_PROXY] || null)
|
|
875
876
|
: this.resolveProxy(validatedConfig);
|
|
876
|
-
if (proxy) {
|
|
877
|
-
contextOptions.proxy = proxy;
|
|
878
|
-
}
|
|
879
877
|
|
|
880
878
|
// The identity a clearance is bound to: engine, the User-Agent this context
|
|
881
879
|
// presents, and the exit it goes out through. Read before the camoufox
|
|
@@ -953,6 +951,12 @@ export class StealthBrowserManager {
|
|
|
953
951
|
}
|
|
954
952
|
}
|
|
955
953
|
|
|
954
|
+
// What goes on the context is the SSRF-guarded local proxy, chained to
|
|
955
|
+
// that one: it checks every connection the page opens — redirect hops,
|
|
956
|
+
// iframes, WebSockets — where route() never sees them, and keeps the
|
|
957
|
+
// upstream's credentials itself (src/core/browser/guardedProxy.js).
|
|
958
|
+
contextOptions.proxy = await guardedProxyFor(proxy);
|
|
959
|
+
|
|
956
960
|
const context = await this.browser.newContext(contextOptions);
|
|
957
961
|
const contextId = this.generateContextId();
|
|
958
962
|
|
|
@@ -3028,7 +3032,8 @@ export class StealthBrowserManager {
|
|
|
3028
3032
|
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded', onRedirect });
|
|
3029
3033
|
const landedUrl = page.url();
|
|
3030
3034
|
if (response && status === null) status = response.status();
|
|
3031
|
-
|
|
3035
|
+
const waitedMs = wait_for > 0 ? wait_for : 0;
|
|
3036
|
+
if (waitedMs > 0) await page.waitForTimeout(waitedMs);
|
|
3032
3037
|
// Cloudflare's and Vercel's JavaScript challenges solve themselves in a
|
|
3033
3038
|
// real browser and then reload the page. camoufox on lesswrong.com was
|
|
3034
3039
|
// read while the "Vercel Security Checkpoint" interstitial was still
|
|
@@ -3087,7 +3092,7 @@ export class StealthBrowserManager {
|
|
|
3087
3092
|
this._markBlocked(contextId, [url, page.url()]);
|
|
3088
3093
|
}
|
|
3089
3094
|
|
|
3090
|
-
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings, ...(windowState ? { windowState } : {}) };
|
|
3095
|
+
return { success: true, url, title, text, html, screenshot: shot, status, waitedMs, gracedMs, engine: engineUsed, warnings, ...(windowState ? { windowState } : {}) };
|
|
3091
3096
|
} finally {
|
|
3092
3097
|
await this.closeContext(contextId).catch(() => {});
|
|
3093
3098
|
}
|
|
@@ -255,7 +255,7 @@ export class ContentAnalyzer {
|
|
|
255
255
|
|
|
256
256
|
// Readability metrics
|
|
257
257
|
if (analysisOptions.includeReadabilityMetrics) {
|
|
258
|
-
result.readability = await this.calculateReadability(text);
|
|
258
|
+
result.readability = await this.calculateReadability(text, analysisOptions);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
// Sentiment analysis
|
|
@@ -512,6 +512,48 @@ export class ContentAnalyzer {
|
|
|
512
512
|
return latin.length / letters.length < 0.5;
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Whether the text is confidently not English, so the English-only sentiment
|
|
517
|
+
* lexicon and Flesch formula must not be applied. A Spanish complaint scored
|
|
518
|
+
* -0.08 and Spanish and German got English Flesch scores (live sweep
|
|
519
|
+
* 2026-10-20). franc misfiles short English — "I am so happy today" is
|
|
520
|
+
* Somali — so a detected language counts only when it has a stop-word list,
|
|
521
|
+
* the script is not Latin, or the text is long enough to detect reliably.
|
|
522
|
+
* @param {string} text
|
|
523
|
+
* @param {{ detectedLanguage?: string|null }} options
|
|
524
|
+
* @returns {boolean}
|
|
525
|
+
*/
|
|
526
|
+
isNonEnglishText(text, options = {}) {
|
|
527
|
+
if (!this.needsSegmentedTerms(text, options)) return false;
|
|
528
|
+
if (this.isCjkText(text) || !this.isLatinScriptText(text)) return true;
|
|
529
|
+
return Boolean(LANGUAGE_STOP_WORDS[options.detectedLanguage])
|
|
530
|
+
|| text.split(/\s+/).filter(Boolean).length >= 20;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The noun run of a compromise noun phrase: leading determiners, numbers
|
|
535
|
+
* and adjectives and trailing non-nouns dropped. compromise tags "feel" in
|
|
536
|
+
* "The switches feel crisp" as a noun, and the whole clause came back as a
|
|
537
|
+
* topic (sweep 2026-10-20); a plural noun ends an English compound
|
|
538
|
+
* ("battery life", not "batteries life"), so a noun after it is cut off.
|
|
539
|
+
* A phrase of only time words ("two weeks", "Monday") is not a topic.
|
|
540
|
+
* @param {Object} phrase - One compromise noun-phrase match
|
|
541
|
+
* @returns {string} - The head, or '' when there is none
|
|
542
|
+
*/
|
|
543
|
+
nounPhraseHead(phrase) {
|
|
544
|
+
const terms = phrase.terms().json().map(t => t.terms[0]);
|
|
545
|
+
const isNoun = t => t.tags.includes('Noun') && !t.tags.includes('Pronoun');
|
|
546
|
+
const first = terms.findIndex(isNoun);
|
|
547
|
+
if (first === -1) return '';
|
|
548
|
+
let last = terms.length - 1;
|
|
549
|
+
while (!isNoun(terms[last])) last--;
|
|
550
|
+
const head = terms.slice(first, last + 1);
|
|
551
|
+
const plural = head.findIndex(t => t.tags.includes('Plural'));
|
|
552
|
+
if (plural !== -1 && plural < head.length - 1 && isNoun(head[plural + 1])) head.length = plural + 1;
|
|
553
|
+
if (head.every(t => t.tags.includes('Date'))) return '';
|
|
554
|
+
return head.map(t => t.text).join(' ');
|
|
555
|
+
}
|
|
556
|
+
|
|
515
557
|
async extractTopics(text, options = {}) {
|
|
516
558
|
try {
|
|
517
559
|
// compromise noun-phrase matching is English-only: on CJK text it emits
|
|
@@ -533,8 +575,8 @@ export class ContentAnalyzer {
|
|
|
533
575
|
|
|
534
576
|
const doc = nlp(text);
|
|
535
577
|
|
|
536
|
-
// Extract noun
|
|
537
|
-
const nounPhrases = doc.nouns().
|
|
578
|
+
// Extract noun-phrase heads as potential topics
|
|
579
|
+
const nounPhrases = doc.nouns().map(phrase => this.nounPhraseHead(phrase)).filter(Boolean);
|
|
538
580
|
const adjNounPhrases = doc.match('#Adjective+ #Noun+').out('array');
|
|
539
581
|
|
|
540
582
|
// Combine and count frequency
|
|
@@ -597,8 +639,12 @@ export class ContentAnalyzer {
|
|
|
597
639
|
// compromise's out('array') keeps adjoining punctuation ("Craigslist.",
|
|
598
640
|
// "United States,") — clean edges, drop bare stopword/pronoun tokens,
|
|
599
641
|
// and dedupe case-insensitively keeping the first casing seen.
|
|
642
|
+
// On other languages compromise's capital-letter heuristics also pick
|
|
643
|
+
// up function words and titles: "über" as an organisation, "Berlin den",
|
|
644
|
+
// "Präsidenten Emmanuel Macron" (sweep 2026-10-20).
|
|
645
|
+
const language = options.detectedLanguage;
|
|
600
646
|
const clean = (arr) => this.dedupeEntities(
|
|
601
|
-
arr.map(e => this.cleanEntityText(e))
|
|
647
|
+
arr.map(e => this.trimForeignEntity(this.cleanEntityText(e), language))
|
|
602
648
|
.filter(e => e.length > 1
|
|
603
649
|
&& !this.isBareStopword(e)
|
|
604
650
|
&& !/^(?:inc|corp|ltd|co|llc)\.?$/i.test(e)) // bare corporate-suffix fragment
|
|
@@ -609,7 +655,10 @@ export class ContentAnalyzer {
|
|
|
609
655
|
let organizations = clean(doc.organizations().out('array'));
|
|
610
656
|
// .dates() needs the compromise-dates plugin (not installed) and threw,
|
|
611
657
|
// aborting ALL entity extraction; #Date+ tag matching is core compromise.
|
|
612
|
-
|
|
658
|
+
// A duration ("weeks", "an hour") is not a date; a weekday is, in
|
|
659
|
+
// every language with a list — compromise knows only the English ones.
|
|
660
|
+
const dates = clean([...doc.match('#Date+').out('array'), ...this.findWeekdays(text, language)])
|
|
661
|
+
.filter(e => !DURATION_ONLY.test(e));
|
|
613
662
|
const money = clean(doc.money().out('array'));
|
|
614
663
|
let other = clean(doc.topics().out('array').slice(0, 10))
|
|
615
664
|
.filter(e => !this.isSentenceInitialArtifact(e, text));
|
|
@@ -651,12 +700,13 @@ export class ContentAnalyzer {
|
|
|
651
700
|
// Unicode classes, not [A-Z]: the ASCII version cut "Universität" to
|
|
652
701
|
// "Universit" and "Wisłą" to "Wis" and dropped "Środkowej" outright (R16).
|
|
653
702
|
// \b stays ASCII-only even under the u flag, hence the lookbehind.
|
|
654
|
-
|
|
703
|
+
// German capitalises every noun, so a capital is no evidence there.
|
|
704
|
+
const properNouns = language === 'deu' ? [] : (text.match(/(?<![\p{L}\p{N}])\p{Lu}[\p{L}.]+(?:\s+\p{Lu}[\p{L}.]+)*/gu) || [])
|
|
655
705
|
// Split matches that crossed a sentence boundary ("XPath. In") — a
|
|
656
706
|
// period after a lowercase letter followed by a capital is a sentence
|
|
657
707
|
// end, while abbreviations ("U.S. Holdings") stay intact.
|
|
658
708
|
.flatMap(n => n.split(/(?<=\p{Ll}\.)\s+(?=\p{Lu})/u));
|
|
659
|
-
const supplemental = this.dedupeEntities(properNouns.map(n => this.cleanEntityText(n)))
|
|
709
|
+
const supplemental = this.dedupeEntities(properNouns.map(n => this.trimForeignEntity(this.cleanEntityText(n), language)))
|
|
660
710
|
.filter(n => n.length > 1
|
|
661
711
|
&& !existingEntities.has(n.toLowerCase())
|
|
662
712
|
&& !this.isSentenceInitialArtifact(n, text))
|
|
@@ -778,9 +828,10 @@ export class ContentAnalyzer {
|
|
|
778
828
|
/**
|
|
779
829
|
* Calculate readability metrics
|
|
780
830
|
* @param {string} text - Text to analyze
|
|
831
|
+
* @param {{ detectedLanguage?: string|null }} [options]
|
|
781
832
|
* @returns {Promise<Object>} - Readability metrics
|
|
782
833
|
*/
|
|
783
|
-
async calculateReadability(text) {
|
|
834
|
+
async calculateReadability(text, options = {}) {
|
|
784
835
|
try {
|
|
785
836
|
const sentences = splitSentences(text);
|
|
786
837
|
const isCjk = this.isCjkText(text);
|
|
@@ -824,6 +875,14 @@ export class ContentAnalyzer {
|
|
|
824
875
|
metrics
|
|
825
876
|
};
|
|
826
877
|
}
|
|
878
|
+
// The constants are fitted to English; Spanish and German got English
|
|
879
|
+
// scores (sweep 2026-10-20). No other-language formula is implemented.
|
|
880
|
+
if (this.isNonEnglishText(text, options)) {
|
|
881
|
+
return {
|
|
882
|
+
notApplicable: 'flesch-reading-ease-is-english-only',
|
|
883
|
+
metrics
|
|
884
|
+
};
|
|
885
|
+
}
|
|
827
886
|
|
|
828
887
|
// Flesch Reading Ease Score
|
|
829
888
|
const fleschScore = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord);
|
|
@@ -852,31 +911,27 @@ export class ContentAnalyzer {
|
|
|
852
911
|
// negation ("not happy" scores negative). A 25-word list used to leave
|
|
853
912
|
// clearly positive text "neutral" (R25).
|
|
854
913
|
const { calculation, tokens } = sentimentAnalyzer.analyze(text);
|
|
855
|
-
|
|
856
|
-
// "die" is -3 in the English lexicon.
|
|
857
|
-
const foreignStopWords = LANGUAGE_STOP_WORDS[options.detectedLanguage];
|
|
858
|
-
const scores = calculation
|
|
859
|
-
.map(entry => Object.entries(entry)[0])
|
|
860
|
-
.filter(([word]) => !foreignStopWords?.has(word))
|
|
861
|
-
.map(([, score]) => score);
|
|
914
|
+
const scores = calculation.map(entry => Object.values(entry)[0]);
|
|
862
915
|
|
|
863
916
|
const totalSentimentWords = scores.length;
|
|
864
917
|
|
|
918
|
+
// The lexicon is English. Its hits in Spanish or German text are
|
|
919
|
+
// accidents ("die" is -3), and finding none is not evidence of
|
|
920
|
+
// neutrality, so say the measure does not apply (R24, sweep 2026-10-20).
|
|
921
|
+
// Text franc may have misfiled — "I am so happy today" as Somali — is
|
|
922
|
+
// still scored when the lexicon matched.
|
|
923
|
+
if (this.isNonEnglishText(text, options)
|
|
924
|
+
|| (totalSentimentWords === 0 && this.needsSegmentedTerms(text, options))) {
|
|
925
|
+
return {
|
|
926
|
+
polarity: 0,
|
|
927
|
+
subjectivity: 0,
|
|
928
|
+
label: 'not_applicable',
|
|
929
|
+
confidence: 0,
|
|
930
|
+
notApplicable: 'sentiment-lexicon-is-english-only'
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
865
934
|
if (totalSentimentWords === 0) {
|
|
866
|
-
// The lexicon is English. Finding none of its words in German or
|
|
867
|
-
// Japanese text is not evidence of neutrality, so say the measure does
|
|
868
|
-
// not apply instead of reporting "neutral" (R24). Only when nothing
|
|
869
|
-
// matched: franc puts short English such as "I am so happy today" in
|
|
870
|
-
// Somali, and those strings must still be scored.
|
|
871
|
-
if (this.needsSegmentedTerms(text, options)) {
|
|
872
|
-
return {
|
|
873
|
-
polarity: 0,
|
|
874
|
-
subjectivity: 0,
|
|
875
|
-
label: 'not_applicable',
|
|
876
|
-
confidence: 0,
|
|
877
|
-
notApplicable: 'sentiment-lexicon-is-english-only'
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
935
|
// No opinion words at all: neutral, but with no evidence behind it.
|
|
881
936
|
return {
|
|
882
937
|
polarity: 0,
|
|
@@ -1005,6 +1060,37 @@ export class ContentAnalyzer {
|
|
|
1005
1060
|
return entity;
|
|
1006
1061
|
}
|
|
1007
1062
|
|
|
1063
|
+
/**
|
|
1064
|
+
* Trim an entity found in non-English text: leading and trailing function
|
|
1065
|
+
* words of the detected language ("Berlin den" → "Berlin", "über" → "")
|
|
1066
|
+
* and leading titles ("Präsidenten Emmanuel Macron" → "Emmanuel Macron").
|
|
1067
|
+
* Text with no stop-word list for its language is returned unchanged.
|
|
1068
|
+
* @param {string} entity - Cleaned entity string
|
|
1069
|
+
* @param {string|null} [language] - Detected ISO 639-3 code
|
|
1070
|
+
* @returns {string}
|
|
1071
|
+
*/
|
|
1072
|
+
trimForeignEntity(entity, language) {
|
|
1073
|
+
const stopWords = LANGUAGE_STOP_WORDS[language];
|
|
1074
|
+
if (!stopWords) return entity;
|
|
1075
|
+
const tokens = entity.split(/\s+/);
|
|
1076
|
+
while (tokens.length > 0 && (stopWords.has(tokens[0].toLowerCase()) || ENTITY_TITLES.has(tokens[0].toLowerCase()))) tokens.shift();
|
|
1077
|
+
while (tokens.length > 0 && stopWords.has(tokens[tokens.length - 1].toLowerCase())) tokens.pop();
|
|
1078
|
+
return tokens.join(' ');
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Weekday names of the detected language, as written in the text.
|
|
1083
|
+
* @param {string} text - Full source text
|
|
1084
|
+
* @param {string|null} [language] - Detected ISO 639-3 code
|
|
1085
|
+
* @returns {string[]}
|
|
1086
|
+
*/
|
|
1087
|
+
findWeekdays(text, language) {
|
|
1088
|
+
const names = WEEKDAYS[language];
|
|
1089
|
+
if (!names) return [];
|
|
1090
|
+
const pattern = new RegExp(`(?<![\\p{L}\\p{N}])(?:${names.join('|')})(?![\\p{L}\\p{N}])`, 'giu');
|
|
1091
|
+
return text.match(pattern) || [];
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1008
1094
|
/**
|
|
1009
1095
|
* Deduplicate entities case-insensitively, keeping the first casing seen
|
|
1010
1096
|
* @param {string[]} entities - Entity strings
|
|
@@ -1156,6 +1242,48 @@ export class ContentAnalyzer {
|
|
|
1156
1242
|
* way; every language whose function words are not on this list gets them back
|
|
1157
1243
|
* as topics.
|
|
1158
1244
|
*/
|
|
1245
|
+
/**
|
|
1246
|
+
* A bare duration, optionally counted: "weeks", "an hour", "two years". It
|
|
1247
|
+
* measures time but names no date; compromise tags it #Date (sweep 2026-10-20).
|
|
1248
|
+
*/
|
|
1249
|
+
const DURATION_ONLY = /^(?:(?:a|an|one|two|three|four|five|six|seven|eight|nine|ten|few|several|many|\d+)\s+)?(?:seconds?|minutes?|hours?|days?|weeks?|fortnights?|months?|years?|decades?|centuries|century)$/i;
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Weekday names by ISO 639-3 code, for the languages with a stop-word list.
|
|
1253
|
+
* English is left to compromise. Indonesian "minggu" (Sunday) is left out: it
|
|
1254
|
+
* also means "week", and Portuguese bare "segunda" also means "second".
|
|
1255
|
+
*/
|
|
1256
|
+
const WEEKDAYS = {
|
|
1257
|
+
deu: ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonnabend', 'sonntag'],
|
|
1258
|
+
spa: ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo'],
|
|
1259
|
+
fra: ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'],
|
|
1260
|
+
ita: ['lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica'],
|
|
1261
|
+
por: ['segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado', 'domingo'],
|
|
1262
|
+
nld: ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'],
|
|
1263
|
+
swe: ['måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag', 'söndag'],
|
|
1264
|
+
dan: ['mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag', 'søndag'],
|
|
1265
|
+
nob: ['mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag', 'søndag'],
|
|
1266
|
+
pol: ['poniedziałek', 'wtorek', 'środa', 'środę', 'czwartek', 'piątek', 'sobota', 'sobotę', 'niedziela', 'niedzielę'],
|
|
1267
|
+
ind: ['senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu'],
|
|
1268
|
+
zlm: ['isnin', 'selasa', 'rabu', 'khamis', 'jumaat', 'sabtu', 'ahad'],
|
|
1269
|
+
zsm: ['isnin', 'selasa', 'rabu', 'khamis', 'jumaat', 'sabtu', 'ahad']
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Titles that compromise folds into a following name in non-English text
|
|
1274
|
+
* ("Präsidenten Emmanuel Macron"), lowercased. Stripped only from the front.
|
|
1275
|
+
*/
|
|
1276
|
+
const ENTITY_TITLES = new Set([
|
|
1277
|
+
'präsident', 'präsidenten', 'präsidentin', 'bundespräsident', 'bundespräsidenten',
|
|
1278
|
+
'bundeskanzler', 'bundeskanzlerin', 'kanzler', 'kanzlerin', 'minister', 'ministerin',
|
|
1279
|
+
'ministerpräsident', 'ministerpräsidentin', 'herr', 'herrn', 'frau', 'könig', 'königin',
|
|
1280
|
+
'presidente', 'presidenta', 'señor', 'señora', 'don', 'doña', 'ministro', 'ministra', 'rey', 'reina',
|
|
1281
|
+
'président', 'présidente', 'monsieur', 'madame', 'ministre', 'roi', 'reine',
|
|
1282
|
+
'signor', 'signore', 'signora', 'senhor', 'senhora',
|
|
1283
|
+
'president', 'premier', 'meneer', 'mevrouw', 'koning', 'koningin',
|
|
1284
|
+
'dr', 'dr.', 'prof', 'prof.', 'professor'
|
|
1285
|
+
]);
|
|
1286
|
+
|
|
1159
1287
|
const STOP_WORDS = new Set([
|
|
1160
1288
|
// English
|
|
1161
1289
|
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',
|