crawlforge-mcp-server 5.4.3 → 5.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.4.3",
3
+ "version": "5.5.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 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",
@@ -114,7 +114,7 @@
114
114
  "cheerio": "^1.1.2",
115
115
  "commander": "^14.0.3",
116
116
  "compromise": "^14.14.4",
117
- "crawlforge-extractors": "^1.4.1",
117
+ "crawlforge-extractors": "^1.5.0",
118
118
  "diff": "^9.0.0",
119
119
  "dotenv": "^17.2.1",
120
120
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -105,7 +105,7 @@ const taskStore = createTaskStore({ logger });
105
105
  // Create the server
106
106
  const server = new McpServer({
107
107
  name: "crawlforge",
108
- version: "5.4.3",
108
+ version: "5.5.0",
109
109
  description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, 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.",
110
110
  homepage: "https://www.crawlforge.dev",
111
111
  icon: "https://www.crawlforge.dev/icon.png",
@@ -492,7 +492,7 @@ registerToolIfEnabled("serp_rank", {
492
492
 
493
493
  // Tool: reddit_search — search Reddit posts/comments or read a full thread (via community archives)
494
494
  registerToolIfEnabled("reddit_search", {
495
- description: "Use this to search Reddit posts or comments, or read a full comment thread — reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search has no backend — scope it or search posts. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
495
+ description: "Use this to search Reddit posts or comments, or read a full comment thread — reddit.com blocks direct scraping, so this reads the Arctic Shift community archive instead (free, no Reddit credentials). Modes: 'posts' (default) and 'comments' search; 'thread' returns a post plus its nested comment tree by link_id. A subreddit/author-scoped search queries the archive directly. A keyword search across ALL of Reddit finds posts with a site-restricted web search and then reads those posts from the archive, because Arctic Shift can only keyword-search within a scope; results come back as real archive rows, ordered by search relevance. An unscoped COMMENT search discovers posts the same way and then searches each post's comments for the keywords. A scoped comment search Arctic Shift times out on is retried over narrower windows (7d, 3d, 1d) and reports window_applied. Example: reddit_search({query: \"best mechanical keyboard\", subreddit: \"MechanicalKeyboards\", limit: 10})",
496
496
  annotations: { title: "Reddit Search", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
497
497
  inputSchema: {
498
498
  query: z.string().optional().describe("Keyword search. Posts: matches title+selftext; comments: matches body. Supports \"quoted phrases\", OR, -exclusion"),
@@ -504,7 +504,7 @@ registerToolIfEnabled("reddit_search", {
504
504
  before: z.string().optional().describe("Only content posted before this date — same formats as after"),
505
505
  limit: z.number().min(1).max(100).optional().describe("Max results (default 25; thread mode: max comments returned)"),
506
506
  sort: z.enum(["asc", "desc"]).optional().describe("Sort by post date (default desc = newest first)"),
507
- source: z.enum(["auto", "arctic_shift", "pullpush", "reddit_api"]).optional().describe("Backend: auto routes + falls back (default). reddit_api uses the official Reddit Data API — only when REDDIT_CLIENT_ID/REDDIT_CLIENT_SECRET are set; serves posts/thread, not comment search")
507
+ source: z.enum(["auto", "arctic_shift", "pullpush", "reddit_api", "web_discovery"]).optional().describe("Backend: auto routes + falls back (default). web_discovery serves only unscoped keyword searches (web search finds the posts, the archive supplies the rows). reddit_api uses the official Reddit Data API — only when REDDIT_CLIENT_ID/REDDIT_CLIENT_SECRET are set; serves posts/thread, not comment search")
508
508
  },
509
509
  outputSchema: OUTPUT_SCHEMAS.reddit_search
510
510
  }, withAuth("reddit_search", async ({ query, subreddit, author, mode, link_id, after, before, limit, sort, source }) => {
@@ -977,7 +977,7 @@ if (toolFilter.isEnabled("deep_research")) {
977
977
  maxVariations: z.number().min(1).max(20).optional().default(8)
978
978
  }).optional().describe("Query expansion settings for broader search coverage"),
979
979
  llmConfig: z.object({
980
- provider: z.enum(['auto', 'openai', 'anthropic']).optional().default('auto'),
980
+ provider: z.enum(['auto', 'openai', 'anthropic', 'ollama']).optional().default('auto'),
981
981
  openai: z.object({
982
982
  apiKey: z.string().optional(),
983
983
  model: z.string().optional().default('gpt-3.5-turbo'),
@@ -987,9 +987,13 @@ if (toolFilter.isEnabled("deep_research")) {
987
987
  apiKey: z.string().optional(),
988
988
  model: z.string().optional().default('claude-3-haiku-20240307')
989
989
  }).optional(),
990
+ ollama: z.object({
991
+ model: z.string().optional(),
992
+ embeddingModel: z.string().optional()
993
+ }).optional(),
990
994
  enableSemanticAnalysis: z.boolean().optional().default(true),
991
995
  enableIntelligentSynthesis: z.boolean().optional().default(true)
992
- }).optional().describe("LLM provider configuration for AI-powered analysis"),
996
+ }).optional().describe("LLM provider configuration for AI-powered analysis. provider 'auto' (default) uses a configured cloud key if there is one, else the local Ollama (http://localhost:11434, no key); 'ollama' forces the local model; 'openai'/'anthropic' need the matching API key"),
993
997
  concurrency: z.number().min(1).max(20).optional().default(5).describe("Number of concurrent research requests"),
994
998
  cacheResults: z.boolean().optional().default(true).describe("Cache research results for reuse"),
995
999
  webhook: z.object({
@@ -1534,10 +1538,30 @@ registerToolIfEnabled("localization", {
1534
1538
  if (!params.countryCode) throw new Error('countryCode is required for configure_country operation');
1535
1539
  result = await localizationManager.configureCountry(params.countryCode, params);
1536
1540
  break;
1537
- case 'localize_search':
1541
+ case 'localize_search': {
1538
1542
  if (!params.searchParams) throw new Error('searchParams is required for localize_search operation');
1539
- result = await localizationManager.localizeSearchQuery(params.searchParams, params.countryCode);
1543
+ const localizedParams = await localizationManager.localizeSearchQuery(params.searchParams, params.countryCode);
1544
+ if (!params.searchParams.query) {
1545
+ result = {
1546
+ ...localizedParams,
1547
+ note: 'No searchParams.query was given, so no search ran — these are the parameters a localized search would use.'
1548
+ };
1549
+ break;
1550
+ }
1551
+ // Run the search the caller asked for under the localized country and
1552
+ // language, so the operation returns results rather than a config.
1553
+ // Priced as a search_web call (AuthManager.getToolCost).
1554
+ const countryCode = params.countryCode || localizationManager.getCurrentSettings().countryCode;
1555
+ const search = await searchWebTool.execute({
1556
+ query: params.searchParams.query,
1557
+ limit: params.searchParams.limit,
1558
+ offset: params.searchParams.offset,
1559
+ lang: localizedParams.lang,
1560
+ localization: { countryCode, language: params.language || localizedParams.lang }
1561
+ });
1562
+ result = { localizedParams, search };
1540
1563
  break;
1564
+ }
1541
1565
  case 'localize_browser':
1542
1566
  if (!params.browserOptions) throw new Error('browserOptions is required for localize_browser operation');
1543
1567
  result = await localizationManager.localizeBrowserContext(params.browserOptions, params.countryCode);
@@ -1578,7 +1602,7 @@ registerToolIfEnabled("localization", {
1578
1602
 
1579
1603
  // Tool: scrape_template (D3.3 — pre-built site templates)
1580
1604
  registerToolIfEnabled("scrape_template", {
1581
- description: "Use this when you want structured data from a well-known site or platform API without writing custom selectors. Three modes: a template id with a url (scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})); template:\"auto\" with a url, which picks the template from the URL and names its choice in the response; or template:\"list\" to enumerate every template with the URLs it handles. Page templates return one record — e-commerce, social, developer and news sites (shopify-product, amazon-product, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package, linkedin-profile). List connectors return N records from one call and are driven by params instead of a url: job boards (Greenhouse, Lever, Ashby, Workable, Recruitee, Teamtailor) return a company's whole careers board, US government APIs (NHTSA VIN decode, NPI provider registry) answer keyless lookups, and shopify-collection returns a whole collection. Example: scrape_template({template:\"greenhouse-jobs\", params:{company:\"stripe\"}})",
1605
+ description: "Use this when you want structured data from a well-known site or platform API without writing custom selectors. Three modes: a template id with a url (scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})); template:\"auto\" with a url, which picks the template from the URL and names its choice in the response; or template:\"list\" to enumerate every template with the URLs it handles. Page templates return one record — e-commerce, social, developer and news sites (shopify-product, amazon-product, github-repo, youtube-video, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package; reddit-thread reads the post from the Arctic Shift archive and reddit_search reads the comment tree). linkedin-profile and tweet are retired — those sites' robots.txt disallow every keyless path — and naming one returns the reason. List connectors return N records from one call and are driven by params instead of a url: job boards (Greenhouse, Lever, Ashby, Workable, Recruitee, Teamtailor) return a company's whole careers board, US government APIs (NHTSA VIN decode, NPI provider registry) answer keyless lookups, and shopify-collection returns a whole collection. Example: scrape_template({template:\"greenhouse-jobs\", params:{company:\"stripe\"}})",
1582
1606
  annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1583
1607
  inputSchema: {
1584
1608
  template: z.string().describe("Template ID (e.g. github-repo), \"auto\" to detect one from the url, or \"list\" to enumerate available templates"),
@@ -612,6 +612,12 @@ class AuthManager {
612
612
  if (bookkeepingOps.has(params?.operation)) return 1;
613
613
  }
614
614
 
615
+ // localize_search with a query runs a real web search through the same
616
+ // adapter search_web uses, so it is priced as one rather than undercutting it.
617
+ if (tool === 'localization' && params?.operation === 'localize_search' && params?.searchParams?.query) {
618
+ return costs.search_web;
619
+ }
620
+
615
621
  return costs[tool] ?? 1;
616
622
  }
617
623
 
@@ -189,6 +189,8 @@ const redditSearchShape = {
189
189
  count: z.number().optional(),
190
190
  results: z.array(z.union([redditPostShape, redditCommentShape])).optional().describe('posts/comments modes'),
191
191
  discovered: z.number().optional().describe('web_discovery: how many post ids the site-restricted web search surfaced before archive hydration'),
192
+ posts_searched: z.number().optional().describe('web_discovery comments mode: how many discovered posts had their comments searched before limit was reached'),
193
+ window_applied: z.string().optional().describe('arctic_shift comments mode: the after-window ("7d"/"3d"/"1d") the search was narrowed to after the full-history search timed out; absent when the caller set after or no narrowing was needed'),
192
194
  post: redditPostShape.nullable().optional().describe('thread mode: the post itself'),
193
195
  comments: z.array(z.unknown()).optional().describe('thread mode: nested comment tree ({...comment, replies:[...]}); collapsed branches appear as {more_count, more_ids}'),
194
196
  comment_count: z.number().optional(),
@@ -53,9 +53,11 @@ source and a ready-to-paste path.
53
53
  ```
54
54
 
55
55
  Pass `template:"list"` to enumerate templates. Supports `shopify-product`,
56
- `amazon-product`, `linkedin-profile`, `github-repo`, `youtube-video`, `tweet`,
57
- `reddit-thread`, `hacker-news-front-page`, `producthunt-launch`,
58
- `stackoverflow-question`, `npm-package`. Prefer `shopify-product` over scraping
56
+ `amazon-product`, `github-repo`, `youtube-video`, `reddit-thread` (the post,
57
+ from the Arctic Shift archive; `reddit_search` reads the comment tree),
58
+ `hacker-news-front-page`, `producthunt-launch`, `stackoverflow-question`,
59
+ `npm-package`. `linkedin-profile` and `tweet` are retired — those sites'
60
+ robots.txt disallow every keyless path. Prefer `shopify-product` over scraping
59
61
  or LLM extraction on any Shopify storefront — it reads the store's own JSON, so
60
62
  prices and stock cannot be misread. Full field lists: [templates](references/templates.md).
61
63
  CLI: `crawlforge template github-repo https://github.com/owner/repo`.
@@ -8,16 +8,21 @@ to enumerate at runtime. Cost: 1 credit per call.
8
8
  |-------------|--------|-------------------------|
9
9
  | `shopify-product` | Any Shopify storefront product page (`/products/<handle>`), custom domains included | title, price, compare-at price, on_sale, currency, price range, per-variant stock, options, images, tags — read from the store's `/products/<handle>.json`, not the rendered page |
10
10
  | `amazon-product` | Amazon product page (`/dp/...`) | title, price, currency, rating (number), review count (number), brand, ASIN, availability, full-size images, description, breadcrumbs. Breadcrumbs are empty on device pages, which genuinely have none |
11
- | `linkedin-profile` | LinkedIn public profile | name, headline, location, current role, about, experience |
12
11
  | `github-repo` | GitHub repository | name, owner, description, stars, forks, language, topics, README excerpt |
13
12
  | `youtube-video` | YouTube watch page | title, channel, views, likes, published date, description |
14
- | `tweet` | A single tweet/X post | author, handle, text, timestamp, likes, reposts |
15
- | `reddit-thread` | Reddit thread | title, subreddit, author, score, top comments |
13
+ | `reddit-thread` | Reddit post (read from the Arctic Shift archive, not reddit.com, which blocks plain fetchers) | id, title, subreddit, author, score, upvote_ratio, num_comments, posted, body, link_url, flair, removed. Pass `id` to `reddit_search` with `mode:"thread"` for the comment tree |
16
14
  | `hacker-news-front-page` | HN front page | ranked stories: title, URL, points, author, comment count |
17
15
  | `producthunt-launch` | Product Hunt launch | name, tagline, upvotes, maker, description |
18
16
  | `stackoverflow-question` | Stack Overflow question (read from the keyless Stack Exchange API, not the page) | title, body, votes, views, tags, author, asked, answered, accepted_answer_id, answer_count, top 5 answers (accepted first: votes, author, posted, body) |
19
17
  | `npm-package` | npm package page | name, version, description, weekly downloads, license, repo link |
20
18
 
19
+ **Retired (2026-08-30):** `linkedin-profile` and `tweet`. LinkedIn's robots.txt
20
+ disallows every path for all agents but its own crawler and profiles sit behind
21
+ an auth wall; X's robots.txt disallows every path for generic agents and its
22
+ keyless embed endpoints are disallowed by their own robots.txt. Naming either
23
+ returns the reason and fetches nothing. For X or LinkedIn data, use the
24
+ platform's own API with the user's credentials.
25
+
21
26
  ## Usage
22
27
 
23
28
  ```json
@@ -49,7 +49,7 @@ const DeepResearchSchema = z.object({
49
49
 
50
50
  // LLM Configuration
51
51
  llmConfig: z.object({
52
- provider: z.enum(['auto', 'openai', 'anthropic']).optional().default('auto'),
52
+ provider: z.enum(['auto', 'openai', 'anthropic', 'ollama']).optional().default('auto'),
53
53
  openai: z.object({
54
54
  apiKey: z.string().optional(),
55
55
  model: z.string().optional().default('gpt-3.5-turbo'),
@@ -59,6 +59,10 @@ const DeepResearchSchema = z.object({
59
59
  apiKey: z.string().optional(),
60
60
  model: z.string().optional().default('claude-3-haiku-20240307')
61
61
  }).optional(),
62
+ ollama: z.object({
63
+ model: z.string().optional(),
64
+ embeddingModel: z.string().optional()
65
+ }).optional(),
62
66
  enableSemanticAnalysis: z.boolean().optional().default(true),
63
67
  enableIntelligentSynthesis: z.boolean().optional().default(true)
64
68
  }).optional(),
@@ -265,9 +269,23 @@ export class DeepResearchTool {
265
269
  ...baseConfig.searchConfig
266
270
  };
267
271
 
268
- // Add LLM configuration if provided
272
+ // Add LLM configuration if provided. LLMManager selects on
273
+ // `defaultProvider`; the schema's `provider` was never mapped onto it, so
274
+ // an explicit choice was silently ignored and 'auto' always ran.
269
275
  if (params.llmConfig) {
270
- baseConfig.llmConfig = params.llmConfig;
276
+ const { provider = 'auto', ...llmConfig } = params.llmConfig;
277
+ const cloudKey = {
278
+ openai: llmConfig.openai?.apiKey || process.env.OPENAI_API_KEY,
279
+ anthropic: llmConfig.anthropic?.apiKey || process.env.ANTHROPIC_API_KEY
280
+ };
281
+ if (provider in cloudKey && !cloudKey[provider]) {
282
+ throw new Error(
283
+ `llmConfig.provider "${provider}" needs an API key: pass llmConfig.${provider}.apiKey ` +
284
+ `or set ${provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'}. ` +
285
+ 'Omit provider (or pass "ollama") to use the local Ollama, which needs no key.'
286
+ );
287
+ }
288
+ baseConfig.llmConfig = { ...llmConfig, defaultProvider: provider };
271
289
  }
272
290
 
273
291
  // params.cacheResults was previously dropped entirely — only the
@@ -15,9 +15,11 @@
15
15
  * cross-subreddit full-text search, but has known post-2023 archive gaps
16
16
  * and recurring outages.
17
17
  *
18
- * Routing: scoped searches go to Arctic Shift (fresher) with PullPush as an
19
- * error-only fallback; unscoped keyword searches can only go to PullPush.
20
- * Both services are free and need no credentials.
18
+ * Routing: scoped searches go to Arctic Shift. An unscoped keyword search
19
+ * finds posts through a site-restricted web search and then reads them — or
20
+ * searches their comments — in the archive by id. PullPush stopped serving
21
+ * automated clients in August 2026 and is used only on explicit request.
22
+ * Both archives are free and need no credentials.
21
23
  *
22
24
  * Optional official-API path: if the user sets REDDIT_CLIENT_ID and
23
25
  * REDDIT_CLIENT_SECRET (their own Reddit app), posts/thread requests can read
@@ -148,14 +150,15 @@ export class RedditSearchTool {
148
150
  // comment full-text search, so `comments` mode always uses the archives.
149
151
  const officialPossible = this.officialConfigured && (v.mode === 'thread' || v.mode === 'posts');
150
152
 
151
- // A Reddit-wide keyword search for posts can be discovered through a web
152
- // search and then read out of the archive by ID.
153
- const discoveryPossible = v.mode === 'posts' && Boolean(v.query) && !subreddit && !author;
153
+ // A Reddit-wide keyword search can be discovered through a web search and
154
+ // then read out of the archive by ID — posts directly, comments by
155
+ // searching each discovered post's comments for the keywords.
156
+ const discoveryPossible = v.mode !== 'thread' && Boolean(v.query) && !scoped;
154
157
 
155
158
  let order; // backends to try, in order
156
159
  if (v.source === 'web_discovery') {
157
160
  if (!discoveryPossible) {
158
- throw new Error('web_discovery only serves unscoped keyword searches in posts mode — it finds posts through a web search and then reads them from the archive');
161
+ throw new Error('web_discovery only serves unscoped keyword searches (posts or comments) — it finds posts through a web search and then reads them from the archive');
159
162
  }
160
163
  order = ['web_discovery'];
161
164
  } else if (v.source === 'reddit_api') {
@@ -187,14 +190,6 @@ export class RedditSearchTool {
187
190
  order = officialPossible ? ['reddit_api', ...archives] : archives;
188
191
  }
189
192
 
190
- if (order.length === 0) {
191
- // Unscoped comment search was PullPush-only, and PullPush no longer
192
- // serves automated clients. Web discovery identifies posts, not comments.
193
- throw new Error(
194
- `An unscoped keyword search of ${v.mode} has no available backend: Arctic Shift requires a subreddit or author scope, and PullPush no longer serves automated clients. Add a subreddit or author filter, or search mode:"posts", which finds posts through a web search and reads them from the archive.`
195
- );
196
- }
197
-
198
193
  const errors = [];
199
194
  for (const source of order) {
200
195
  try {
@@ -226,11 +221,12 @@ export class RedditSearchTool {
226
221
 
227
222
  /**
228
223
  * Reddit-wide keyword search, in two steps: find matching posts with a
229
- * site-restricted web search, then read those posts out of the Arctic Shift
230
- * archive by ID. Arctic Shift cannot keyword-search across all of Reddit and
231
- * reddit.com blocks scrapers, so discovery has to come from somewhere else —
232
- * but what comes back are real archive rows, the same shape a scoped search
233
- * returns, not scraped search-engine snippets.
224
+ * site-restricted web search, then read them out of the Arctic Shift archive
225
+ * by ID — the posts themselves, or (comments mode) each post's comments that
226
+ * match the keywords. Arctic Shift cannot keyword-search across all of
227
+ * Reddit and reddit.com blocks scrapers, so discovery has to come from
228
+ * somewhere else — but what comes back are real archive rows, the same shape
229
+ * a scoped search returns, not scraped search-engine snippets.
234
230
  */
235
231
  async #searchWebDiscovery(v) {
236
232
  let found;
@@ -252,7 +248,9 @@ export class RedditSearchTool {
252
248
  }
253
249
 
254
250
  const notes = [
255
- 'Reddit-wide keyword search: posts were found with a site-restricted web search, then read from the Arctic Shift archive by ID.',
251
+ v.mode === 'comments'
252
+ ? 'Reddit-wide comment search: posts were found with a site-restricted web search, then each post\'s comments were searched for the keywords in the Arctic Shift archive, in post relevance order.'
253
+ : 'Reddit-wide keyword search: posts were found with a site-restricted web search, then read from the Arctic Shift archive by ID.',
256
254
  'Results are ordered by web-search relevance, not by score or date.',
257
255
  'Arctic Shift cannot keyword-search across all of Reddit, and PullPush no longer serves automated clients — scope the search to a subreddit or author to query the archive directly.',
258
256
  ];
@@ -262,11 +260,47 @@ export class RedditSearchTool {
262
260
  notes.push('after/before were NOT applied: discovery runs through a web search, which cannot filter by post date. Scope the search to a subreddit or author to use date filters.');
263
261
  }
264
262
 
263
+ const envelope = {
264
+ source: 'web_discovery', mode: v.mode,
265
+ query: v.query ?? null, subreddit: null, author: null,
266
+ };
265
267
  if (ids.length === 0) {
268
+ return { ...envelope, count: 0, results: [], notes, checkedAt: new Date().toISOString() };
269
+ }
270
+
271
+ if (v.mode === 'comments') {
272
+ // Arctic Shift's body search needs a scope, and link_id is one: search
273
+ // the discovered posts in relevance order until `limit` comments are in
274
+ // hand. A post whose search fails is skipped and counted, so one
275
+ // throttled request does not discard the others' results.
276
+ const results = [];
277
+ const skipped = [];
278
+ let postsSearched = 0;
279
+ for (const id of ids) {
280
+ if (results.length >= v.limit) break;
281
+ try {
282
+ const data = await this.#get(`${this.arcticBaseUrl}/api/comments/search`, {
283
+ link_id: id,
284
+ body: v.query,
285
+ limit: String(v.limit - results.length),
286
+ sort: v.sort,
287
+ });
288
+ postsSearched++;
289
+ const rows = Array.isArray(data.data) ? data.data : [];
290
+ results.push(...rows.map(normalizeComment));
291
+ } catch (error) {
292
+ skipped.push(`${id}: ${error.message}`);
293
+ }
294
+ }
295
+ if (postsSearched === 0) {
296
+ throw new Error(`comment search failed for every discovered post — ${skipped.join('; ')}`);
297
+ }
298
+ if (skipped.length > 0) {
299
+ notes.push(`${skipped.length} discovered post(s) could not be searched: ${skipped.join('; ')}`);
300
+ }
266
301
  return {
267
- source: 'web_discovery', mode: 'posts',
268
- query: v.query ?? null, subreddit: null, author: null,
269
- count: 0, results: [],
302
+ ...envelope, count: results.length, results,
303
+ discovered: ids.length, posts_searched: postsSearched,
270
304
  notes, checkedAt: new Date().toISOString(),
271
305
  };
272
306
  }
@@ -280,9 +314,7 @@ export class RedditSearchTool {
280
314
  const ordered = ids.map(id => byId.get(id)).filter(Boolean);
281
315
 
282
316
  return {
283
- source: 'web_discovery', mode: 'posts',
284
- query: v.query ?? null, subreddit: null, author: null,
285
- count: ordered.length, results: ordered.map(normalizePost),
317
+ ...envelope, count: ordered.length, results: ordered.map(normalizePost),
286
318
  discovered: ids.length,
287
319
  notes, checkedAt: new Date().toISOString(),
288
320
  };
@@ -327,13 +359,45 @@ export class RedditSearchTool {
327
359
  if (v.query) query.body = v.query;
328
360
  if (v.link_id) query.link_id = stripIdPrefix(v.link_id);
329
361
  }
330
- const data = await this.#get(`${this.arcticBaseUrl}${path}`, query);
362
+ let data;
363
+ let windowApplied = null;
364
+ try {
365
+ data = await this.#get(`${this.arcticBaseUrl}${path}`, query);
366
+ } catch (error) {
367
+ // Arctic Shift sheds a scoped comment keyword search over its whole
368
+ // history with the same 422 "Timeout. Maybe slow down a bit" it uses for
369
+ // throttling (observed live 2026-08-30: r/webdev body=react failed
370
+ // unbounded and at 7d, answered at 3d). When the caller set no window,
371
+ // narrow it step by step rather than hand the caller the 422.
372
+ const narrowable = v.mode === 'comments' && Boolean(v.query) && !v.after;
373
+ if (!error.retryable || !narrowable) throw error;
374
+ let lastError = error;
375
+ for (const window of ['7d', '3d', '1d']) {
376
+ await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
377
+ try {
378
+ data = await this.#getOnce(`${this.arcticBaseUrl}${path}?${new URLSearchParams({ ...query, after: window })}`);
379
+ windowApplied = window;
380
+ break;
381
+ } catch (retryError) {
382
+ lastError = retryError;
383
+ if (!retryError.retryable) throw retryError;
384
+ }
385
+ }
386
+ if (!data) {
387
+ throw new Error(
388
+ `${lastError.message} — Arctic Shift timed out at every window (full history, 7d, 3d, 1d), ` +
389
+ 'so its comment search is under load right now. Retry later, pass a narrower after/before, or scope to one post with link_id.'
390
+ );
391
+ }
392
+ notes.push(`Arctic Shift timed out searching the full history; results are limited to the last ${windowApplied}. Pass after/before to choose the window.`);
393
+ }
331
394
  const rows = Array.isArray(data.data) ? data.data : [];
332
395
  const results = v.mode === 'posts' ? rows.map(normalizePost) : rows.map(normalizeComment);
333
396
  return {
334
397
  source: 'arctic_shift', mode: v.mode,
335
398
  query: v.query ?? null, subreddit: subreddit ?? null, author: author ?? null,
336
399
  count: results.length, results,
400
+ ...(windowApplied && { window_applied: windowApplied }),
337
401
  notes, checkedAt: new Date().toISOString(),
338
402
  };
339
403
  }
@@ -11,7 +11,7 @@
11
11
  * one call — the registry builds the URL, this tool fetches it.
12
12
  */
13
13
 
14
- import { TemplateRegistry } from 'crawlforge-extractors';
14
+ import { TemplateRegistry, retiredTemplate } from 'crawlforge-extractors';
15
15
  import { safeFetch } from '../../utils/ssrfGuard.js';
16
16
  import { preflightFetch } from '../../utils/robotsGate.js';
17
17
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
@@ -60,6 +60,12 @@ export class ScrapeTemplateTool {
60
60
  }
61
61
  const detected = this.registry.detect(url);
62
62
  if (!detected) {
63
+ // A URL a withdrawn template used to handle gets the reason it is
64
+ // gone, not "no template matches".
65
+ const retired = retiredTemplate(url);
66
+ if (retired) {
67
+ throw badRequest(`The "${retired.id}" template that handled ${url} was retired: ${retired.reason}`);
68
+ }
63
69
  throw badRequest(
64
70
  `No template matches ${url}. Pass template:"list" to see every template and the URLs ` +
65
71
  'each one handles, or name a template explicitly.'
@@ -74,6 +80,10 @@ export class ScrapeTemplateTool {
74
80
  // Validate template exists before making network call
75
81
  const tpl = this.registry.get(templateId);
76
82
  if (!tpl) {
83
+ const retired = retiredTemplate(templateId);
84
+ if (retired) {
85
+ throw badRequest(`Template "${templateId}" was retired: ${retired.reason}`);
86
+ }
77
87
  const available = this.registry.list().map(t => t.id).join(', ');
78
88
  throw new Error(`Unknown template "${templateId}". Available templates: ${available}`);
79
89
  }