crawlforge-mcp-server 5.0.2 → 5.0.4
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/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/server.js +5 -3
- package/src/cli/commands/llmstxt.js +9 -2
- package/src/constants/config.js +5 -1
- package/src/core/ActionExecutor.js +20 -1
- package/src/core/AgentOrchestrator.js +59 -5
- package/src/core/ChangeTracker.js +28 -16
- package/src/core/LocalizationManager.js +62 -17
- package/src/core/MonitorStore.js +55 -6
- package/src/core/ResearchOrchestrator.js +6 -1
- package/src/core/StealthBrowserManager.js +72 -11
- package/src/core/analysis/ContentAnalyzer.js +201 -18
- package/src/tools/advanced/ScrapeWithActionsTool.js +5 -1
- package/src/tools/advanced/batchScrape/worker.js +8 -9
- package/src/tools/advanced/scrapeWithActions/recorder.js +3 -0
- package/src/tools/basic/extractText.js +7 -0
- package/src/tools/extract/_fetchAndParse.js +32 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +147 -7
- package/src/tools/templates/TemplateRegistry.js +5 -2
- package/src/tools/tracking/trackChanges/index.js +12 -5
|
@@ -448,8 +448,9 @@ export class ContentAnalyzer {
|
|
|
448
448
|
|
|
449
449
|
/**
|
|
450
450
|
* Create extractive summary by scoring pre-split sentences via word
|
|
451
|
-
* frequency (Luhn-style salience), tokenized with compromise, plus a
|
|
452
|
-
* positional bonus
|
|
451
|
+
* frequency (Luhn-style salience), tokenized with compromise, plus a
|
|
452
|
+
* lead-biased positional bonus (first sentence strongly favored) and a
|
|
453
|
+
* brevity penalty for very short sentences. Selects the top N and
|
|
453
454
|
* restores original document order.
|
|
454
455
|
* @param {string[]} sentences - Sentences in original document order
|
|
455
456
|
* @param {number} targetSentences - Number of sentences to select
|
|
@@ -474,11 +475,22 @@ export class ContentAnalyzer {
|
|
|
474
475
|
|
|
475
476
|
const scored = sentences.map((sentence, index) => {
|
|
476
477
|
const words = sentenceWords[index];
|
|
478
|
+
// Very short sentences carry little information yet the frequency
|
|
479
|
+
// average below inflates them (few words, each high-frequency), so
|
|
480
|
+
// dampen their word score (length penalty, Nobata & Sekine 2004).
|
|
481
|
+
const brevityPenalty = words.length < 5 ? 0.5 : 1;
|
|
477
482
|
const wordScore = words.length > 0
|
|
478
|
-
? words.reduce((sum, w) => sum + freq[w] / maxFreq, 0) / words.length
|
|
483
|
+
? (words.reduce((sum, w) => sum + freq[w] / maxFreq, 0) / words.length) * brevityPenalty
|
|
479
484
|
: 0;
|
|
480
|
-
//
|
|
481
|
-
|
|
485
|
+
// Lead bias (Edmundson position method): document-leading sentences
|
|
486
|
+
// carry the definitional payload — Lead-3 remains a near-SOTA
|
|
487
|
+
// extractive baseline — so favor the first sentence strongly, decay
|
|
488
|
+
// over the next two, and keep only a token bonus for the closer.
|
|
489
|
+
let positionScore = 0;
|
|
490
|
+
if (index === 0) positionScore = 0.4;
|
|
491
|
+
else if (index === 1) positionScore = 0.15;
|
|
492
|
+
else if (index === 2) positionScore = 0.08;
|
|
493
|
+
else if (index === sentences.length - 1) positionScore = 0.05;
|
|
482
494
|
return { sentence, index, score: wordScore + positionScore };
|
|
483
495
|
});
|
|
484
496
|
|
|
@@ -508,17 +520,23 @@ export class ContentAnalyzer {
|
|
|
508
520
|
const phraseCount = {};
|
|
509
521
|
|
|
510
522
|
allPhrases.forEach(phrase => {
|
|
511
|
-
|
|
512
|
-
|
|
523
|
+
// Strip edge punctuation and skip phrases made only of stop words
|
|
524
|
+
const cleaned = phrase.toLowerCase().trim().replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '');
|
|
525
|
+
const words = cleaned.split(/\s+/);
|
|
526
|
+
if (cleaned.length > 2 && words.some(w => !this.isStopWord(w))) {
|
|
513
527
|
phraseCount[cleaned] = (phraseCount[cleaned] || 0) + 1;
|
|
514
528
|
}
|
|
515
529
|
});
|
|
516
530
|
|
|
517
|
-
// Score and rank topics
|
|
531
|
+
// Score and rank topics. Confidence is the phrase frequency relative to
|
|
532
|
+
// the MOST frequent phrase (RAKE-style relative salience) — the old
|
|
533
|
+
// frequency/totalPhrases normalization collapsed toward 0 on long
|
|
534
|
+
// documents, so every topic failed minConfidence and topics came back [].
|
|
535
|
+
const maxFreq = Math.max(1, ...Object.values(phraseCount));
|
|
518
536
|
const topics = Object.entries(phraseCount)
|
|
519
537
|
.map(([topic, frequency]) => ({
|
|
520
538
|
topic,
|
|
521
|
-
confidence: Math.
|
|
539
|
+
confidence: Math.round((frequency / maxFreq) * 100) / 100,
|
|
522
540
|
keywords: topic.split(' ').filter(w => w.length > 2)
|
|
523
541
|
}))
|
|
524
542
|
.filter(topic => topic.confidence >= options.minConfidence)
|
|
@@ -543,14 +561,53 @@ export class ContentAnalyzer {
|
|
|
543
561
|
try {
|
|
544
562
|
const doc = nlp(text);
|
|
545
563
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
564
|
+
// compromise's out('array') keeps adjoining punctuation ("Craigslist.",
|
|
565
|
+
// "United States,") — clean edges, drop bare stopword/pronoun tokens,
|
|
566
|
+
// and dedupe case-insensitively keeping the first casing seen.
|
|
567
|
+
const clean = (arr) => this.dedupeEntities(
|
|
568
|
+
arr.map(e => this.cleanEntityText(e))
|
|
569
|
+
.filter(e => e.length > 1
|
|
570
|
+
&& !this.isBareStopword(e)
|
|
571
|
+
&& !/^(?:inc|corp|ltd|co|llc)\.?$/i.test(e)) // bare corporate-suffix fragment
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
let people = clean(doc.people().out('array'));
|
|
575
|
+
let places = clean(doc.places().out('array'));
|
|
576
|
+
let organizations = clean(doc.organizations().out('array'));
|
|
549
577
|
// .dates() needs the compromise-dates plugin (not installed) and threw,
|
|
550
578
|
// aborting ALL entity extraction; #Date+ tag matching is core compromise.
|
|
551
|
-
const dates = doc.match('#Date+').out('array');
|
|
552
|
-
const money = doc.money().out('array');
|
|
553
|
-
let other = doc.topics().out('array').slice(0, 10)
|
|
579
|
+
const dates = clean(doc.match('#Date+').out('array'));
|
|
580
|
+
const money = clean(doc.money().out('array'));
|
|
581
|
+
let other = clean(doc.topics().out('array').slice(0, 10))
|
|
582
|
+
.filter(e => !this.isSentenceInitialArtifact(e, text));
|
|
583
|
+
|
|
584
|
+
// "X v. Y" fragments are legal-case citations, not people/places/orgs —
|
|
585
|
+
// reclassify them under "other".
|
|
586
|
+
const legalCases = [];
|
|
587
|
+
const extractCases = (list) => list.filter(e => {
|
|
588
|
+
if (this.isLegalCaseFragment(e)) {
|
|
589
|
+
legalCases.push(e);
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
return true;
|
|
593
|
+
});
|
|
594
|
+
people = extractCases(people);
|
|
595
|
+
places = extractCases(places);
|
|
596
|
+
organizations = extractCases(organizations);
|
|
597
|
+
|
|
598
|
+
// A bare ALL-CAPS token (UNIX, DOM) is not enough evidence for an
|
|
599
|
+
// organization — demote to "other" unless the text corroborates it
|
|
600
|
+
// (corporate suffix or organization-headed alias definition).
|
|
601
|
+
const demotedAcronyms = [];
|
|
602
|
+
organizations = organizations.filter(e => {
|
|
603
|
+
if (/^[A-Z]{2,}$/.test(e) && !this.hasOrganizationEvidence(e, text)) {
|
|
604
|
+
demotedAcronyms.push(e);
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
return true;
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
other = [...other, ...legalCases, ...demotedAcronyms];
|
|
554
611
|
|
|
555
612
|
// Supplement with capitalized proper nouns that compromise may miss
|
|
556
613
|
// (technology names, product names, etc.)
|
|
@@ -558,15 +615,27 @@ export class ContentAnalyzer {
|
|
|
558
615
|
...people, ...places, ...organizations, ...other
|
|
559
616
|
].map(e => e.toLowerCase()));
|
|
560
617
|
|
|
561
|
-
const properNouns = text.match(/\b[A-Z][a-zA-Z.]+(?:\s+[A-Z][a-zA-Z.]+)*/g) || []
|
|
562
|
-
|
|
563
|
-
|
|
618
|
+
const properNouns = (text.match(/\b[A-Z][a-zA-Z.]+(?:\s+[A-Z][a-zA-Z.]+)*/g) || [])
|
|
619
|
+
// Split matches that crossed a sentence boundary ("XPath. In") — a
|
|
620
|
+
// period after a lowercase letter followed by a capital is a sentence
|
|
621
|
+
// end, while abbreviations ("U.S. Holdings") stay intact.
|
|
622
|
+
.flatMap(n => n.split(/(?<=[a-z]\.)\s+(?=[A-Z])/));
|
|
623
|
+
const supplemental = this.dedupeEntities(properNouns.map(n => this.cleanEntityText(n)))
|
|
624
|
+
.filter(n => n.length > 1
|
|
625
|
+
&& !existingEntities.has(n.toLowerCase())
|
|
626
|
+
&& !this.isSentenceInitialArtifact(n, text))
|
|
564
627
|
.slice(0, 10);
|
|
565
628
|
|
|
566
629
|
if (supplemental.length > 0) {
|
|
567
630
|
other = [...other, ...supplemental].slice(0, 15);
|
|
568
631
|
}
|
|
569
632
|
|
|
633
|
+
// Keep "other" free of entities already classified more specifically.
|
|
634
|
+
const classified = new Set(
|
|
635
|
+
[...people, ...places, ...organizations, ...dates, ...money].map(e => e.toLowerCase())
|
|
636
|
+
);
|
|
637
|
+
other = this.dedupeEntities(other).filter(e => !classified.has(e.toLowerCase()));
|
|
638
|
+
|
|
570
639
|
const allEntities = [...people, ...places, ...organizations, ...dates, ...money, ...other];
|
|
571
640
|
const uniqueEntities = new Set(allEntities.map(e => e.toLowerCase()));
|
|
572
641
|
|
|
@@ -828,6 +897,120 @@ export class ContentAnalyzer {
|
|
|
828
897
|
return 'Very Difficult';
|
|
829
898
|
}
|
|
830
899
|
|
|
900
|
+
/**
|
|
901
|
+
* Strip leading/trailing punctuation from an entity string while preserving
|
|
902
|
+
* trailing periods on abbreviations ("Inc.", "U.S.") and internal
|
|
903
|
+
* punctuation ("Home.dk", "Bidder's Edge").
|
|
904
|
+
* @param {string} raw - Raw entity string
|
|
905
|
+
* @returns {string} - Cleaned entity string
|
|
906
|
+
*/
|
|
907
|
+
cleanEntityText(raw) {
|
|
908
|
+
let entity = String(raw).trim();
|
|
909
|
+
let previous;
|
|
910
|
+
do {
|
|
911
|
+
previous = entity;
|
|
912
|
+
entity = entity.replace(/^[\s"'‘’“”`([{<,;:!?«»–—-]+/, '');
|
|
913
|
+
entity = entity.replace(/[\s"'‘’“”`)\]}>,;:!?«»–—-]+$/, '');
|
|
914
|
+
if (entity.endsWith('.')) {
|
|
915
|
+
const lastToken = entity.split(/\s+/).pop();
|
|
916
|
+
const isAbbreviation = /^(?:[A-Za-z]\.)+$/.test(lastToken)
|
|
917
|
+
|| /^(?:inc|corp|ltd|co|llc|jr|sr|st|mr|mrs|ms|dr|no|vs?)\.$/i.test(lastToken);
|
|
918
|
+
if (!isAbbreviation) {
|
|
919
|
+
entity = entity.replace(/\.+$/, '');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
} while (entity !== previous);
|
|
923
|
+
return entity;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Deduplicate entities case-insensitively, keeping the first casing seen
|
|
928
|
+
* @param {string[]} entities - Entity strings
|
|
929
|
+
* @returns {string[]} - Deduplicated entity strings
|
|
930
|
+
*/
|
|
931
|
+
dedupeEntities(entities) {
|
|
932
|
+
const seen = new Set();
|
|
933
|
+
const result = [];
|
|
934
|
+
for (const entity of entities) {
|
|
935
|
+
const key = entity.toLowerCase();
|
|
936
|
+
if (!seen.has(key)) {
|
|
937
|
+
seen.add(key);
|
|
938
|
+
result.push(entity);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return result;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Check whether an entity is a single bare stop word/pronoun token
|
|
946
|
+
* @param {string} entity - Cleaned entity string
|
|
947
|
+
* @returns {boolean} - True if a bare stop word
|
|
948
|
+
*/
|
|
949
|
+
isBareStopword(entity) {
|
|
950
|
+
return !entity.includes(' ') && this.isStopWord(entity.toLowerCase());
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* True when a single capitalized token is likely just a sentence-initial
|
|
955
|
+
* common word ("While", "It", "Once"): it is a stop word, the same word
|
|
956
|
+
* also appears in lowercase elsewhere in the text, or it is only ever
|
|
957
|
+
* capitalized at the start of a sentence (real proper nouns keep their
|
|
958
|
+
* capital mid-sentence).
|
|
959
|
+
* @param {string} entity - Cleaned entity string
|
|
960
|
+
* @param {string} text - Full source text
|
|
961
|
+
* @returns {boolean} - True if likely a sentence-initial artifact
|
|
962
|
+
*/
|
|
963
|
+
isSentenceInitialArtifact(entity, text) {
|
|
964
|
+
if (entity.includes(' ')) return false;
|
|
965
|
+
const lower = entity.toLowerCase();
|
|
966
|
+
if (this.isStopWord(lower)) return true;
|
|
967
|
+
if (!/^[A-Z][a-z]+$/.test(entity)) return false;
|
|
968
|
+
|
|
969
|
+
// Word also appears in lowercase → ordinary word, capitalized only by position
|
|
970
|
+
if (new RegExp(`(?:^|[^A-Za-z])${lower}(?:[^A-Za-z]|$)`).test(text)) return true;
|
|
971
|
+
|
|
972
|
+
// Keep only if at least one occurrence is NOT at a sentence start
|
|
973
|
+
const occurrence = new RegExp(`\\b${entity}\\b`, 'g');
|
|
974
|
+
let match;
|
|
975
|
+
while ((match = occurrence.exec(text)) !== null) {
|
|
976
|
+
const before = text.slice(0, match.index).replace(/[\s"'‘’“”()[\]]+$/, '');
|
|
977
|
+
if (before.length > 0 && !/[.!?:]$/.test(before)) {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return true;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Check whether an entity is an "X v. Y" / "X vs. Y" legal-case fragment
|
|
986
|
+
* @param {string} entity - Cleaned entity string
|
|
987
|
+
* @returns {boolean} - True if a legal-case citation fragment
|
|
988
|
+
*/
|
|
989
|
+
isLegalCaseFragment(entity) {
|
|
990
|
+
return /\s+vs?\.?\s+/i.test(entity);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Corroborating evidence that an ALL-CAPS token really is an organization:
|
|
995
|
+
* a corporate suffix follows it ("IBM Corp"), or it is introduced as the
|
|
996
|
+
* alias of an organization-headed name ("American Airlines (AA)",
|
|
997
|
+
* "French Data Protection Authority (CNIL)").
|
|
998
|
+
* @param {string} acronym - ALL-CAPS candidate (already /^[A-Z]{2,}$/)
|
|
999
|
+
* @param {string} text - Full source text
|
|
1000
|
+
* @returns {boolean} - True if the text corroborates the org classification
|
|
1001
|
+
*/
|
|
1002
|
+
hasOrganizationEvidence(acronym, text) {
|
|
1003
|
+
const corporateSuffix = new RegExp(
|
|
1004
|
+
`\\b${acronym},?\\s+(?:Inc|Corp|Corporation|Company|Co|Ltd|LLC|Group)\\.?(?:[^a-zA-Z]|$)`
|
|
1005
|
+
);
|
|
1006
|
+
if (corporateSuffix.test(text)) return true;
|
|
1007
|
+
|
|
1008
|
+
const orgHeadedAlias = new RegExp(
|
|
1009
|
+
`\\b(?:Airlines?|Authority|Agency|Association|Bureau|Commission|Committee|Corporation|Company|Council|Court|Foundation|Group|Institute|Institution|Organi[sz]ation|Press|Society|Union|University)\\s*\\(["'“‘]?${acronym}["'”’]?\\)`
|
|
1010
|
+
);
|
|
1011
|
+
return orgHeadedAlias.test(text);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
831
1014
|
/**
|
|
832
1015
|
* Check if word is a stop word
|
|
833
1016
|
* @param {string} word - Word to check
|
|
@@ -73,7 +73,11 @@ const ScrollActionSchema = BaseActionSchema.extend({
|
|
|
73
73
|
direction: z.enum(['up', 'down', 'left', 'right']).default('down'),
|
|
74
74
|
distance: z.number().min(0).default(100),
|
|
75
75
|
smooth: z.boolean().default(true),
|
|
76
|
-
toElement: z.string().optional()
|
|
76
|
+
toElement: z.string().optional(),
|
|
77
|
+
// Absolute scroll-to coordinates (window.scrollTo). When present they take
|
|
78
|
+
// precedence over direction/distance — see ActionExecutor's ScrollActionSchema.
|
|
79
|
+
x: z.number().min(0).optional(),
|
|
80
|
+
y: z.number().min(0).optional()
|
|
77
81
|
});
|
|
78
82
|
|
|
79
83
|
const ScreenshotActionSchema = BaseActionSchema.extend({
|
|
@@ -8,6 +8,7 @@ import { load } from 'cheerio';
|
|
|
8
8
|
import { config as appConfig } from '../../../constants/config.js';
|
|
9
9
|
import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
|
|
10
10
|
import { throttleHost } from '../../../utils/hostRateLimiter.js';
|
|
11
|
+
import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
|
|
11
12
|
|
|
12
13
|
const USER_AGENT = 'MCP-WebScraper-BatchTool/1.0.0';
|
|
13
14
|
|
|
@@ -165,7 +166,6 @@ function generateFormats($, html, formats) {
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
function buildMarkdown($) {
|
|
168
|
-
let md = '';
|
|
169
169
|
const title = $('title').text().trim();
|
|
170
170
|
|
|
171
171
|
const selectors = ['article', 'main', '.content', '#content', '.post-content', '.entry-content'];
|
|
@@ -176,18 +176,17 @@ function buildMarkdown($) {
|
|
|
176
176
|
}
|
|
177
177
|
if (!$body || $body.length === 0) $body = $('body');
|
|
178
178
|
|
|
179
|
+
// Full-fidelity conversion via the shared Turndown helper (same converter
|
|
180
|
+
// the unified `scrape` tool uses). The previous hand-rolled h1–h3/p/li walk
|
|
181
|
+
// silently dropped any text living outside those tags (e.g. quotes in
|
|
182
|
+
// <span>/<small> on quotes.toscrape.com).
|
|
183
|
+
let md = htmlToMarkdown($.html($body));
|
|
184
|
+
|
|
179
185
|
// C3: de-dup title — only emit the <title> heading if the page has no <h1>
|
|
180
186
|
// or if the first <h1> text differs from the <title> text (case-insensitive).
|
|
181
187
|
const firstH1 = $body.find('h1').first().text().trim();
|
|
182
188
|
const titleDuplicated = firstH1 && firstH1.toLowerCase() === title.toLowerCase();
|
|
183
|
-
if (title && !titleDuplicated) md
|
|
184
|
-
|
|
185
|
-
$body.find('h1').each((_, el) => { md += `# ${$(el).text().trim()}\n\n`; });
|
|
186
|
-
$body.find('h2').each((_, el) => { md += `## ${$(el).text().trim()}\n\n`; });
|
|
187
|
-
$body.find('h3').each((_, el) => { md += `### ${$(el).text().trim()}\n\n`; });
|
|
188
|
-
$body.find('p').each((_, el) => { const t = $(el).text().trim(); if (t) md += `${t}\n\n`; });
|
|
189
|
-
$body.find('ul li').each((_, el) => { md += `- ${$(el).text().trim()}\n`; });
|
|
190
|
-
$body.find('ol li').each((_, el) => { md += `1. ${$(el).text().trim()}\n`; });
|
|
189
|
+
if (title && !titleDuplicated) md = `# ${title}\n\n${md}`;
|
|
191
190
|
|
|
192
191
|
return md.trim();
|
|
193
192
|
}
|
|
@@ -170,6 +170,9 @@ export function buildRecordedEntry(action, timestampMsSinceStart) {
|
|
|
170
170
|
if (action.value !== undefined) entry.value = action.value;
|
|
171
171
|
if (action.direction !== undefined) entry.direction = action.direction;
|
|
172
172
|
if (action.distance !== undefined) entry.distance = action.distance;
|
|
173
|
+
// scroll: absolute scroll-to coordinates
|
|
174
|
+
if (action.x !== undefined) entry.x = action.x;
|
|
175
|
+
if (action.y !== undefined) entry.y = action.y;
|
|
173
176
|
if (action.description !== undefined) entry.description = action.description;
|
|
174
177
|
// executeJavaScript actions require `script` to replay (ActionExecutor's
|
|
175
178
|
// ActionChainSchema rejects the entry otherwise) — preserve it.
|
|
@@ -89,6 +89,13 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
|
|
|
89
89
|
if (remove_scripts !== false) $('script').remove();
|
|
90
90
|
if (remove_styles !== false) $('style').remove();
|
|
91
91
|
|
|
92
|
+
// <noscript> contents are parsed as raw TEXT when scripting is enabled
|
|
93
|
+
// (cheerio/parse5 default, per the HTML spec), so leaving them in leaks
|
|
94
|
+
// literal markup into the extracted text — e.g. Wikipedia's
|
|
95
|
+
// Special:CentralAutoLogin 1x1 <img> tracking pixel. Browsers with JS
|
|
96
|
+
// enabled never render noscript content, so always strip it.
|
|
97
|
+
$('noscript').remove();
|
|
98
|
+
|
|
92
99
|
$('nav, header, footer, aside, .advertisement, .ad, .sidebar').remove();
|
|
93
100
|
|
|
94
101
|
const result = {
|
|
@@ -96,6 +96,37 @@ function classifyContentType(contentType) {
|
|
|
96
96
|
return 'binary';
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Flatten a parsed document's <body> to text while preserving line structure.
|
|
101
|
+
*
|
|
102
|
+
* Cheerio's .text() joins adjacent elements with no separator, which welds
|
|
103
|
+
* table rows / list items together ("1.Story title329 points") and starves
|
|
104
|
+
* downstream LLM extraction (AgentOrchestrator, extractStructured,
|
|
105
|
+
* extractWithLlm) of any structure to parse. Works on a detached clone so the
|
|
106
|
+
* caller's $ tree is untouched — unifiedScrape reuses $ for other formats.
|
|
107
|
+
*
|
|
108
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function flattenBodyText($) {
|
|
112
|
+
// Block boundaries are marked with a U+E000 private-use sentinel so the
|
|
113
|
+
// HTML source's own insignificant newlines can be collapsed to spaces
|
|
114
|
+
// first, and only the sentinels become line breaks. (NUL won't survive:
|
|
115
|
+
// .after()/.replaceWith() parse their argument as HTML and the parser
|
|
116
|
+
// strips NUL; U+E000 passes through and never occurs in real page text.)
|
|
117
|
+
const $body = $('body').clone();
|
|
118
|
+
$body.find('br').replaceWith('\uE000');
|
|
119
|
+
$body.find('td, th').after(' ');
|
|
120
|
+
$body
|
|
121
|
+
.find('p, div, li, tr, h1, h2, h3, h4, h5, h6, blockquote, pre, table, ul, ol, dl, section, article, header, footer')
|
|
122
|
+
.after('\uE000');
|
|
123
|
+
return $body
|
|
124
|
+
.text()
|
|
125
|
+
.replace(/\s+/g, ' ')
|
|
126
|
+
.replace(/ ?(?:\uE000 ?)+/g, '\n')
|
|
127
|
+
.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
99
130
|
/**
|
|
100
131
|
* Fetch a URL and return parsed HTML via Cheerio.
|
|
101
132
|
*
|
|
@@ -149,7 +180,7 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
149
180
|
$(stripTags.join(', ')).remove();
|
|
150
181
|
}
|
|
151
182
|
|
|
152
|
-
const textContent = $
|
|
183
|
+
const textContent = flattenBodyText($);
|
|
153
184
|
|
|
154
185
|
return { html, $, textContent, finalUrl: response.url };
|
|
155
186
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { load } from 'cheerio';
|
|
2
3
|
import { LLMsTxtAnalyzer } from '../../core/LLMsTxtAnalyzer.js';
|
|
3
4
|
import { Logger } from '../../utils/Logger.js';
|
|
4
5
|
import { getBaseUrl } from '../../utils/urlNormalizer.js';
|
|
6
|
+
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
5
7
|
|
|
6
8
|
const logger = new Logger('GenerateLLMsTxtTool');
|
|
7
9
|
|
|
@@ -79,6 +81,14 @@ export class GenerateLLMsTxtTool {
|
|
|
79
81
|
});
|
|
80
82
|
const analysis = await analyzer.analyzeWebsite(url, analysisOptions);
|
|
81
83
|
|
|
84
|
+
// Capture the homepage's <title>/h1 and meta/og description so the
|
|
85
|
+
// spec-compliant output can carry a real site summary and a named Home
|
|
86
|
+
// link (llmstxt.org) instead of boilerplate. Best-effort: null on
|
|
87
|
+
// failure, in which case the generic fallbacks below apply.
|
|
88
|
+
if (!outputOptions.robotsStyle) {
|
|
89
|
+
analysis.homePage = await this.fetchHomePageMetadata(baseUrl);
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
// Step 2: Generate LLMs.txt Content
|
|
83
93
|
const llmsTxtContent = this.generateLLMsTxt(analysis, outputOptions, complianceLevel);
|
|
84
94
|
|
|
@@ -156,8 +166,10 @@ export class GenerateLLMsTxtTool {
|
|
|
156
166
|
lines.push(`# ${title}`);
|
|
157
167
|
lines.push('');
|
|
158
168
|
|
|
159
|
-
// Blockquote summary (required by spec)
|
|
160
|
-
|
|
169
|
+
// Blockquote summary (required by spec). Prefer the site's own meta /
|
|
170
|
+
// og:description captured from the homepage over generic boilerplate.
|
|
171
|
+
const summary = analysis.homePage?.description
|
|
172
|
+
|| `Site map and key resources for ${baseUrl}, generated to help LLMs locate relevant content.`;
|
|
161
173
|
lines.push(`> ${summary}`);
|
|
162
174
|
lines.push('');
|
|
163
175
|
|
|
@@ -177,13 +189,16 @@ export class GenerateLLMsTxtTool {
|
|
|
177
189
|
lines.push('');
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
// Helper: emit a "## Section" with a list of [name](url) links.
|
|
192
|
+
// Helper: emit a "## Section" with a list of [name](url) links. Link
|
|
193
|
+
// names prefer the page's actual <title> captured during analysis;
|
|
194
|
+
// otherwise a humanized full path ("/tag/abilities/page/1" ->
|
|
195
|
+
// "Tag: abilities — page 1") — never a bare trailing segment like "1".
|
|
196
|
+
const pageTitles = this.collectPageTitles(analysis);
|
|
181
197
|
const linkLabel = (u) => {
|
|
198
|
+
const captured = pageTitles.get(this.normalizeTitleKey(u));
|
|
199
|
+
if (captured) return captured;
|
|
182
200
|
try {
|
|
183
|
-
|
|
184
|
-
if (!p || p === '') return 'Home';
|
|
185
|
-
const seg = p.split('/').filter(Boolean).pop() || p;
|
|
186
|
-
return seg.replace(/[-_]/g, ' ').replace(/\.[a-z0-9]+$/i, '').trim() || p;
|
|
201
|
+
return this.humanizePath(new URL(u).pathname);
|
|
187
202
|
} catch {
|
|
188
203
|
return u;
|
|
189
204
|
}
|
|
@@ -247,6 +262,131 @@ export class GenerateLLMsTxtTool {
|
|
|
247
262
|
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
|
|
248
263
|
}
|
|
249
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Build a URL -> page title map from titles captured during analysis
|
|
267
|
+
* (classifyPage stores each public page's <title>; the homepage fetch names
|
|
268
|
+
* the site root). A title shared verbatim by multiple URLs is dropped — it
|
|
269
|
+
* cannot identify a specific page, so those links fall back to the
|
|
270
|
+
* humanized path instead.
|
|
271
|
+
*/
|
|
272
|
+
collectPageTitles(analysis) {
|
|
273
|
+
const titles = new Map();
|
|
274
|
+
const counts = new Map();
|
|
275
|
+
const add = (url, title) => {
|
|
276
|
+
if (!url || typeof title !== 'string') return;
|
|
277
|
+
const clean = title.replace(/\s+/g, ' ').trim();
|
|
278
|
+
if (!clean) return;
|
|
279
|
+
const key = this.normalizeTitleKey(url);
|
|
280
|
+
const prev = titles.get(key);
|
|
281
|
+
if (prev === clean) return;
|
|
282
|
+
if (prev) counts.set(prev, counts.get(prev) - 1);
|
|
283
|
+
titles.set(key, clean);
|
|
284
|
+
counts.set(clean, (counts.get(clean) || 0) + 1);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
for (const entries of Object.values(analysis.contentTypes || {})) {
|
|
288
|
+
if (!Array.isArray(entries)) continue;
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
add(entry?.url, entry?.metadata?.title);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (analysis.homePage?.title && analysis.metadata?.baseUrl) {
|
|
294
|
+
add(analysis.metadata.baseUrl, analysis.homePage.title);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const [key, title] of titles) {
|
|
298
|
+
if (counts.get(title) > 1) titles.delete(key);
|
|
299
|
+
}
|
|
300
|
+
return titles;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
normalizeTitleKey(url) {
|
|
304
|
+
try {
|
|
305
|
+
return new URL(url).toString();
|
|
306
|
+
} catch {
|
|
307
|
+
return url;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Humanize a full URL path into a readable label:
|
|
313
|
+
* "/" -> "Home"
|
|
314
|
+
* "/login" -> "Login"
|
|
315
|
+
* "/author/Albert-Einstein" -> "Author: Albert Einstein"
|
|
316
|
+
* "/tag/abilities/page/1" -> "Tag: abilities — page 1"
|
|
317
|
+
* A trailing pagination segment ("page/2", "page-2", bare "2") is folded
|
|
318
|
+
* into "page N" so a link is never labeled by a bare number.
|
|
319
|
+
*/
|
|
320
|
+
humanizePath(pathname) {
|
|
321
|
+
let raw = pathname;
|
|
322
|
+
try { raw = decodeURIComponent(pathname); } catch { /* keep encoded */ }
|
|
323
|
+
const segments = raw.split('/').filter(Boolean);
|
|
324
|
+
if (segments.length === 0) return 'Home';
|
|
325
|
+
|
|
326
|
+
// Strip a file extension from the last segment, then de-slug.
|
|
327
|
+
segments[segments.length - 1] = segments[segments.length - 1].replace(/\.[a-z0-9]+$/i, '');
|
|
328
|
+
const clean = segments.map((s) => s.replace(/[-_]+/g, ' ').trim()).filter(Boolean);
|
|
329
|
+
if (clean.length === 0) return 'Home';
|
|
330
|
+
|
|
331
|
+
let pageNum = null;
|
|
332
|
+
const last = clean[clean.length - 1];
|
|
333
|
+
const pageMatch = last.match(/^page\s*(\d+)$/i);
|
|
334
|
+
if (pageMatch) {
|
|
335
|
+
pageNum = pageMatch[1];
|
|
336
|
+
clean.pop();
|
|
337
|
+
} else if (/^\d+$/.test(last)) {
|
|
338
|
+
pageNum = last;
|
|
339
|
+
clean.pop();
|
|
340
|
+
if (clean.length > 0 && /^pages?$/i.test(clean[clean.length - 1])) {
|
|
341
|
+
clean.pop();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (clean.length === 0) return pageNum ? `Page ${pageNum}` : 'Home';
|
|
345
|
+
|
|
346
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
347
|
+
let label = clean.length === 1
|
|
348
|
+
? cap(clean[0])
|
|
349
|
+
: `${cap(clean[0])}: ${clean.slice(1).join(' / ')}`;
|
|
350
|
+
if (pageNum) label += ` — page ${pageNum}`;
|
|
351
|
+
return label;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Fetch the site's homepage once and extract naming metadata for the spec
|
|
356
|
+
* output. Best-effort: returns null on any failure.
|
|
357
|
+
*/
|
|
358
|
+
async fetchHomePageMetadata(baseUrl) {
|
|
359
|
+
const controller = new AbortController();
|
|
360
|
+
const timeoutId = setTimeout(() => controller.abort(), Math.min(this.options.timeout, 10000));
|
|
361
|
+
try {
|
|
362
|
+
const response = await safeFetch(baseUrl, {
|
|
363
|
+
signal: controller.signal,
|
|
364
|
+
headers: { 'User-Agent': this.options.userAgent }
|
|
365
|
+
});
|
|
366
|
+
if (!response.ok) return null;
|
|
367
|
+
return this.extractHomePageMetadata(await response.text());
|
|
368
|
+
} catch {
|
|
369
|
+
return null;
|
|
370
|
+
} finally {
|
|
371
|
+
clearTimeout(timeoutId);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Extract { title, description } from homepage HTML. Title prefers <title>
|
|
377
|
+
* then the first <h1>; description prefers meta[name="description"] then
|
|
378
|
+
* og:description. Returns null when neither is present.
|
|
379
|
+
*/
|
|
380
|
+
extractHomePageMetadata(html) {
|
|
381
|
+
const $ = load(html);
|
|
382
|
+
const clean = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
|
383
|
+
const title = clean($('title').first().text()) || clean($('h1').first().text());
|
|
384
|
+
const description = clean($('meta[name="description"]').attr('content'))
|
|
385
|
+
|| clean($('meta[property="og:description"]').attr('content'));
|
|
386
|
+
if (!title && !description) return null;
|
|
387
|
+
return { title, description };
|
|
388
|
+
}
|
|
389
|
+
|
|
250
390
|
/**
|
|
251
391
|
* Generate legacy robots.txt-style content (opt-in via outputOptions.robotsStyle).
|
|
252
392
|
*/
|
|
@@ -183,8 +183,11 @@ const TEMPLATES = [
|
|
|
183
183
|
site: $row.find('.sitebit a').text().trim() || null,
|
|
184
184
|
score: $score.text().replace(' points', '').trim() || null,
|
|
185
185
|
author: $subtext.find('.hnuser').text().trim() || null,
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
// ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
|
|
187
|
+
posted: $subtext.find('.age a').text().trim() || null,
|
|
188
|
+
// The comments link is also an item?id= link, so exclude the age anchor.
|
|
189
|
+
// Job posts have no comments link at all -> null.
|
|
190
|
+
comments: $subtext.find('a[href*="item"]').not('.age a').last().text().trim() || null
|
|
188
191
|
});
|
|
189
192
|
});
|
|
190
193
|
return { stories: stories.slice(0, 30), scraped_at: new Date().toISOString() };
|
|
@@ -68,7 +68,10 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
68
68
|
// Scheduled-monitor subsystem (timers are NOT started here — only the
|
|
69
69
|
// single server-owned instance calls startScheduler()).
|
|
70
70
|
this._mcpServer = null;
|
|
71
|
-
|
|
71
|
+
// No storageDir fallback here: MonitorStore itself defaults to
|
|
72
|
+
// ~/.crawlforge/monitors (cwd-independent, like snapshotStorageDir above)
|
|
73
|
+
// and runs its legacy ./monitors migration only on that default path.
|
|
74
|
+
this.monitorStore = new MonitorStore({ storageDir: this.options.monitorStorageDir });
|
|
72
75
|
this.scheduler = new MonitorScheduler({ tool: this, store: this.monitorStore });
|
|
73
76
|
|
|
74
77
|
// Wired synchronously (no I/O) so no 'error' event emitted by
|
|
@@ -200,14 +203,18 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
200
203
|
snapshotInfo = await this.snapshotManager.storeSnapshot(url, sourceContent, { ...fetchMeta, baseline: true, trackingOptions }, { enableCompression: storageOptions.compressionEnabled });
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
// ChangeTracker.createBaseline returns a summary ({contentHash, sections,
|
|
207
|
+
// elements, createdAt, ...}), NOT the internal baseline object — reading
|
|
208
|
+
// baseline.analysis?./baseline.timestamp here always yielded
|
|
209
|
+
// undefined/0/0/undefined regardless of the page.
|
|
203
210
|
return {
|
|
204
211
|
success: true, operation: 'create_baseline', url,
|
|
205
212
|
baseline: {
|
|
206
213
|
version: baseline.version,
|
|
207
|
-
contentHash: baseline.
|
|
208
|
-
sections:
|
|
209
|
-
elements:
|
|
210
|
-
createdAt: baseline.
|
|
214
|
+
contentHash: baseline.contentHash,
|
|
215
|
+
sections: baseline.sections,
|
|
216
|
+
elements: baseline.elements,
|
|
217
|
+
createdAt: baseline.createdAt,
|
|
211
218
|
options: trackingOptions
|
|
212
219
|
},
|
|
213
220
|
snapshot: snapshotInfo, timestamp: Date.now()
|