crawlforge-mcp-server 6.3.1 → 6.4.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 +1 -1
- package/server.js +3 -2
- package/src/core/AgentOrchestrator.js +22 -1
- package/src/core/crawlers/BFSCrawler.js +21 -2
- package/src/server/inlineThreshold.js +3 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +2 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +21 -0
- package/src/tools/basic/extractLinks.js +3 -1
- package/src/tools/scrape/_mainContent.js +17 -1
- package/src/tools/search/redditSearch.js +12 -1
- package/src/utils/htmlToMarkdown.js +33 -1
- package/src/utils/sitemapParser.js +35 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 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",
|
package/server.js
CHANGED
|
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
107
107
|
// Create the server
|
|
108
108
|
const server = new McpServer({
|
|
109
109
|
name: "crawlforge",
|
|
110
|
-
version: "6.
|
|
110
|
+
version: "6.4.0",
|
|
111
111
|
description: "Production-ready MCP server with 30 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.",
|
|
112
112
|
homepage: "https://www.crawlforge.dev",
|
|
113
113
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -876,7 +876,8 @@ registerToolIfEnabled("get_batch_results", {
|
|
|
876
876
|
inputSchema: {
|
|
877
877
|
batchId: z.string().describe("The batch ID returned by batch_scrape"),
|
|
878
878
|
page: z.number().min(1).default(1).describe("Page number (1-based)"),
|
|
879
|
-
pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page")
|
|
879
|
+
pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page"),
|
|
880
|
+
...MAX_INLINE_CHARS_PARAM
|
|
880
881
|
}
|
|
881
882
|
}, withAuth("get_batch_results", async ({ batchId, page = 1, pageSize = 25 }) => {
|
|
882
883
|
try {
|
|
@@ -333,6 +333,23 @@ export class AgentOrchestrator {
|
|
|
333
333
|
quotedTerms.every(t => q.toLowerCase().includes(t.toLowerCase())) ? q : `${q} ${quotedTerms.join(' ')}`.trim()
|
|
334
334
|
);
|
|
335
335
|
}
|
|
336
|
+
// A current-state plan is told to make its first query the bare entity
|
|
337
|
+
// name, and a small model often stops there. The bare query surfaces the
|
|
338
|
+
// live front page, which rarely states the fact asked for: "what does
|
|
339
|
+
// Southwest charge for a first checked bag" fetched southwest.com's home,
|
|
340
|
+
// booking and careers pages and answered that the fee is not stated
|
|
341
|
+
// (R20, 2026-09-07). Keep the entity query first — the domain vote and
|
|
342
|
+
// live-root promotion depend on it — and add the task's own words as a
|
|
343
|
+
// second query so the page that states the fact is in the queue too.
|
|
344
|
+
if (currentState && searchQueries.length === 1) {
|
|
345
|
+
try {
|
|
346
|
+
const { compactSearchTopic, clampSearchQuery } = await import('./ResearchOrchestrator.js');
|
|
347
|
+
const factQuery = clampSearchQuery(compactSearchTopic(prompt));
|
|
348
|
+
if (factQuery && factQuery.toLowerCase() !== searchQueries[0].toLowerCase()) {
|
|
349
|
+
searchQueries.push(factQuery);
|
|
350
|
+
}
|
|
351
|
+
} catch { /* the entity query alone is the pre-R20 behaviour */ }
|
|
352
|
+
}
|
|
336
353
|
|
|
337
354
|
// ── GATHER (search) ───────────────────────────────────────────────────────
|
|
338
355
|
const urlQueue = [...seedUrls]; // start with any user-provided seeds
|
|
@@ -389,7 +406,11 @@ export class AgentOrchestrator {
|
|
|
389
406
|
// and never enters evidence.
|
|
390
407
|
if (currentState && searchResults.length > 0) {
|
|
391
408
|
const originCounts = new Map();
|
|
409
|
+
// Only the bare entity query votes: the fact query added above returns
|
|
410
|
+
// guides and news sites, which must not outvote the official domain.
|
|
411
|
+
const entityQuery = searchQueries[0];
|
|
392
412
|
for (const s of searchResults) {
|
|
413
|
+
if (s.query !== entityQuery) continue;
|
|
393
414
|
try {
|
|
394
415
|
const origin = new URL(s.url).origin;
|
|
395
416
|
originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
|
|
@@ -536,7 +557,7 @@ export class AgentOrchestrator {
|
|
|
536
557
|
// Short and imperative on purpose: the executing model is a small
|
|
537
558
|
// local one (gemma3:4b-class) and ignores hedged phrasing.
|
|
538
559
|
(currentState
|
|
539
|
-
? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page). NEVER present older or dated content as the current answer.\n`
|
|
560
|
+
? `- 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`
|
|
540
561
|
: '') +
|
|
541
562
|
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
542
563
|
`- Read the sources carefully before concluding anything is missing from them.\n` +
|
|
@@ -12,6 +12,7 @@ import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.j
|
|
|
12
12
|
import { throttleHost } from '../../utils/hostRateLimiter.js';
|
|
13
13
|
import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
|
|
14
14
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
15
|
+
import { extractMainContent, isThinMainContent } from '../../tools/scrape/_mainContent.js';
|
|
15
16
|
|
|
16
17
|
const logger = new Logger('BFSCrawler');
|
|
17
18
|
|
|
@@ -391,9 +392,27 @@ export class BFSCrawler {
|
|
|
391
392
|
// Extract title
|
|
392
393
|
const title = pageTitle($) || $('h1').first().text().trim() || '';
|
|
393
394
|
|
|
394
|
-
// Extract main content
|
|
395
|
+
// Extract main content. A page's chrome (header, mega-menu, footer)
|
|
396
|
+
// repeats on every page of a crawl and comes first in body order, so the
|
|
397
|
+
// 500-char preview showed the same "Explore Products" menu for all six
|
|
398
|
+
// Cessna pages, whose menu is plain <div>s outside any <nav> (R20,
|
|
399
|
+
// 2026-09-07). Use the same main-content pass scrape uses, fall back to
|
|
400
|
+
// the body minus its landmark chrome when Readability finds nothing or
|
|
401
|
+
// only a thin fragment, and to the whole body as a last resort.
|
|
395
402
|
$('script, style, noscript').remove();
|
|
396
|
-
const
|
|
403
|
+
const squash = (text) => text.replace(/\s+/g, ' ').trim();
|
|
404
|
+
let content = '';
|
|
405
|
+
try {
|
|
406
|
+
const main = extractMainContent(html, url);
|
|
407
|
+
if (main.html && !isThinMainContent(main.html, html)) {
|
|
408
|
+
content = squash(load(main.html)('body').text());
|
|
409
|
+
}
|
|
410
|
+
} catch { /* fall through to the body */ }
|
|
411
|
+
if (!content) {
|
|
412
|
+
const $body = $('body').clone();
|
|
413
|
+
$body.find('header, nav, footer, aside, [role="navigation"], [role="banner"], [role="contentinfo"]').remove();
|
|
414
|
+
content = squash($body.text()) || squash($('body').text());
|
|
415
|
+
}
|
|
397
416
|
|
|
398
417
|
// Extract metadata
|
|
399
418
|
const metadata = {
|
|
@@ -30,6 +30,9 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
|
30
30
|
extract_content: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.cleanedHTML'], truncate: true },
|
|
31
31
|
crawl_deep: { textPaths: [], truncate: true },
|
|
32
32
|
batch_scrape: { textPaths: [], truncate: true },
|
|
33
|
+
// A page of 25 markdown results is the same payload batch_scrape shapes;
|
|
34
|
+
// an async job's page came back as 111 KB whole (R20, 2026-09-07).
|
|
35
|
+
get_batch_results: { textPaths: [], truncate: true },
|
|
33
36
|
stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
|
|
34
37
|
scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
|
|
35
38
|
process_document: { textPaths: ['content.text'], truncate: true },
|
|
@@ -73,6 +73,8 @@ completed or in-progress job. Cheap (1 credit) because the batch was already
|
|
|
73
73
|
paid for. Completed jobs are also exposed as `crawlforge://job/{jobId}`
|
|
74
74
|
resources. Stored batch results share the local 1-hour result store that
|
|
75
75
|
`read_result` reads, with the same eviction, so page through a batch within the hour.
|
|
76
|
+
Like `batch_scrape`, it takes `max_inline_chars` (default 40,000): a page over the
|
|
77
|
+
limit comes back as a `preview` plus a `result_handle` for `read_result`.
|
|
76
78
|
|
|
77
79
|
## scrape_with_actions — interact, then scrape (cost: 5)
|
|
78
80
|
|
|
@@ -13,6 +13,7 @@ import ExtractContentTool from '../extract/extractContent.js';
|
|
|
13
13
|
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
14
14
|
import { elementText } from '../../utils/elementText.js';
|
|
15
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
16
|
+
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
16
17
|
|
|
17
18
|
// Recording / replay helpers
|
|
18
19
|
import {
|
|
@@ -726,6 +727,26 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
726
727
|
}
|
|
727
728
|
}
|
|
728
729
|
|
|
730
|
+
// extractContent hands back text alone when Readability finds no
|
|
731
|
+
// article. support.southwest.com's help centre is a JS shell: after an
|
|
732
|
+
// 8 s wait the body text was there, `html` came back empty and
|
|
733
|
+
// `markdown` was the "Content not available" placeholder, reported as
|
|
734
|
+
// success (R20, 2026-09-07). The post-action DOM is in hand, so serve
|
|
735
|
+
// the requested formats from it instead of a placeholder.
|
|
736
|
+
if (chainResult?.finalHtml) {
|
|
737
|
+
extractResult.content = extractResult.content || {};
|
|
738
|
+
if (params.formats?.includes('markdown') && !extractResult.content.markdown) {
|
|
739
|
+
const bodyMarkdown = htmlToMarkdown(chainResult.finalHtml);
|
|
740
|
+
if (bodyMarkdown) {
|
|
741
|
+
extractResult.content.markdown = bodyMarkdown;
|
|
742
|
+
extractResult.content.markdownSource = 'body';
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (params.formats?.includes('html') && !extractResult.content.html) {
|
|
746
|
+
extractResult.content.html = chainResult.finalHtml;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
729
750
|
return extractResult;
|
|
730
751
|
} catch (error) {
|
|
731
752
|
this.log('warn', `Final content extraction failed: ${error.message}`);
|
|
@@ -42,7 +42,9 @@ export async function extractLinksHandler({ url, filter_external, base_url, user
|
|
|
42
42
|
const href = $(element).attr('href');
|
|
43
43
|
const text = $(element).text().trim();
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// A javascript: pseudo-link ("Cookie Settings") is a button, not a
|
|
46
|
+
// link; it was counted as an external link on boeing.com (R20).
|
|
47
|
+
if (!href || /^\s*javascript:/i.test(href)) return;
|
|
46
48
|
|
|
47
49
|
try {
|
|
48
50
|
const absoluteUrl = new URL(href, baseUrl).toString();
|
|
@@ -172,6 +172,22 @@ function isDataTable(table) {
|
|
|
172
172
|
return rows >= 10 || columns > 4;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* A table whose author marked header cells carries data whatever its size —
|
|
177
|
+
* layout tables do not use <th>. The size test above misses every small fee
|
|
178
|
+
* or spec table: WestJet's checked-bag fees are 6 rows × 3 columns inside a
|
|
179
|
+
* `com-tabs` component (Readability's `negative` regex matches `com-`), and
|
|
180
|
+
* the page came back reading "fees are as follows:" with nothing following
|
|
181
|
+
* (R20, 2026-09-07).
|
|
182
|
+
* @param {HTMLTableElement} table
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
function isHeadedTable(table) {
|
|
186
|
+
if (table.rows.length < 2) return false;
|
|
187
|
+
if (!table.querySelector('th')) return false;
|
|
188
|
+
return Array.from(table.rows).some((row) => row.cells.length >= 2);
|
|
189
|
+
}
|
|
190
|
+
|
|
175
191
|
/**
|
|
176
192
|
* A data table's text with its structure kept: one line per row, cells joined
|
|
177
193
|
* by " | ". A bare `textContent` runs every cell together
|
|
@@ -216,7 +232,7 @@ export function recoverDroppedTables(html, url, keptText = '') {
|
|
|
216
232
|
// A nested table travels with its parent; re-attaching it separately
|
|
217
233
|
// would duplicate it.
|
|
218
234
|
.filter((table) => !table.parentElement?.closest('table'))
|
|
219
|
-
.filter(isDataTable)
|
|
235
|
+
.filter((table) => isDataTable(table) || isHeadedTable(table))
|
|
220
236
|
.filter((table) => {
|
|
221
237
|
const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
|
|
222
238
|
return signature.length > 0 && !kept.includes(signature);
|
|
@@ -373,7 +373,18 @@ export class RedditSearchTool {
|
|
|
373
373
|
// caller the 422. Thread mode returned above, so both remaining modes
|
|
374
374
|
// (posts, comments) narrow.
|
|
375
375
|
const narrowable = Boolean(v.query) && !v.after;
|
|
376
|
-
if (!error.retryable || !narrowable)
|
|
376
|
+
if (!error.retryable || !narrowable) {
|
|
377
|
+
// The caller's window is respected, so the 422 is theirs to act on —
|
|
378
|
+
// but the generic hint ("add subreddit or author") is useless on a
|
|
379
|
+
// search that is already scoped (R20: r/aviation "737 MAX" after=30d).
|
|
380
|
+
if (error.retryable && v.query && v.after) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`${error.message} — Arctic Shift timed out searching your after=${v.after} window. ` +
|
|
383
|
+
'Pass a narrower after (7d, 3d, 1d), drop the query to list the newest posts in the scope, or read one post with mode:"thread" and link_id.'
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
377
388
|
let lastError = error;
|
|
378
389
|
for (const window of ['7d', '3d', '1d']) {
|
|
379
390
|
await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
|
|
@@ -16,10 +16,42 @@
|
|
|
16
16
|
|
|
17
17
|
import TurndownService from 'turndown';
|
|
18
18
|
import { gfm } from 'turndown-plugin-gfm';
|
|
19
|
+
import { load } from 'cheerio';
|
|
19
20
|
import { stripHiddenHtml } from './hiddenContent.js';
|
|
20
21
|
|
|
21
22
|
let _td = null;
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* turndown-plugin-gfm renders a table as a pipe table only when its first row
|
|
26
|
+
* is entirely <th>. A corner cell written as an empty <td> — WestJet's fee
|
|
27
|
+
* table opens `<td> </td><th>1st Bag</th><th>2nd Bag</th>` — fails that test,
|
|
28
|
+
* so the whole table fell to the layout-table rule and flattened to text
|
|
29
|
+
* lines with the columns lost (R20, 2026-09-07). Promote empty corner cells
|
|
30
|
+
* in an otherwise all-<th> first row so the table renders as a table.
|
|
31
|
+
* @param {string} html
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function promoteCornerHeaderCells(html) {
|
|
35
|
+
if (!/<th[\s>]/i.test(html)) return html;
|
|
36
|
+
try {
|
|
37
|
+
const $ = load(html);
|
|
38
|
+
let changed = false;
|
|
39
|
+
$('table').each((_, table) => {
|
|
40
|
+
const firstRow = $(table).find('tr').first();
|
|
41
|
+
if (!firstRow.length || firstRow.closest('table')[0] !== table) return;
|
|
42
|
+
const cells = firstRow.children('th, td');
|
|
43
|
+
const tds = cells.filter('td');
|
|
44
|
+
if (cells.filter('th').length === 0 || tds.length === 0) return;
|
|
45
|
+
if (tds.toArray().some((td) => $(td).text().trim() !== '')) return;
|
|
46
|
+
tds.each((__, td) => { td.name = 'th'; });
|
|
47
|
+
changed = true;
|
|
48
|
+
});
|
|
49
|
+
return changed ? $.html() : html;
|
|
50
|
+
} catch {
|
|
51
|
+
return html;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
// Mirrors turndown-plugin-gfm's own heading-row test, which is what decides
|
|
24
56
|
// whether it converts a table or keeps it as raw HTML.
|
|
25
57
|
function isHeadingRow(tr) {
|
|
@@ -112,7 +144,7 @@ export function htmlToMarkdown(html, options = {}) {
|
|
|
112
144
|
const visible = options.keepHiddenContent
|
|
113
145
|
? html
|
|
114
146
|
: stripHiddenHtml(html, { css: options.css });
|
|
115
|
-
return getTurndown().turndown(visible).trim();
|
|
147
|
+
return getTurndown().turndown(promoteCornerHeaderCells(visible)).trim();
|
|
116
148
|
} catch {
|
|
117
149
|
// Fallback: strip tags, return plain text
|
|
118
150
|
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
@@ -10,6 +10,23 @@ import { noteRetryAfter } from './hostRateLimiter.js';
|
|
|
10
10
|
|
|
11
11
|
const gunzip = promisify(zlib.gunzip);
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* A sitemap <loc> resolved against the sitemap's own URL and normalized;
|
|
15
|
+
* null when it is empty or not a URL. The protocol wants absolute locs, but
|
|
16
|
+
* boeing.com's 1,878-entry sitemap is written with relative paths ("/",
|
|
17
|
+
* "/commercial"): `normalizeUrl("/")` threw out of the entry loop, the whole
|
|
18
|
+
* sitemap read as empty, and map_site fell back to crawling links — 75 URLs
|
|
19
|
+
* (R20, 2026-09-07). One bad entry must not discard the rest either.
|
|
20
|
+
*/
|
|
21
|
+
function resolveLoc(loc, base) {
|
|
22
|
+
if (!loc) return null;
|
|
23
|
+
try {
|
|
24
|
+
return normalizeUrl(new URL(loc, base).href);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
export class SitemapParser {
|
|
14
31
|
constructor(options = {}) {
|
|
15
32
|
const {
|
|
@@ -121,11 +138,11 @@ export class SitemapParser {
|
|
|
121
138
|
// Parse sitemap index entries
|
|
122
139
|
$('sitemap').each((_, element) => {
|
|
123
140
|
const $sitemap = $(element);
|
|
124
|
-
const loc = $sitemap.find('loc').text().trim();
|
|
125
|
-
|
|
141
|
+
const loc = resolveLoc($sitemap.find('loc').text().trim(), indexUrl);
|
|
142
|
+
|
|
126
143
|
if (loc) {
|
|
127
144
|
const sitemap = {
|
|
128
|
-
url:
|
|
145
|
+
url: loc,
|
|
129
146
|
lastmod: $sitemap.find('lastmod').text().trim() || null
|
|
130
147
|
};
|
|
131
148
|
sitemaps.push(sitemap);
|
|
@@ -347,7 +364,9 @@ export class SitemapParser {
|
|
|
347
364
|
const cacheKey = this.cache?.generateKey(url, { depth: currentDepth });
|
|
348
365
|
if (this.cache && cacheKey) {
|
|
349
366
|
const cached = await this.cache.get(cacheKey);
|
|
350
|
-
|
|
367
|
+
// An empty cached parse is a failure that got remembered (see the
|
|
368
|
+
// write side below); re-parse rather than serve it for an hour.
|
|
369
|
+
if (cached && (cached.urls?.length > 0 || cached.sitemaps?.length > 0)) {
|
|
351
370
|
this.stats.cacheHits++;
|
|
352
371
|
return cached;
|
|
353
372
|
}
|
|
@@ -378,8 +397,12 @@ export class SitemapParser {
|
|
|
378
397
|
}
|
|
379
398
|
}
|
|
380
399
|
|
|
381
|
-
// Cache the result
|
|
382
|
-
|
|
400
|
+
// Cache the result — but never an empty one. A parse that yielded no
|
|
401
|
+
// URL and no child sitemap is far more likely a failure than a fact
|
|
402
|
+
// (the relative-<loc> throw above sat in the disk cache for an hour
|
|
403
|
+
// and map_site kept answering 75 for boeing.com after the parser was
|
|
404
|
+
// fixed, R20 2026-09-07).
|
|
405
|
+
if (this.cache && cacheKey && (result.urls.length > 0 || result.sitemaps.length > 0)) {
|
|
383
406
|
await this.cache.set(cacheKey, result);
|
|
384
407
|
}
|
|
385
408
|
|
|
@@ -469,11 +492,11 @@ export class SitemapParser {
|
|
|
469
492
|
// Parse standard URLs
|
|
470
493
|
$('url').each((_, element) => {
|
|
471
494
|
const $url = $(element);
|
|
472
|
-
const loc = $url.find('loc').text().trim();
|
|
473
|
-
|
|
495
|
+
const loc = resolveLoc($url.find('loc').text().trim(), url);
|
|
496
|
+
|
|
474
497
|
if (loc && result.urls.length < this.maxUrlsPerSitemap) {
|
|
475
498
|
const urlData = {
|
|
476
|
-
loc
|
|
499
|
+
loc,
|
|
477
500
|
lastmod: $url.find('lastmod').text().trim() || null,
|
|
478
501
|
changefreq: $url.find('changefreq').text().trim() || null,
|
|
479
502
|
priority: $url.find('priority').text().trim() || null
|
|
@@ -565,10 +588,10 @@ export class SitemapParser {
|
|
|
565
588
|
|
|
566
589
|
$('sitemap').each((_, element) => {
|
|
567
590
|
const $sitemap = $(element);
|
|
568
|
-
const loc = $sitemap.find('loc').text().trim();
|
|
569
|
-
|
|
591
|
+
const loc = resolveLoc($sitemap.find('loc').text().trim(), url);
|
|
592
|
+
|
|
570
593
|
if (loc) {
|
|
571
|
-
result.sitemaps.push(
|
|
594
|
+
result.sitemaps.push(loc);
|
|
572
595
|
}
|
|
573
596
|
});
|
|
574
597
|
|