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.
@@ -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 small
452
- * positional bonus for leading/closing sentences. Selects the top N and
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
- // Leading/closing sentences tend to carry more salience in prose.
481
- const positionScore = (index === 0 || index === sentences.length - 1) ? 0.15 : 0;
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
- const cleaned = phrase.toLowerCase().trim();
512
- if (cleaned.length > 2) {
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.min(1, frequency / Math.max(allPhrases.length, 1)),
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
- const people = doc.people().out('array');
547
- const places = doc.places().out('array');
548
- const organizations = doc.organizations().out('array');
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
- const supplemental = [...new Set(properNouns)]
563
- .filter(n => !existingEntities.has(n.toLowerCase()) && n.length > 1)
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 += `# ${title}\n\n`;
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 = $('body').text().replace(/\s+/g, ' ').trim();
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
- const summary = `Site map and key resources for ${baseUrl}, generated to help LLMs locate relevant content.`;
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
- const p = new URL(u).pathname.replace(/\/+$/, '');
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
- posted: $subtext.find('.age a').attr('href') || null,
187
- comments: $subtext.find('a[href*="item"]').last().text().trim() || null
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
- this.monitorStore = new MonitorStore({ storageDir: this.options.monitorStorageDir || './monitors' });
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.analysis?.hashes?.page,
208
- sections: Object.keys(baseline.analysis?.hashes?.sections || {}).length,
209
- elements: Object.keys(baseline.analysis?.hashes?.elements || {}).length,
210
- createdAt: baseline.timestamp,
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()