crawlforge-mcp-server 6.21.0 → 6.22.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/package.json +3 -2
- package/server.js +12 -12
- package/src/core/ActionExecutor.js +23 -4
- package/src/core/AgentOrchestrator.js +141 -19
- package/src/core/AuthManager.js +4 -1
- package/src/core/LLMsTxtAnalyzer.js +6 -7
- package/src/core/LocalizationManager.js +29 -4
- package/src/core/ResearchOrchestrator.js +48 -13
- package/src/core/SamplingClient.js +38 -8
- package/src/core/SnapshotManager.js +32 -13
- package/src/core/analysis/ContentAnalyzer.js +26 -25
- package/src/core/crawlers/BFSCrawler.js +48 -10
- package/src/core/processing/ContentProcessor.js +4 -2
- package/src/core/queue/QueueManager.js +16 -2
- package/src/resources/ResourceRegistry.js +19 -5
- package/src/schemas/toolOutputSchemas.js +23 -15
- package/src/server/fallbackHints.js +37 -7
- package/src/server/withAuth.js +27 -44
- 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 +1 -1
- 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-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
- 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/tools/advanced/BrowserSessionTool.js +11 -2
- package/src/tools/advanced/batchScrape/queue.js +4 -2
- package/src/tools/advanced/batchScrape/worker.js +50 -11
- package/src/tools/agent/agent.js +1 -1
- package/src/tools/basic/_fetch.js +2 -1
- package/src/tools/basic/extractMetadata.js +2 -1
- package/src/tools/basic/extractText.js +12 -8
- package/src/tools/crawl/mapSite.js +8 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractStructured.js +4 -2
- package/src/tools/extract/extractWithLlm.js +8 -4
- package/src/tools/extract/processDocument.js +29 -1
- package/src/tools/extract/summarizeContent.js +5 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
- package/src/tools/scrape/_mainContent.js +54 -2
- package/src/tools/scrape/unifiedScrape.js +94 -6
- package/src/tools/search/adapters/braveSearch.js +27 -5
- package/src/tools/search/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- package/src/tools/search/searchWeb.js +2 -2
- package/src/tools/tracking/trackChanges/differ.js +2 -1
- package/src/tools/tracking/trackChanges/index.js +6 -1
- package/src/tools/tracking/trackChanges/schema.js +15 -1
- package/src/utils/fetchError.js +47 -0
- package/src/utils/fetchLadder.js +5 -5
- package/src/utils/htmlToMarkdown.js +36 -2
- package/src/utils/ssrfGuard.js +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.22.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"cheerio": "^1.1.2",
|
|
122
122
|
"commander": "^14.0.3",
|
|
123
123
|
"compromise": "^14.14.4",
|
|
124
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.18.1",
|
|
125
125
|
"diff": "^9.0.0",
|
|
126
126
|
"dotenv": "^17.2.1",
|
|
127
127
|
"franc": "^6.2.0",
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"pdf-parse": "2.4.5",
|
|
133
133
|
"playwright": "^1.54.2",
|
|
134
134
|
"robots-parser": "^3.0.1",
|
|
135
|
+
"sentiment": "5.0.2",
|
|
135
136
|
"turndown": "^7.2.4",
|
|
136
137
|
"turndown-plugin-gfm": "^1.0.2",
|
|
137
138
|
"undici": "^7.24.0",
|
package/server.js
CHANGED
|
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
113
113
|
// Create the server
|
|
114
114
|
const server = new McpServer({
|
|
115
115
|
name: "crawlforge",
|
|
116
|
-
version: "6.
|
|
116
|
+
version: "6.22.0",
|
|
117
117
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
118
118
|
homepage: "https://www.crawlforge.dev",
|
|
119
119
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -572,10 +572,10 @@ registerToolIfEnabled("search_web", {
|
|
|
572
572
|
try {
|
|
573
573
|
if (!query && !queries) {
|
|
574
574
|
// `query` stopped being a required field when `queries` arrived, so the
|
|
575
|
-
// SDK no longer rejects this call for us — it reaches the handler,
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
575
|
+
// SDK no longer rejects this call for us — it reaches the handler, which
|
|
576
|
+
// refuses it before any search runs. Same reasoning as
|
|
577
|
+
// ScrapeTemplateTool's badRequest: we fetched nothing, so it costs
|
|
578
|
+
// nothing (G4).
|
|
579
579
|
markPreflightRefusal('BAD_REQUEST');
|
|
580
580
|
return { content: [{ type: "text", text: EXACTLY_ONE_QUERY_MESSAGE }], isError: true };
|
|
581
581
|
}
|
|
@@ -1258,8 +1258,8 @@ registerToolIfEnabled("scrape", {
|
|
|
1258
1258
|
return shot;
|
|
1259
1259
|
});
|
|
1260
1260
|
}
|
|
1261
|
-
// A blocked verdict (0.1) is an error result: withAuth
|
|
1262
|
-
//
|
|
1261
|
+
// A blocked verdict (0.1) is an error result: withAuth charges it nothing
|
|
1262
|
+
// and appends the hint naming stealth_mode, and the SDK skips
|
|
1263
1263
|
// outputSchema validation on isError. The JSON body still carries the
|
|
1264
1264
|
// verdict (success:false, error, blocked) the way stealth_mode's does.
|
|
1265
1265
|
if (result.success === false) return { ...dualOutput(result), isError: true };
|
|
@@ -1274,7 +1274,7 @@ registerToolIfEnabled("agent", {
|
|
|
1274
1274
|
description: "Use this when you need an autonomous agent to research, navigate, and synthesise an answer from the web - no URLs required. The agent plans search queries, fetches and filters relevant pages, and returns a prose or structured answer. model:\"pro\" uses deep multi-source research. Hard limits: maxSteps<=10, maxUrls<=20, 120s wall-clock. Confirms before pro runs. Degraded-but-useful output if no LLM keys/Ollama. Not for a URL you already have (scrape) or a question one search answers (search_web). Pages that block a plain fetch are retried in the stealth browser automatically (at most 2 a run, URLs you name first; evidence marked via:\"stealth\"). Cost: 18 credits at most - 8, plus 5 per stealth retry that gets the page; a retry that is blocked again is free. Example: agent({prompt:\"What are the top 5 MCP servers in 2025?\", maxUrls:10})",
|
|
1275
1275
|
annotations: { title: "Agent (Autonomous)", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1276
1276
|
inputSchema: {
|
|
1277
|
-
prompt: z.string().min(1).max(2000).describe("Natural-language task or question"),
|
|
1277
|
+
prompt: z.string().trim().min(1).max(2000).describe("Natural-language task or question"),
|
|
1278
1278
|
urls: z.array(z.string().url()).max(20).optional().describe("Optional seed URLs to include (max 20)"),
|
|
1279
1279
|
schema: z.record(z.any()).optional().describe("Optional JSON schema for structured output"),
|
|
1280
1280
|
model: z.enum(["default", "pro"]).optional().default("default").describe("\"default\" = SamplingClient loop (no keys needed); \"pro\" = full ResearchOrchestrator"),
|
|
@@ -1335,7 +1335,7 @@ registerToolIfEnabled("generate_llms_txt", {
|
|
|
1335
1335
|
customRestrictions: z.array(z.string()).optional(),
|
|
1336
1336
|
robotsStyle: z.boolean().optional().default(false)
|
|
1337
1337
|
}).optional().describe("Output customization and organization details"),
|
|
1338
|
-
complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe("
|
|
1338
|
+
complianceLevel: z.enum(['basic', 'standard', 'strict']).optional().default('standard').describe("Applies only with outputOptions.robotsStyle:true, where it sets how many paths the llms.txt Disallow list covers (basic < standard < strict). The default llmstxt.org markdown is the same at every level; llms-full.txt only records the level as a label."),
|
|
1339
1339
|
format: z.enum(['both', 'llms-txt', 'llms-full-txt']).optional().default('both').describe("Output format: llms.txt, llms-full.txt, or both"),
|
|
1340
1340
|
...MAX_INLINE_CHARS_PARAM
|
|
1341
1341
|
}
|
|
@@ -1423,10 +1423,10 @@ function stealthScrapeFormats(formats, scraped) {
|
|
|
1423
1423
|
|
|
1424
1424
|
// Tool: stealth_mode
|
|
1425
1425
|
registerToolIfEnabled("stealth_mode", {
|
|
1426
|
-
description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a real browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing - Camoufox (Firefox) when it is installed, Chromium otherwise, and the result names the one that ran. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down.
|
|
1426
|
+
description: "Use this when a site blocks normal scraping - Cloudflare, Datadome, or other bot-detection systems. Renders in a real browser with randomized fingerprints, human behavior simulation, WebRTC/canvas spoofing - Camoufox (Firefox) when it is installed, Chromium otherwise, and the result names the one that ran. operation:\"scrape\" is the one-shot path: it creates a context, navigates, returns the requested formats and tears down. create_context then create_page is a navigation and challenge check: create_page loads urlToTest, reports its status, title and any challenge page, and closes the page before the call returns, so nothing on it carries over to a later call. For work over several calls on one page (log in, then read; click, then read), use browser_session with stealth:true. operation:\"configure\" only validates a stealthConfig and returns it with the defaults filled in - it stores nothing, so pass stealthConfig on each scrape or create_context call that should use it. robots.txt is respected on every navigation. Not a first choice: try scrape first and switch here after a 403/429/CAPTCHA/challenge page or an empty shell. Cost: 5 credits per browser operation; configure, get_stats and cleanup cost 1. Example: stealth_mode({operation:\"scrape\", url:\"https://example.com\", formats:[\"markdown\",\"links\"]})",
|
|
1427
1427
|
annotations: { title: "Stealth Mode", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1428
1428
|
inputSchema: {
|
|
1429
|
-
operation: z.enum(['scrape', 'configure', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform. scrape: render one URL and return the formats. configure: validate stealthConfig and return it with defaults filled in; nothing is stored. create_context
|
|
1429
|
+
operation: z.enum(['scrape', 'configure', 'create_context', 'create_page', 'get_stats', 'cleanup']).default('configure').describe("Stealth operation to perform. scrape: render one URL and return the formats. configure: validate stealthConfig and return it with defaults filled in; nothing is stored. create_context: a stealth context to run create_page in. create_page: navigate urlToTest in that context and report status, title and any challenge page; the page is closed before the call returns. get_stats: this server process's open contexts, browser and proxy state. cleanup: close the browser and every context."),
|
|
1430
1430
|
stealthConfig: z.object({
|
|
1431
1431
|
level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
|
|
1432
1432
|
randomizeFingerprint: z.boolean().default(true),
|
|
@@ -1467,7 +1467,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1467
1467
|
}).optional().describe("Stealth browser configuration with anti-detection settings. Applies to the call it is passed on and is not remembered. Chromium applies customUserAgent, customViewport, locale and timezone per call. On camoufox customUserAgent and customViewport are not applied, locale is fixed when the browser launches (run operation:\"cleanup\" first to change it), and behind a proxy locale and timezone come from the exit IP; each of these is reported in `warnings`."),
|
|
1468
1468
|
engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).optional().default("auto").describe("Browser engine: \"auto\" (default — camoufox when it is installed, Chromium otherwise, and the result says which), \"camoufox\" (Firefox-based, higher anti-detect score; fails if not installed), or \"chromium\" (\"playwright\" is the same engine under its old name)"),
|
|
1469
1469
|
contextId: z.string().optional().describe("Browser context ID for page operations"),
|
|
1470
|
-
urlToTest: z.string().url().optional().describe("URL to navigate to
|
|
1470
|
+
urlToTest: z.string().url().optional().describe("create_page: the URL to navigate to and check"),
|
|
1471
1471
|
url: z.string().url().optional().describe("URL to scrape — required for operation:\"scrape\""),
|
|
1472
1472
|
formats: z.array(z.enum(["markdown", "html", "text", "links", "metadata", "screenshot"])).optional().default(["markdown"]).describe("Formats to return from operation:\"scrape\" (default: [\"markdown\"]). \"screenshot\" returns a crawlforge://screenshot/{id} resource URI."),
|
|
1473
1473
|
wait_for: z.number().min(0).max(30000).optional().describe("Extra wait after page load, in ms — for content that renders after DOMContentLoaded"),
|
|
@@ -51,6 +51,17 @@ const SELECTOR_WAIT_STATES = new Set(['attached', 'detached', 'visible', 'hidden
|
|
|
51
51
|
|
|
52
52
|
const withoutHash = (url) => url.split('#')[0];
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A failed action's message, naming its selector. Playwright names it only in
|
|
56
|
+
* the "Call log:", which is stripped before the model reads the error (R25 3.2).
|
|
57
|
+
*/
|
|
58
|
+
function actionErrorMessage(action, error) {
|
|
59
|
+
const firstLine = String(error.message).split('\n')[0];
|
|
60
|
+
return action.selector && !firstLine.includes(action.selector)
|
|
61
|
+
? `${action.type} on selector "${action.selector}": ${error.message}`
|
|
62
|
+
: error.message;
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
// Action schemas
|
|
55
66
|
const BaseActionSchema = z.object({
|
|
56
67
|
type: z.string(),
|
|
@@ -854,6 +865,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
854
865
|
action.timeout) {
|
|
855
866
|
timeout = Math.max(this.defaultTimeout, action.timeout + 5000);
|
|
856
867
|
}
|
|
868
|
+
// A pause before a selector/text wait (executeWaitAction) runs on top of
|
|
869
|
+
// that wait's deadline.
|
|
870
|
+
if (action.type === 'wait' && (action.selector || action.text)) {
|
|
871
|
+
timeout += action.duration || action.milliseconds || 0;
|
|
872
|
+
}
|
|
857
873
|
|
|
858
874
|
// Execute based on action type. Playwright owns the real deadline (every
|
|
859
875
|
// call below is given `timeout`), so this race is only a backstop for a
|
|
@@ -902,7 +918,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
902
918
|
id: actionId,
|
|
903
919
|
type: action.type,
|
|
904
920
|
success: false,
|
|
905
|
-
error: error
|
|
921
|
+
error: actionErrorMessage(action, error),
|
|
906
922
|
// Only when the gate refused (a `navigate` to a URL it would not
|
|
907
923
|
// load), so the chain can tell an answer from a fault.
|
|
908
924
|
...(refusalCode ? { errorCode: refusalCode } : {}),
|
|
@@ -1094,14 +1110,17 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1094
1110
|
(!action.selector && !action.text ? action.timeout : undefined);
|
|
1095
1111
|
if (waitTime) {
|
|
1096
1112
|
await this.delay(waitTime);
|
|
1097
|
-
|
|
1113
|
+
// A pause given with a selector or text waits for that too, after the
|
|
1114
|
+
// pause; the selector and condition used to be dropped (R25 3.3).
|
|
1115
|
+
if (!action.selector && !action.text) return { waited: waitTime };
|
|
1098
1116
|
}
|
|
1117
|
+
const waited = waitTime ? { waited: waitTime } : {};
|
|
1099
1118
|
|
|
1100
1119
|
const timeout = this.actionTimeout(action);
|
|
1101
1120
|
|
|
1102
1121
|
if (action.selector) {
|
|
1103
1122
|
await this.waitForCondition(page, action.selector, action.condition, timeout);
|
|
1104
|
-
return { selector: action.selector, condition: action.condition };
|
|
1123
|
+
return { ...waited, selector: action.selector, condition: action.condition };
|
|
1105
1124
|
}
|
|
1106
1125
|
|
|
1107
1126
|
if (action.text) {
|
|
@@ -1110,7 +1129,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1110
1129
|
action.text,
|
|
1111
1130
|
{ timeout }
|
|
1112
1131
|
);
|
|
1113
|
-
return { text: action.text };
|
|
1132
|
+
return { ...waited, text: action.text };
|
|
1114
1133
|
}
|
|
1115
1134
|
|
|
1116
1135
|
throw new Error('Wait action requires duration/milliseconds/timeout, selector, or text');
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* Hard stops (enforced here, not by the LLM):
|
|
13
13
|
* 1. maxSteps iterations of the ACT loop
|
|
14
14
|
* 2. maxUrls total URLs fetched
|
|
15
|
-
* 3. wallClockMs wall-clock milliseconds (default 120 000)
|
|
15
|
+
* 3. wallClockMs wall-clock milliseconds (default 120 000); every LLM call
|
|
16
|
+
* gets only the time left, and a run that reaches it returns degraded
|
|
17
|
+
* with the evidence gathered so far
|
|
16
18
|
*
|
|
17
19
|
* No-LLM-key path: if all LLM calls fail, return collected evidence + {degraded:true}.
|
|
18
20
|
* pro model: delegates to ResearchOrchestrator.conductResearch() for richer synthesis.
|
|
@@ -31,6 +33,12 @@ const DEFAULT_MAX_URLS = 10;
|
|
|
31
33
|
// A stealth retry launches a browser and waits out a challenge; one started
|
|
32
34
|
// with less than this left on the wall clock would overrun the hard stop.
|
|
33
35
|
const ESCALATION_MIN_REMAINING_MS = 20_000;
|
|
36
|
+
// PLAN is optional (the raw prompt is the fallback query), so a slow model
|
|
37
|
+
// may spend at most this share of the wall clock on it, never all of it.
|
|
38
|
+
const PLAN_MAX_SHARE = 0.25;
|
|
39
|
+
// A corrective rewrite is a whole synthesis call; with less than this left it
|
|
40
|
+
// would run into the hard stop, so the first answer is kept and flagged.
|
|
41
|
+
const REWRITE_MIN_REMAINING_MS = 15_000;
|
|
34
42
|
|
|
35
43
|
/** A refusal (robots.txt, blocklist) is a decision, not a wall — a browser must not retry it. */
|
|
36
44
|
function isRefusal(err) {
|
|
@@ -103,14 +111,14 @@ export function unverifiedValues(answer, sourceText) {
|
|
|
103
111
|
|
|
104
112
|
/**
|
|
105
113
|
* Current-state task gate: prompts about the live "now" ("right now",
|
|
106
|
-
* "currently", "today", "latest", "#1 … now") go stale through search alone —
|
|
114
|
+
* "current", "currently", "today", "latest", "#1 … now") go stale through search alone —
|
|
107
115
|
* dated articles rank for the task's words while the live page does not
|
|
108
116
|
* (live repro 2026-08-26: "#1 story on Hacker News right now" answered from
|
|
109
117
|
* a January thread; news.ycombinator.com was never fetched). Detected
|
|
110
118
|
* deterministically here (never LLM-trusted) so PLAN steers search toward
|
|
111
119
|
* the live entity page and SHAPE answers from it, not from dated results.
|
|
112
120
|
*/
|
|
113
|
-
const CURRENT_STATE_RE = /\b(right now|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
|
|
121
|
+
const CURRENT_STATE_RE = /\b(right now|current|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
|
|
114
122
|
|
|
115
123
|
/** Version numbers as they appear in an answer, e.g. "2.11.4". */
|
|
116
124
|
const VERSION_IN_TEXT = /\b\d+\.\d+(?:\.\d+){0,2}\b/g;
|
|
@@ -172,6 +180,66 @@ export function isCurrentStateTask(prompt) {
|
|
|
172
180
|
return CURRENT_STATE_RE.test(prompt || '');
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
/** The registrable label of a hostname: "python" for www.python.org, "bbc" for bbc.co.uk. */
|
|
184
|
+
function siteLabel(hostname) {
|
|
185
|
+
const labels = hostname.toLowerCase().split('.');
|
|
186
|
+
const ccSecondLevel = labels.length >= 3 && labels.at(-1).length === 2 && labels.at(-2).length <= 3;
|
|
187
|
+
return labels[Math.max(labels.length - (ccSecondLevel ? 3 : 2), 0)];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* True when a search result is the entity's own site: its domain is the
|
|
192
|
+
* entity's name (python.org for "Python"), or it is a site root whose title
|
|
193
|
+
* leads with exactly that name (news.ycombinator.com, titled "Hacker News").
|
|
194
|
+
*
|
|
195
|
+
* The domain vote alone picked the wrong site in both R25 repros (2026-10-19).
|
|
196
|
+
* Brave returns each origin about once, so for "Python" every origin tied and
|
|
197
|
+
* the top-ranked one, en.wikipedia.org, was promoted — its root is the
|
|
198
|
+
* Wikipedia Main Page. For "Hacker News" thehackernews.com appeared twice and
|
|
199
|
+
* outvoted news.ycombinator.com. Neither look-alike passes this test: its
|
|
200
|
+
* domain is "thehackernews", its title "The Hacker News | …".
|
|
201
|
+
*/
|
|
202
|
+
export function isOfficialResult(entity, url, title) {
|
|
203
|
+
const name = (entity || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
204
|
+
if (!name) return false;
|
|
205
|
+
let u;
|
|
206
|
+
try { u = new URL(url); } catch { return false; }
|
|
207
|
+
const compact = s => s.replace(/[^\p{L}\p{N}]/gu, '');
|
|
208
|
+
if (compact(siteLabel(u.hostname)) === compact(name)) return true;
|
|
209
|
+
const lead = (title || '').split(/\s+[|–—-]\s+|:\s/)[0].trim().toLowerCase().replace(/\s+/g, ' ');
|
|
210
|
+
return u.pathname === '/' && !u.search && lead === name;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A page's share of the synthesis budget. Cut from the top, a page whose
|
|
215
|
+
* answer sits below its navigation menus loses it: python.org's
|
|
216
|
+
* "Latest: Python 3.15.0" sits ~3 500 characters in, under a wall of menu
|
|
217
|
+
* links, past a 2 400-character share (R25, 2026-10-19). When a line past the
|
|
218
|
+
* cut matches more of the task's terms than any line before it, the second
|
|
219
|
+
* half of the share goes to the text around that line.
|
|
220
|
+
*/
|
|
221
|
+
export function sliceForSynthesis(text, cap, terms) {
|
|
222
|
+
if (!text || text.length <= cap) return text;
|
|
223
|
+
const score = line => {
|
|
224
|
+
const lc = line.toLowerCase();
|
|
225
|
+
return terms.filter(t => lc.includes(t)).length;
|
|
226
|
+
};
|
|
227
|
+
let keptBest = 0;
|
|
228
|
+
let best = 0;
|
|
229
|
+
let bestAt = -1;
|
|
230
|
+
let pos = 0;
|
|
231
|
+
for (const line of text.split('\n')) {
|
|
232
|
+
const s = score(line);
|
|
233
|
+
if (pos + line.length <= cap) keptBest = Math.max(keptBest, s);
|
|
234
|
+
else if (s > best) { best = s; bestAt = pos; }
|
|
235
|
+
pos += line.length + 1;
|
|
236
|
+
}
|
|
237
|
+
if (best <= keptBest) return truncate(text, cap);
|
|
238
|
+
const head = Math.floor(cap / 2);
|
|
239
|
+
const from = Math.max(head, bestAt - 200);
|
|
240
|
+
return `${text.slice(0, head)}\n[...]\n${truncate(text.slice(from), cap - head)}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
175
243
|
/**
|
|
176
244
|
* The names a prompt uses: words carrying a capital letter that is not just
|
|
177
245
|
* the capital opening a sentence ("TypeScript", "Hacker", "News", "NASA"; not
|
|
@@ -309,7 +377,9 @@ export class AgentOrchestrator {
|
|
|
309
377
|
usage.charged = 0;
|
|
310
378
|
|
|
311
379
|
const startTime = Date.now();
|
|
312
|
-
const
|
|
380
|
+
const remaining = () => wallClockMs - (Date.now() - startTime);
|
|
381
|
+
const deadline = () => remaining() <= 0;
|
|
382
|
+
const timeLimitReason = `Stopped at the ${wallClockMs} ms time limit; the evidence gathered before it is returned.`;
|
|
313
383
|
|
|
314
384
|
// Hard-cap params regardless of what caller sends
|
|
315
385
|
const capSteps = Math.min(maxSteps, 10);
|
|
@@ -358,7 +428,10 @@ export class AgentOrchestrator {
|
|
|
358
428
|
? `The task asks about a CURRENT live state: the FIRST query must be ONLY the name of the site or thing whose current state is asked, nothing else. `
|
|
359
429
|
: '') +
|
|
360
430
|
`Output ONLY the queries, one per line, no preamble or numbering:\n\n${prompt}`;
|
|
361
|
-
const { text } = await this._getSamplingClient().complete(planPrompt, {
|
|
431
|
+
const { text } = await this._getSamplingClient().complete(planPrompt, {
|
|
432
|
+
maxTokens: 200,
|
|
433
|
+
timeoutMs: Math.min(remaining(), wallClockMs * PLAN_MAX_SHARE)
|
|
434
|
+
});
|
|
362
435
|
const lines = text.split('\n')
|
|
363
436
|
.map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
|
|
364
437
|
// A model that fences its list ("```\nq1\nq2\n```") or quotes each
|
|
@@ -477,8 +550,10 @@ export class AgentOrchestrator {
|
|
|
477
550
|
// queue and first at SHAPE time so synthesis answers from it, never from
|
|
478
551
|
// a dated article. A wrong root self-heals: it fails the relevance gate
|
|
479
552
|
// and never enters evidence.
|
|
553
|
+
let promotedOrigin = null;
|
|
480
554
|
if (currentState && searchResults.length > 0) {
|
|
481
555
|
const originCounts = new Map();
|
|
556
|
+
const official = new Set();
|
|
482
557
|
// Only the bare entity query votes: the fact query added above returns
|
|
483
558
|
// guides and news sites, which must not outvote the official domain.
|
|
484
559
|
const entityQuery = searchQueries[0];
|
|
@@ -487,14 +562,23 @@ export class AgentOrchestrator {
|
|
|
487
562
|
try {
|
|
488
563
|
const origin = new URL(s.url).origin;
|
|
489
564
|
originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
|
|
565
|
+
if (isOfficialResult(entityQuery, s.url, s.title)) official.add(origin);
|
|
490
566
|
} catch { /* unparsable result url */ }
|
|
491
567
|
}
|
|
568
|
+
// The entity's own site wins over any vote count; only when no result
|
|
569
|
+
// is official does the vote decide, and a tied vote decides nothing —
|
|
570
|
+
// with one result per origin it would just promote the top-ranked one.
|
|
492
571
|
let bestOrigin = null;
|
|
493
572
|
let bestCount = 0;
|
|
573
|
+
let tied = false;
|
|
494
574
|
for (const [origin, count] of originCounts) {
|
|
575
|
+
if (official.size > 0 && !official.has(origin)) continue;
|
|
495
576
|
// Strict > keeps the earliest-seen (top-ranked) origin on ties.
|
|
496
|
-
if (count > bestCount) { bestOrigin = origin; bestCount = count; }
|
|
577
|
+
if (count > bestCount) { bestOrigin = origin; bestCount = count; tied = false; }
|
|
578
|
+
else if (count === bestCount) tied = true;
|
|
497
579
|
}
|
|
580
|
+
if (tied && official.size === 0) bestOrigin = null;
|
|
581
|
+
promotedOrigin = bestOrigin;
|
|
498
582
|
if (bestOrigin) {
|
|
499
583
|
const liveRoot = `${bestOrigin}/`;
|
|
500
584
|
if (!priorityUrls.includes(liveRoot)) priorityUrls.push(liveRoot);
|
|
@@ -527,7 +611,7 @@ export class AgentOrchestrator {
|
|
|
527
611
|
let plainError = null;
|
|
528
612
|
let verdict = null;
|
|
529
613
|
try {
|
|
530
|
-
plain = await fetchAndParse(url, { timeoutMs: 10000, errorDocuments: true });
|
|
614
|
+
plain = await fetchAndParse(url, { timeoutMs: Math.max(1, Math.min(10000, remaining())), errorDocuments: true });
|
|
531
615
|
verdict = documentVerdict(
|
|
532
616
|
{ url: plain.finalUrl, title: plain.$('title').first().text().trim(), text: plain.textContent, html: plain.html, status: plain.status },
|
|
533
617
|
{ fetcher: 'a plain fetch', rendered: false, contentReturned: false }
|
|
@@ -622,15 +706,18 @@ export class AgentOrchestrator {
|
|
|
622
706
|
}))
|
|
623
707
|
.sort((a, b) => b._score - a._score);
|
|
624
708
|
const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
|
|
709
|
+
// Question words match any prose ("What is your name?" in a code sample).
|
|
710
|
+
const sliceTerms = promptTerms.map(t => t.replace(/[^\p{L}\p{N}]/gu, ''))
|
|
711
|
+
.filter(t => t.length > 3 && !/^(what|which|when|where|does|that|this|with|from|have|about)$/.test(t));
|
|
625
712
|
const combinedText = orderedEvidence
|
|
626
|
-
.map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${
|
|
713
|
+
.map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${sliceForSynthesis(e.text, perSourceCap, sliceTerms)}`)
|
|
627
714
|
.join('\n\n');
|
|
628
715
|
|
|
629
716
|
if (!combinedText.trim()) {
|
|
630
717
|
return {
|
|
631
718
|
success: true,
|
|
632
719
|
degraded: true,
|
|
633
|
-
reason: 'No content could be fetched for the given prompt.',
|
|
720
|
+
reason: deadline() ? timeLimitReason : 'No content could be fetched for the given prompt.',
|
|
634
721
|
search_results: searchResults,
|
|
635
722
|
evidence: [],
|
|
636
723
|
answer: null,
|
|
@@ -643,15 +730,19 @@ export class AgentOrchestrator {
|
|
|
643
730
|
}
|
|
644
731
|
|
|
645
732
|
// Schema path: use ExtractWithLlm for structured output
|
|
646
|
-
if (schema && Object.keys(schema).length > 0) {
|
|
733
|
+
if (schema && Object.keys(schema).length > 0 && !deadline()) {
|
|
647
734
|
try {
|
|
648
735
|
const extractWithLlm = await this._getExtractWithLlm();
|
|
649
736
|
const result = await extractWithLlm.execute({
|
|
650
737
|
content: combinedText,
|
|
651
738
|
prompt: `From the following research sources, answer this task and extract structured data:\n${prompt}`,
|
|
652
739
|
schema,
|
|
653
|
-
provider: 'auto'
|
|
740
|
+
provider: 'auto',
|
|
741
|
+
signal: AbortSignal.timeout(Math.max(0, remaining()))
|
|
654
742
|
});
|
|
743
|
+
// Out of time: fall through so the time-limit result below carries
|
|
744
|
+
// the full evidence.
|
|
745
|
+
if (!result.success && deadline()) throw new Error(result.error);
|
|
655
746
|
// A required field left null is an incomplete answer, not a
|
|
656
747
|
// structured success (R24, 2026-10-03).
|
|
657
748
|
const missingRequired = result.success && Array.isArray(schema.required)
|
|
@@ -692,6 +783,7 @@ export class AgentOrchestrator {
|
|
|
692
783
|
let synthesisFailed = false;
|
|
693
784
|
|
|
694
785
|
try {
|
|
786
|
+
if (deadline()) throw new Error('time limit reached before synthesis');
|
|
695
787
|
// Wording matters for small local models (llama3.2-class): without the
|
|
696
788
|
// "already fetched / do not refuse" framing they answer "I cannot access
|
|
697
789
|
// the internet" or "the sources do not contain it" even when the answer
|
|
@@ -706,9 +798,13 @@ export class AgentOrchestrator {
|
|
|
706
798
|
`Rules:\n` +
|
|
707
799
|
// Short and imperative on purpose: the executing model is a small
|
|
708
800
|
// local one (gemma3:4b-class) and ignores hedged phrasing.
|
|
709
|
-
|
|
801
|
+
// "The first source is the live page" is only true when GATHER
|
|
802
|
+
// promoted one; otherwise the first source is just the best term match.
|
|
803
|
+
(currentState && promotedOrigin
|
|
710
804
|
? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page); if the FIRST source does not state the answer, take it from the next sources in order. NEVER present older or dated content as the current answer.\n`
|
|
711
|
-
:
|
|
805
|
+
: currentState
|
|
806
|
+
? `- The task asks about the CURRENT state. Prefer the subject's own official site over third-party articles. NEVER present older or dated content as the current answer.\n`
|
|
807
|
+
: '') +
|
|
712
808
|
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
713
809
|
`- Read the sources carefully before concluding anything is missing from them.\n` +
|
|
714
810
|
`- Cite the exact source URL(s) you used.\n` +
|
|
@@ -716,7 +812,7 @@ export class AgentOrchestrator {
|
|
|
716
812
|
`- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
|
|
717
813
|
`Provide a clear, concise answer.`;
|
|
718
814
|
|
|
719
|
-
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
|
|
815
|
+
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
720
816
|
answer = text;
|
|
721
817
|
|
|
722
818
|
// A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
|
|
@@ -725,14 +821,14 @@ export class AgentOrchestrator {
|
|
|
725
821
|
// the source text; what does not gets one corrective rewrite, and
|
|
726
822
|
// whatever still remains is named in the answer and in `provenance`.
|
|
727
823
|
unverified = unverifiedValues(answer, combinedText);
|
|
728
|
-
if (unverified.length > 0) {
|
|
824
|
+
if (unverified.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
|
|
729
825
|
const retryPrompt =
|
|
730
826
|
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
731
827
|
`It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` +
|
|
732
828
|
`Rewrite the answer. Keep only values that the sources state, spelled as the sources spell them. ` +
|
|
733
829
|
`Where the sources do not state a value, say that it is not stated instead of guessing.`;
|
|
734
830
|
try {
|
|
735
|
-
const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024 });
|
|
831
|
+
const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
736
832
|
if (rewritten && rewritten.trim()) {
|
|
737
833
|
answer = rewritten;
|
|
738
834
|
unverified = unverifiedValues(answer, combinedText);
|
|
@@ -756,7 +852,7 @@ export class AgentOrchestrator {
|
|
|
756
852
|
// presented as the answer.
|
|
757
853
|
if (currentState && isVersionQuestion(prompt)) {
|
|
758
854
|
forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
|
|
759
|
-
if (forumOnlyVersions.length > 0) {
|
|
855
|
+
if (forumOnlyVersions.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
|
|
760
856
|
const versionRetryPrompt =
|
|
761
857
|
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
762
858
|
`It states ${forumOnlyVersions.map(v => `"${v}"`).join(', ')}, which ${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} ` +
|
|
@@ -765,7 +861,7 @@ export class AgentOrchestrator {
|
|
|
765
861
|
`Rewrite the answer using only versions stated by the project's own sources. ` +
|
|
766
862
|
`If none of them states a current version, say plainly that the fetched sources do not state one.`;
|
|
767
863
|
try {
|
|
768
|
-
const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024 });
|
|
864
|
+
const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
769
865
|
if (rewritten && rewritten.trim()) {
|
|
770
866
|
answer = rewritten;
|
|
771
867
|
forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
|
|
@@ -786,10 +882,36 @@ export class AgentOrchestrator {
|
|
|
786
882
|
`only in discussion pages. The authoritative pages fetched state no version.`;
|
|
787
883
|
}
|
|
788
884
|
}
|
|
885
|
+
|
|
886
|
+
// A promoted live page is only worth its +1000 if the answer came from
|
|
887
|
+
// it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
|
|
888
|
+
// Main Page, and the HN question from thehackernews.com, both with
|
|
889
|
+
// degraded:false. The answer counts as the live page's when it cites
|
|
890
|
+
// that host or states a value (version, date, number) the page shows.
|
|
891
|
+
if (currentState && promotedOrigin && !degraded) {
|
|
892
|
+
const host = u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
|
|
893
|
+
const liveHost = host(promotedOrigin);
|
|
894
|
+
const liveText = evidence.filter(e => host(e.url) === liveHost).map(e => e.text || '').join('\n');
|
|
895
|
+
// unverifiedValues against an empty text lists every value the answer states.
|
|
896
|
+
const stated = unverifiedValues(answer, '');
|
|
897
|
+
const fromLivePage = answer.toLowerCase().includes(liveHost) ||
|
|
898
|
+
(liveText !== '' && unverifiedValues(answer, liveText).length < stated.length);
|
|
899
|
+
if (!fromLivePage) {
|
|
900
|
+
degraded = true;
|
|
901
|
+
degradedReason =
|
|
902
|
+
`The answer does not come from the live page ${promotedOrigin}/: ` +
|
|
903
|
+
(liveText
|
|
904
|
+
? 'it neither cites that page nor states a value shown on it.'
|
|
905
|
+
: 'that page could not be fetched or did not match the task.') +
|
|
906
|
+
' It may describe a dated or third-party source rather than the current state.';
|
|
907
|
+
}
|
|
908
|
+
}
|
|
789
909
|
} catch (err) {
|
|
790
910
|
degraded = true;
|
|
791
911
|
synthesisFailed = true;
|
|
792
|
-
degradedReason =
|
|
912
|
+
degradedReason = err.code === 'LLM_TIMEOUT' || deadline()
|
|
913
|
+
? timeLimitReason
|
|
914
|
+
: `LLM synthesis unavailable: ${err.message}`;
|
|
793
915
|
// Return raw evidence so the host LLM can synthesize
|
|
794
916
|
answer = null;
|
|
795
917
|
}
|
package/src/core/AuthManager.js
CHANGED
|
@@ -758,7 +758,10 @@ class AuthManager {
|
|
|
758
758
|
break;
|
|
759
759
|
}
|
|
760
760
|
case 'extract_with_llm':
|
|
761
|
-
|
|
761
|
+
// Only the cloud providers bill you; the default runs on local Ollama.
|
|
762
|
+
if (params?.provider === 'openai' || params?.provider === 'anthropic') {
|
|
763
|
+
note = 'External LLM API call billed by your LLM provider, separate from the credit cost.';
|
|
764
|
+
}
|
|
762
765
|
break;
|
|
763
766
|
case 'stealth_mode':
|
|
764
767
|
note = projected === 1
|
|
@@ -236,7 +236,9 @@ export class LLMsTxtAnalyzer {
|
|
|
236
236
|
const batch = commonPaths.slice(i, i + PROBE_CONCURRENCY);
|
|
237
237
|
const results = await Promise.allSettled(
|
|
238
238
|
batch.map(async (path) => {
|
|
239
|
-
|
|
239
|
+
// Resolved, not concatenated: the URL arrives as typed, and
|
|
240
|
+
// "https://site.com/" + "/json" was listed as "https://site.com//json" (R25 1.8).
|
|
241
|
+
const apiUrl = new URL(path, baseUrl).toString();
|
|
240
242
|
const response = await this.fetchWithTimeout(apiUrl, { timeout: 5000 });
|
|
241
243
|
if (response.ok) {
|
|
242
244
|
const contentType = response.headers.get('content-type') || '';
|
|
@@ -367,7 +369,7 @@ export class LLMsTxtAnalyzer {
|
|
|
367
369
|
const batch = sensitivePaths.slice(i, i + PROBE_CONCURRENCY);
|
|
368
370
|
const results = await Promise.allSettled(
|
|
369
371
|
batch.map(async (path) => {
|
|
370
|
-
const testUrl =
|
|
372
|
+
const testUrl = new URL(path, baseUrl).toString();
|
|
371
373
|
const response = await this.fetchWithTimeout(testUrl, { timeout: 3000 });
|
|
372
374
|
if (response.status === 200 || response.status === 302 || response.status === 401) {
|
|
373
375
|
return {
|
|
@@ -571,7 +573,6 @@ export class LLMsTxtAnalyzer {
|
|
|
571
573
|
*/
|
|
572
574
|
prioritizeUrls(urls, baseUrl, limit) {
|
|
573
575
|
const sections = new Map();
|
|
574
|
-
let sawAny = false;
|
|
575
576
|
|
|
576
577
|
for (const url of urls) {
|
|
577
578
|
let segments;
|
|
@@ -580,7 +581,6 @@ export class LLMsTxtAnalyzer {
|
|
|
580
581
|
} catch {
|
|
581
582
|
continue;
|
|
582
583
|
}
|
|
583
|
-
sawAny = true;
|
|
584
584
|
// The root arrives as either "https://site.com" or "https://site.com/"
|
|
585
585
|
// (normalizeUrl strips a trailing slash from every path but "/"), so it
|
|
586
586
|
// is recognised by its path and re-added below in one canonical form.
|
|
@@ -590,10 +590,9 @@ export class LLMsTxtAnalyzer {
|
|
|
590
590
|
sections.get(section).push({ url, depth: segments.length });
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
-
if (!sawAny) return [];
|
|
594
|
-
|
|
595
593
|
// The site root leads: it is always fetched, and a site guide that omits
|
|
596
|
-
// the homepage is a misleading one.
|
|
594
|
+
// the homepage is a misleading one. That holds with no other candidate
|
|
595
|
+
// too: a one-page site was analysed as 0 pages (R25 1.2).
|
|
597
596
|
const ordered = [`${baseUrl}/`];
|
|
598
597
|
const queues = [...sections.keys()].sort().map((name) =>
|
|
599
598
|
sections.get(name).sort((a, b) => a.depth - b.depth || a.url.localeCompare(b.url)));
|
|
@@ -778,15 +778,40 @@ export class LocalizationManager extends EventEmitter {
|
|
|
778
778
|
}
|
|
779
779
|
|
|
780
780
|
getFirstDayOfWeek(countryCode) {
|
|
781
|
-
// 0 = Sunday, 1 = Monday
|
|
781
|
+
// 0 = Sunday, 1 = Monday. From CLDR 48 supplemental weekData "firstDay";
|
|
782
|
+
// covers every SUPPORTED_COUNTRIES entry. The fallback is CLDR's world
|
|
783
|
+
// default (Monday). `??`, not `||`: Sunday is 0 (R25).
|
|
782
784
|
const firstDays = {
|
|
783
785
|
'US': 0,
|
|
784
786
|
'GB': 1,
|
|
785
787
|
'DE': 1,
|
|
786
|
-
'FR': 1
|
|
788
|
+
'FR': 1,
|
|
789
|
+
'JP': 0,
|
|
790
|
+
'CN': 1,
|
|
791
|
+
'AU': 1,
|
|
792
|
+
'CA': 0,
|
|
793
|
+
'IT': 1,
|
|
794
|
+
'ES': 1,
|
|
795
|
+
'RU': 1,
|
|
796
|
+
'BR': 0,
|
|
797
|
+
'IN': 0,
|
|
798
|
+
'KR': 0,
|
|
799
|
+
'MX': 0,
|
|
800
|
+
'NL': 1,
|
|
801
|
+
'SE': 1,
|
|
802
|
+
'NO': 1,
|
|
803
|
+
'SA': 0,
|
|
804
|
+
'AE': 1,
|
|
805
|
+
'TR': 1,
|
|
806
|
+
'IL': 0,
|
|
807
|
+
'TH': 0,
|
|
808
|
+
'SG': 0,
|
|
809
|
+
'PL': 1,
|
|
810
|
+
'ZA': 0,
|
|
811
|
+
'CH': 1
|
|
787
812
|
};
|
|
788
|
-
|
|
789
|
-
return firstDays[countryCode]
|
|
813
|
+
|
|
814
|
+
return firstDays[countryCode] ?? 1;
|
|
790
815
|
}
|
|
791
816
|
|
|
792
817
|
generateScreenProperties(countryCode) {
|