@gresmcp/mcp 1.1.0 → 1.2.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/dist/cli.js CHANGED
@@ -446,17 +446,57 @@ function turndown() {
446
446
  }
447
447
  return service;
448
448
  }
449
- function htmlToMarkdown(html) {
449
+ function parseHtmlFilter(spec) {
450
+ const steps = spec.split(/\s*->\s*/).map((s) => s.trim()).filter(Boolean);
451
+ if (steps.length === 0) {
452
+ throw new Error(`invalid --html-filter '${spec.trim()}': expected a CSS selector chain, e.g. '#article -> .content'`);
453
+ }
454
+ return steps;
455
+ }
456
+ function validateCssSelector(selector) {
457
+ const dom = new JSDOM();
458
+ try {
459
+ dom.window.document.querySelector(selector);
460
+ } catch (err) {
461
+ throw new Error(`invalid CSS selector '${selector}': ${err instanceof Error ? err.message : err}`);
462
+ }
463
+ }
464
+ function selectChain(scope, steps) {
465
+ for (const step of steps.slice(0, -1)) {
466
+ const next = scope.querySelector(step);
467
+ if (!next) return void 0;
468
+ scope = next;
469
+ }
470
+ const matches = [...scope.querySelectorAll(steps[steps.length - 1])];
471
+ return matches.length > 0 ? matches : void 0;
472
+ }
473
+ function elementsToMarkdown(elements) {
474
+ const parts = [];
475
+ for (const el of elements) {
476
+ try {
477
+ parts.push(turndown().turndown(el.innerHTML));
478
+ } catch {
479
+ parts.push(el.textContent ?? "");
480
+ }
481
+ }
482
+ return parts.join("\n\n");
483
+ }
484
+ function htmlToMarkdown(html, opts = {}) {
450
485
  const dom = new JSDOM(html);
451
486
  const doc = dom.window.document;
452
487
  doc.querySelectorAll(REMOVE_SELECTORS).forEach((el) => el.remove());
453
488
  const title = doc.title?.trim() || doc.querySelector("h1")?.textContent?.trim() || void 0;
454
- const root = doc.body ?? doc.documentElement;
455
489
  let markdown = "";
456
- try {
457
- markdown = turndown().turndown(root.innerHTML);
458
- } catch {
459
- markdown = root.textContent ?? "";
490
+ if (opts.filter) {
491
+ const elements = selectChain(doc, parseHtmlFilter(opts.filter));
492
+ if (elements) markdown = elementsToMarkdown(elements);
493
+ } else {
494
+ const root = doc.body ?? doc.documentElement;
495
+ try {
496
+ markdown = turndown().turndown(root.innerHTML);
497
+ } catch {
498
+ markdown = root.textContent ?? "";
499
+ }
460
500
  }
461
501
  markdown = markdown.replace(/\n{3,}/g, "\n\n").trim();
462
502
  return { markdown, title: title || void 0 };
@@ -490,7 +530,7 @@ function collectTags(doc) {
490
530
  return true;
491
531
  });
492
532
  }
493
- function extractPage(html) {
533
+ function extractPage(html, opts = {}) {
494
534
  const dom = new JSDOM(html);
495
535
  const doc = dom.window.document;
496
536
  const title = doc.title?.trim() || doc.querySelector('meta[property="og:title"]')?.getAttribute("content")?.trim() || doc.querySelector("h1")?.textContent?.trim() || void 0;
@@ -501,20 +541,27 @@ function extractPage(html) {
501
541
  const tags = collectTags(doc);
502
542
  doc.querySelectorAll(REMOVE_SELECTORS).forEach((el) => el.remove());
503
543
  let markdown = "";
504
- try {
505
- if (isProbablyReaderable(doc)) {
506
- const article = new Readability(doc).parse();
507
- if (article?.content) markdown = turndown().turndown(article.content);
508
- }
509
- } catch {
510
- markdown = "";
511
- }
512
- if (!markdown.trim()) {
513
- const root = doc.body ?? doc.documentElement;
544
+ let filterMatched;
545
+ if (opts.filter) {
546
+ const elements = selectChain(doc, parseHtmlFilter(opts.filter));
547
+ filterMatched = elements !== void 0;
548
+ if (elements) markdown = elementsToMarkdown(elements);
549
+ } else {
514
550
  try {
515
- markdown = turndown().turndown(root.innerHTML);
551
+ if (isProbablyReaderable(doc)) {
552
+ const article = new Readability(doc).parse();
553
+ if (article?.content) markdown = turndown().turndown(article.content);
554
+ }
516
555
  } catch {
517
- markdown = root.textContent ?? "";
556
+ markdown = "";
557
+ }
558
+ if (!markdown.trim()) {
559
+ const root = doc.body ?? doc.documentElement;
560
+ try {
561
+ markdown = turndown().turndown(root.innerHTML);
562
+ } catch {
563
+ markdown = root.textContent ?? "";
564
+ }
518
565
  }
519
566
  }
520
567
  markdown = markdown.replace(/\n{3,}/g, "\n\n").trim();
@@ -524,7 +571,8 @@ function extractPage(html) {
524
571
  title,
525
572
  description,
526
573
  tags: tags.length > 0 ? tags : void 0,
527
- likelyShell
574
+ likelyShell,
575
+ filterMatched
528
576
  };
529
577
  }
530
578
 
@@ -544,14 +592,16 @@ function bar(pct2) {
544
592
  function pct(p) {
545
593
  return `${Math.round(Math.max(0, Math.min(1, p)) * 100)}%`;
546
594
  }
595
+ var NOOP_HANDLE = { handled: () => {
596
+ } };
547
597
  var FeedProgress = class {
548
598
  write;
549
599
  interactive;
550
600
  total;
551
601
  rejected;
602
+ slots = [];
552
603
  filesDone = 0;
553
604
  totalChars = 0;
554
- current;
555
605
  blockLines = 0;
556
606
  finished = false;
557
607
  constructor(opts) {
@@ -564,26 +614,13 @@ var FeedProgress = class {
564
614
  return this.interactive;
565
615
  }
566
616
  startFile(source, totalChunks, totalChars) {
567
- if (this.finished) return;
568
- this.current = { source, totalChunks, totalChars, doneChunks: 0, doneChars: 0, printedChunks: 0 };
617
+ if (this.finished) return NOOP_HANDLE;
618
+ const slot = { source, totalChunks, totalChars, doneChunks: 0, doneChars: 0, printedChunks: 0 };
619
+ this.slots.push(slot);
569
620
  this.render();
570
- }
571
- handled(chunks, chars) {
572
- if (this.finished || !this.current || chunks <= 0) return;
573
- const c = this.current;
574
- c.doneChunks += chunks;
575
- c.doneChars += chars;
576
- this.totalChars += chars;
577
- if (c.doneChunks >= c.totalChunks) {
578
- this.filesDone++;
579
- this.printFileLine();
580
- return;
581
- }
582
- if (!this.interactive && c.doneChunks - c.printedChunks >= PLAIN_STEP) {
583
- this.printFileLine();
584
- } else if (this.interactive) {
585
- this.render();
586
- }
621
+ return {
622
+ handled: (chunks, chars) => this.handleSlot(slot, chunks, chars)
623
+ };
587
624
  }
588
625
  log(msg) {
589
626
  if (this.finished) return;
@@ -594,9 +631,9 @@ var FeedProgress = class {
594
631
  finish() {
595
632
  if (this.finished) return;
596
633
  this.finished = true;
597
- if (!this.current && this.total === 0 && this.rejected === 0) return;
634
+ if (this.slots.length === 0 && this.total === 0 && this.rejected === 0) return;
598
635
  if (this.interactive) {
599
- this.redraw([this.fileLine(), this.totalLine()]);
636
+ this.redraw(this.lines());
600
637
  this.write("\n");
601
638
  this.blockLines = 0;
602
639
  } else {
@@ -609,25 +646,42 @@ var FeedProgress = class {
609
646
  this.finished = true;
610
647
  this.clearBlock();
611
648
  }
649
+ lines() {
650
+ return [...this.slots.map((slot) => this.fileLine(slot)), this.totalLine()];
651
+ }
652
+ handleSlot(slot, chunks, chars) {
653
+ if (this.finished || chunks <= 0 || slot.doneChunks >= slot.totalChunks) return;
654
+ slot.doneChunks += chunks;
655
+ slot.doneChars += chars;
656
+ this.totalChars += chars;
657
+ if (slot.doneChunks >= slot.totalChunks) {
658
+ this.filesDone++;
659
+ if (this.interactive) {
660
+ this.render();
661
+ } else {
662
+ this.printFileLine(slot);
663
+ }
664
+ this.slots.splice(this.slots.indexOf(slot), 1);
665
+ return;
666
+ }
667
+ if (!this.interactive && slot.doneChunks - slot.printedChunks >= PLAIN_STEP) {
668
+ this.printFileLine(slot);
669
+ } else if (this.interactive) {
670
+ this.render();
671
+ }
672
+ }
612
673
  render() {
613
674
  if (!this.interactive) return;
614
- this.redraw([this.fileLine(), this.totalLine()]);
675
+ this.redraw(this.lines());
615
676
  }
616
- printFileLine() {
617
- if (!this.current) return;
618
- this.current.printedChunks = this.current.doneChunks;
619
- if (this.interactive) {
620
- this.render();
621
- } else {
622
- this.write(`${this.fileLine()}
677
+ printFileLine(slot) {
678
+ slot.printedChunks = slot.doneChunks;
679
+ this.write(`${this.fileLine(slot)}
623
680
  `);
624
- }
625
681
  }
626
- fileLine() {
627
- const c = this.current;
628
- if (!c) return "";
629
- const p = c.totalChunks > 0 ? c.doneChunks / c.totalChunks : 1;
630
- return `[${bar(p)}] ${pct(p).padStart(4)} ${fmt(c.doneChunks)}/${fmt(c.totalChunks)} chunks ${fmt(c.doneChars)}/${fmt(c.totalChars)} chars ${c.source}`;
682
+ fileLine(slot) {
683
+ const p = slot.totalChunks > 0 ? slot.doneChunks / slot.totalChunks : 1;
684
+ return `[${bar(p)}] ${pct(p).padStart(4)} ${fmt(slot.doneChunks)}/${fmt(slot.totalChunks)} chunks ${fmt(slot.doneChars)}/${fmt(slot.totalChars)} chars ${slot.source}`;
631
685
  }
632
686
  totalLine() {
633
687
  const p = this.total > 0 ? this.filesDone / this.total : 0;
@@ -792,14 +846,17 @@ async function loadTarget(target, opts) {
792
846
  let text = raw;
793
847
  let title = "";
794
848
  if (HTML_EXT.has(ext)) {
795
- const conv = htmlToMarkdown(raw);
849
+ const conv = htmlToMarkdown(raw, { filter: opts.htmlFilter });
796
850
  text = conv.markdown;
797
851
  title = conv.title ?? "";
798
852
  } else if (MD_EXT.has(ext)) {
799
853
  title = mdTitle(raw) ?? "";
800
854
  }
801
855
  if (!text.trim()) {
802
- skipped.push({ path: file, reason: "empty" });
856
+ skipped.push({
857
+ path: file,
858
+ reason: HTML_EXT.has(ext) && opts.htmlFilter ? `html filter '${opts.htmlFilter}' matched nothing` : "empty"
859
+ });
803
860
  continue;
804
861
  }
805
862
  if (!title) title = path.basename(file);
@@ -812,6 +869,7 @@ async function loadTarget(target, opts) {
812
869
  }
813
870
  return { docs, fileCount: files.length, skipped };
814
871
  }
872
+ var DOC_CONCURRENCY = 3;
815
873
  async function feedDocs(ks, docs, opts = {}) {
816
874
  const log2 = opts.log ?? ((m) => console.log(m));
817
875
  const progress = opts.progress;
@@ -823,61 +881,78 @@ async function feedDocs(ks, docs, opts = {}) {
823
881
  let skippedExisting = 0;
824
882
  let inserted = 0;
825
883
  let announcedEmbed = false;
826
- try {
827
- for (const doc of docs) {
828
- progress?.startFile(
829
- doc.source,
830
- doc.chunks.length,
831
- doc.chunks.reduce((n, c) => n + c.content.length, 0)
832
- );
833
- if (opts.replace) {
834
- deletedForReplace += await deleteBySource(ks, doc.source);
835
- }
836
- const existing = await sourceChunkHashes(ks, doc.source);
837
- const meta = { ...baseMeta, ...doc.metadata ?? {} };
838
- if (!("tags" in meta) && cliTags) meta.tags = cliTags;
839
- const todo = [];
840
- for (const chunk of doc.chunks) {
841
- if (!opts.replace && existing.get(chunk.index) === contentHash(chunk.content)) {
842
- skippedExisting++;
843
- progress?.handled(1, chunk.content.length);
844
- continue;
845
- }
846
- todo.push(chunk);
847
- }
848
- if (todo.length === 0) continue;
849
- if (opts.dryRun) {
850
- for (const chunk of todo) progress?.handled(1, chunk.content.length);
884
+ const processDoc = async (doc) => {
885
+ const file = progress?.startFile(
886
+ doc.source,
887
+ doc.chunks.length,
888
+ doc.chunks.reduce((n, c) => n + c.content.length, 0)
889
+ );
890
+ if (opts.replace) {
891
+ const deleted = await deleteBySource(ks, doc.source);
892
+ deletedForReplace += deleted;
893
+ }
894
+ const existing = await sourceChunkHashes(ks, doc.source);
895
+ const meta = { ...baseMeta, ...doc.metadata ?? {} };
896
+ if (!("tags" in meta) && cliTags) meta.tags = cliTags;
897
+ const todo = [];
898
+ for (const chunk of doc.chunks) {
899
+ if (!opts.replace && existing.get(chunk.index) === contentHash(chunk.content)) {
900
+ skippedExisting++;
901
+ file?.handled(1, chunk.content.length);
851
902
  continue;
852
903
  }
853
- if (!announcedEmbed) {
854
- say(`Embedding with ${ks.embedding_model} at ${ks.ollama_url} ...`);
855
- announcedEmbed = true;
904
+ todo.push(chunk);
905
+ }
906
+ if (todo.length === 0) return;
907
+ if (opts.dryRun) {
908
+ for (const chunk of todo) file?.handled(1, chunk.content.length);
909
+ return;
910
+ }
911
+ if (!announcedEmbed) {
912
+ announcedEmbed = true;
913
+ say(`Embedding with ${ks.embedding_model} at ${ks.ollama_url} ...`);
914
+ }
915
+ const contents = todo.map((c) => c.content);
916
+ const charLens = contents.map((c) => c.length);
917
+ let reported = 0;
918
+ const embeddings = await embedBatch(ks.ollama_url, ks.embedding_model, contents, batchSize, (done) => {
919
+ const delta = done - reported;
920
+ if (delta <= 0) return;
921
+ let chars = 0;
922
+ for (let i = reported; i < done; i++) chars += charLens[i];
923
+ reported = done;
924
+ file?.handled(delta, chars);
925
+ });
926
+ const rows = todo.map((chunk, i) => ({
927
+ source: doc.source,
928
+ title: doc.title,
929
+ chunk_index: chunk.index,
930
+ content: chunk.content,
931
+ metadata: meta,
932
+ embedding: embeddings[i]
933
+ }));
934
+ const rowCount = await insertChunks(ks, rows);
935
+ inserted += rowCount;
936
+ };
937
+ let cursor = 0;
938
+ let firstError;
939
+ const runWorker = async () => {
940
+ while (firstError === void 0) {
941
+ const index = cursor++;
942
+ if (index >= docs.length) return;
943
+ try {
944
+ await processDoc(docs[index]);
945
+ } catch (err) {
946
+ firstError ??= err;
947
+ return;
856
948
  }
857
- const contents = todo.map((c) => c.content);
858
- const charLens = contents.map((c) => c.length);
859
- let reported = 0;
860
- const embeddings = await embedBatch(ks.ollama_url, ks.embedding_model, contents, batchSize, (done) => {
861
- const delta = done - reported;
862
- if (delta <= 0) return;
863
- let chars = 0;
864
- for (let i = reported; i < done; i++) chars += charLens[i];
865
- reported = done;
866
- progress?.handled(delta, chars);
867
- });
868
- const rows = todo.map((chunk, i) => ({
869
- source: doc.source,
870
- title: doc.title,
871
- chunk_index: chunk.index,
872
- content: chunk.content,
873
- metadata: meta,
874
- embedding: embeddings[i]
875
- }));
876
- inserted += await insertChunks(ks, rows);
877
949
  }
878
- } catch (err) {
950
+ };
951
+ const workerCount = Math.min(DOC_CONCURRENCY, docs.length);
952
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
953
+ if (firstError !== void 0) {
879
954
  progress?.close();
880
- throw err;
955
+ throw firstError;
881
956
  }
882
957
  progress?.finish();
883
958
  return {
@@ -891,7 +966,7 @@ async function feedDocs(ks, docs, opts = {}) {
891
966
  };
892
967
  }
893
968
  async function feedTarget(ks, target, opts = {}) {
894
- const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180 };
969
+ const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180, htmlFilter: opts.htmlFilter };
895
970
  const { docs, skipped } = await loadTarget(target, loadOpts);
896
971
  for (const s of skipped) {
897
972
  (opts.log ?? ((m) => console.log(m)))(`skipped ${s.path}: ${s.reason}`);
@@ -915,8 +990,30 @@ import { mkdtempSync } from "fs";
915
990
  import { tmpdir } from "os";
916
991
  import path2 from "path";
917
992
  import { randomUUID } from "crypto";
918
- import { CheerioCrawler, PlaywrightCrawler, PuppeteerCrawler, RequestQueue, log } from "crawlee";
993
+ import {
994
+ CheerioCrawler,
995
+ PlaywrightCrawler,
996
+ PuppeteerCrawler,
997
+ Request as CrawleeRequest,
998
+ RequestQueue,
999
+ RobotsTxtFile,
1000
+ Sitemap,
1001
+ constructGlobObjectsFromGlobs,
1002
+ filterRequestsByPatterns,
1003
+ log
1004
+ } from "crawlee";
919
1005
  var CRAWLER_KINDS = ["auto", "crawlee", "playwright", "puppeteer"];
1006
+ function parseSitemapOption(value) {
1007
+ const trimmed = value.trim();
1008
+ if (!trimmed) {
1009
+ throw new Error("invalid --sitemap '': use auto, sitemap-only, html-only or a CSS selector");
1010
+ }
1011
+ const key = trimmed.toLowerCase();
1012
+ if (key === "auto") return { kind: "auto" };
1013
+ if (key === "sitemap-only") return { kind: "sitemap-only" };
1014
+ if (key === "html-only") return { kind: "html-only" };
1015
+ return { kind: "selector", selector: trimmed };
1016
+ }
920
1017
  var BINARY_LINK_RE = /\.(css|js|mjs|cjs|map|png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?|woff2?|ttf|otf|eot|pdf|zip|gz|tgz|tar|rar|7z|bz2|xz|zst|mp3|mp4|m4a|webm|mov|avi|wmv|flv|docx?|xlsx?|pptx?|odt|ods|odp|rtf|dmg|exe|msi|apk|iso|bin|wasm|deb|rpm)(\?.*)?$/i;
921
1018
  process.env.CRAWLEE_STORAGE_DIR ??= mkdtempSync(path2.join(tmpdir(), "gresmcp-crawl-"));
922
1019
  async function freshQueue() {
@@ -1006,7 +1103,7 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1006
1103
  maxCrawlDepth: Math.max(0, opts.depth),
1007
1104
  additionalMimeTypes: ["text/plain", "text/markdown"],
1008
1105
  async requestHandler(ctx) {
1009
- const { request, response, contentType, body } = ctx;
1106
+ const { request, response, contentType, body, $ } = ctx;
1010
1107
  const url = request.loadedUrl ?? request.url;
1011
1108
  const status = responseStatus(response);
1012
1109
  if (status === void 0 || status < 200 || status >= 300) {
@@ -1016,12 +1113,16 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1016
1113
  const type = contentType?.type ?? "";
1017
1114
  if (type === "text/html" || type === "application/xhtml+xml") {
1018
1115
  const html = typeof body === "string" ? body : toText(body);
1019
- const page = extractPage(html);
1116
+ const page = extractPage(html, { filter: opts.htmlFilter });
1020
1117
  if (page.likelyShell) {
1021
1118
  sink.shellUrls?.push(url);
1022
1119
  sink.skipped.push({ path: url, reason: "likely JS-rendered shell" });
1023
1120
  return;
1024
1121
  }
1122
+ if (opts.htmlFilter && !page.filterMatched) {
1123
+ sink.skipped.push({ path: url, reason: `html filter '${opts.htmlFilter}' matched nothing` });
1124
+ return;
1125
+ }
1025
1126
  if (!page.markdown.trim()) {
1026
1127
  sink.skipped.push({ path: url, reason: "empty" });
1027
1128
  return;
@@ -1032,7 +1133,28 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1032
1133
  return;
1033
1134
  }
1034
1135
  sink.docs.push(doc);
1035
- await ctx.enqueueLinks({ strategy: "same-hostname", exclude: [BINARY_LINK_RE] });
1136
+ if (opts.followLinks !== false) {
1137
+ const selector = opts.linkSelector;
1138
+ if (selector) {
1139
+ if ($(selector).length > 0) {
1140
+ opts.selectorStats.hits += 1;
1141
+ await ctx.enqueueLinks({
1142
+ selector,
1143
+ strategy: "same-hostname",
1144
+ exclude: [BINARY_LINK_RE],
1145
+ globs: opts.urlFilter
1146
+ });
1147
+ } else {
1148
+ opts.selectorStats.misses += 1;
1149
+ }
1150
+ } else {
1151
+ await ctx.enqueueLinks({
1152
+ strategy: "same-hostname",
1153
+ exclude: [BINARY_LINK_RE],
1154
+ globs: opts.urlFilter
1155
+ });
1156
+ }
1157
+ }
1036
1158
  } else if (type === "text/markdown") {
1037
1159
  const doc = buildDoc(url, "md", toText(body), void 0, sinkDeps(opts));
1038
1160
  if (!doc) {
@@ -1064,7 +1186,16 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1064
1186
  }
1065
1187
  }
1066
1188
  function sinkDeps(opts) {
1067
- return { maxLen: opts.maxLen, overlap: opts.overlap, cliTags: opts.tags };
1189
+ return {
1190
+ maxLen: opts.maxLen,
1191
+ overlap: opts.overlap,
1192
+ cliTags: opts.tags,
1193
+ urlFilter: opts.urlFilter,
1194
+ htmlFilter: opts.htmlFilter,
1195
+ followLinks: opts.followLinks,
1196
+ linkSelector: opts.linkSelector,
1197
+ selectorStats: opts.selectorStats
1198
+ };
1068
1199
  }
1069
1200
  async function importOptional(name) {
1070
1201
  try {
@@ -1090,7 +1221,11 @@ async function handleBrowserPage(ctx, deps) {
1090
1221
  return;
1091
1222
  }
1092
1223
  const html = await ctx.page.content();
1093
- const page = extractPage(html);
1224
+ const page = extractPage(html, { filter: deps.htmlFilter });
1225
+ if (deps.htmlFilter && !page.filterMatched) {
1226
+ deps.sink.skipped.push({ path: url, reason: `html filter '${deps.htmlFilter}' matched nothing` });
1227
+ return;
1228
+ }
1094
1229
  if (!page.markdown.trim()) {
1095
1230
  deps.sink.skipped.push({ path: url, reason: "empty after render" });
1096
1231
  return;
@@ -1101,7 +1236,29 @@ async function handleBrowserPage(ctx, deps) {
1101
1236
  return;
1102
1237
  }
1103
1238
  deps.sink.docs.push(doc);
1104
- await ctx.enqueueLinks({ strategy: "same-hostname", exclude: [BINARY_LINK_RE] });
1239
+ if (deps.followLinks !== false) {
1240
+ const selector = deps.linkSelector;
1241
+ if (selector) {
1242
+ const elements = await ctx.page.$$(selector);
1243
+ if (elements.length > 0) {
1244
+ deps.selectorStats.hits += 1;
1245
+ await ctx.enqueueLinks({
1246
+ selector,
1247
+ strategy: "same-hostname",
1248
+ exclude: [BINARY_LINK_RE],
1249
+ globs: deps.urlFilter
1250
+ });
1251
+ } else {
1252
+ deps.selectorStats.misses += 1;
1253
+ }
1254
+ } else {
1255
+ await ctx.enqueueLinks({
1256
+ strategy: "same-hostname",
1257
+ exclude: [BINARY_LINK_RE],
1258
+ globs: deps.urlFilter
1259
+ });
1260
+ }
1261
+ }
1105
1262
  }
1106
1263
  async function crawlWithBrowser(kind, queue, seedUrls, opts, sink) {
1107
1264
  const crawler = kind === "playwright" ? new PlaywrightCrawler({
@@ -1142,20 +1299,66 @@ async function crawlWithBrowser(kind, queue, seedUrls, opts, sink) {
1142
1299
  });
1143
1300
  }
1144
1301
  }
1145
- async function crawlWebsite(seed, opts) {
1146
- const seedUrl = normalizeSeedUrl(seed);
1147
- const kind = opts.crawler;
1302
+ function normalizeSitemapUrls(urls) {
1303
+ const out = [];
1304
+ const seen = /* @__PURE__ */ new Set();
1305
+ for (const raw of urls) {
1306
+ let url;
1307
+ try {
1308
+ url = new URL(raw);
1309
+ } catch {
1310
+ continue;
1311
+ }
1312
+ if (url.protocol !== "http:" && url.protocol !== "https:") continue;
1313
+ url.hash = "";
1314
+ const key = url.toString();
1315
+ if (seen.has(key)) continue;
1316
+ seen.add(key);
1317
+ out.push(key);
1318
+ }
1319
+ return out;
1320
+ }
1321
+ function applyUrlFilter(urls, globs) {
1322
+ if (!globs || globs.length === 0) return urls;
1323
+ const requests = urls.map((url) => new CrawleeRequest({ url }));
1324
+ const patterns = constructGlobObjectsFromGlobs(globs);
1325
+ return filterRequestsByPatterns(requests, patterns).map((r) => r.url);
1326
+ }
1327
+ async function loadSitemap(urls) {
1328
+ try {
1329
+ const sitemap = await Sitemap.load(urls, void 0, { reportNetworkErrors: false });
1330
+ return normalizeSitemapUrls(sitemap.urls);
1331
+ } catch {
1332
+ return [];
1333
+ }
1334
+ }
1335
+ async function discoverSitemapUrls(seedUrl) {
1336
+ const origin = new URL(seedUrl).origin;
1337
+ const fromSitemapXml = await loadSitemap([`${origin}/sitemap.xml`]);
1338
+ if (fromSitemapXml.length > 0) return fromSitemapXml;
1339
+ try {
1340
+ const robots = await RobotsTxtFile.find(seedUrl);
1341
+ const referenced = robots.getSitemaps();
1342
+ if (referenced.length > 0) {
1343
+ const fromRobots = await loadSitemap(referenced);
1344
+ if (fromRobots.length > 0) return fromRobots;
1345
+ }
1346
+ } catch {
1347
+ }
1348
+ return void 0;
1349
+ }
1350
+ async function runCrawl(kind, seedUrls, opts) {
1148
1351
  if (kind === "playwright" || kind === "puppeteer") {
1149
1352
  await requireBrowserEngine(kind);
1150
1353
  const sink2 = { docs: [], skipped: [] };
1151
1354
  const queue = await freshQueue();
1152
- await crawlWithBrowser(kind, queue, [seedUrl], opts, sink2);
1355
+ await crawlWithBrowser(kind, queue, seedUrls, opts, sink2);
1153
1356
  return { docs: dedupeDocs(sink2.docs), skipped: sink2.skipped };
1154
1357
  }
1155
1358
  const shellUrls = [];
1156
1359
  const sink = { docs: [], skipped: [], shellUrls };
1157
1360
  const staticQueue = await freshQueue();
1158
- await crawlWithCheerio(staticQueue, [seedUrl], opts, sink);
1361
+ await crawlWithCheerio(staticQueue, seedUrls, opts, sink);
1159
1362
  if (kind !== "auto" || shellUrls.length === 0) {
1160
1363
  return { docs: dedupeDocs(sink.docs), skipped: sink.skipped };
1161
1364
  }
@@ -1174,6 +1377,36 @@ async function crawlWebsite(seed, opts) {
1174
1377
  skipped: [...sink.skipped, ...browserSink.skipped]
1175
1378
  };
1176
1379
  }
1380
+ async function crawlWebsite(seed, opts) {
1381
+ const seedUrl = normalizeSeedUrl(seed);
1382
+ const kind = opts.crawler;
1383
+ const mode = opts.sitemap ?? { kind: "auto" };
1384
+ const selectorStats = { hits: 0, misses: 0 };
1385
+ if (mode.kind === "auto" || mode.kind === "sitemap-only") {
1386
+ const sitemapUrls = await discoverSitemapUrls(seedUrl);
1387
+ if (sitemapUrls) {
1388
+ const filtered = applyUrlFilter(sitemapUrls, opts.urlFilter);
1389
+ const detail = filtered.length < sitemapUrls.length ? `${filtered.length} matching --url-filter` : `${filtered.length} URL(s)`;
1390
+ opts.log(`sitemap: ${sitemapUrls.length} URL(s) discovered, ${detail} to crawl`);
1391
+ return runCrawl(kind, filtered, { ...opts, followLinks: false, selectorStats });
1392
+ }
1393
+ if (mode.kind === "sitemap-only") {
1394
+ opts.log(`warning: no sitemap.xml and no sitemaps in robots.txt found for ${seedUrl}; nothing to ingest`);
1395
+ return { docs: [], skipped: [] };
1396
+ }
1397
+ opts.log("no sitemap found; falling back to HTML link crawling");
1398
+ }
1399
+ const result = await runCrawl(kind, [seedUrl], {
1400
+ ...opts,
1401
+ followLinks: true,
1402
+ linkSelector: mode.kind === "selector" ? mode.selector : void 0,
1403
+ selectorStats
1404
+ });
1405
+ if (mode.kind === "selector" && selectorStats.hits === 0 && selectorStats.misses > 0) {
1406
+ opts.log(`warning: --sitemap selector '${mode.selector}' matched no links`);
1407
+ }
1408
+ return result;
1409
+ }
1177
1410
  async function feedUrl(ks, url, opts = {}) {
1178
1411
  const log2 = opts.log ?? ((m) => console.log(m));
1179
1412
  const kind = opts.crawler ?? "auto";
@@ -1191,6 +1424,9 @@ async function feedUrl(ks, url, opts = {}) {
1191
1424
  maxLen,
1192
1425
  overlap,
1193
1426
  tags: opts.tags,
1427
+ urlFilter: opts.urlFilter,
1428
+ htmlFilter: opts.htmlFilter,
1429
+ sitemap: opts.sitemap,
1194
1430
  log: log2
1195
1431
  });
1196
1432
  for (const s of skipped) {
@@ -1434,7 +1670,7 @@ async function runChecks(opts) {
1434
1670
  }
1435
1671
 
1436
1672
  // src/version.ts
1437
- var VERSION = "1.1.0";
1673
+ var VERSION = "1.2.0";
1438
1674
 
1439
1675
  // src/cli.ts
1440
1676
  async function run(fn) {
@@ -1475,6 +1711,11 @@ function parseTags(tags) {
1475
1711
  const list = tags.split(",").map((t) => t.trim()).filter(Boolean);
1476
1712
  return list.length > 0 ? list : void 0;
1477
1713
  }
1714
+ function parseGlobs(globs) {
1715
+ if (!globs || globs.length === 0) return void 0;
1716
+ const list = [...new Set(globs.map((g) => g.trim()).filter(Boolean))];
1717
+ return list.length > 0 ? list : void 0;
1718
+ }
1478
1719
  var program = new Command();
1479
1720
  program.name("gresmcp").description("Manage Postgres-backed knowledge sources for the gresmcp MCP server").version(VERSION);
1480
1721
  program.command("init").description("Initialize the database schema (extension, ks table; repairs entry tables)").action(
@@ -1609,12 +1850,37 @@ ksCmd.command("delete <name>").description("Delete a knowledge source and all of
1609
1850
  console.log(`Deleted knowledge source '${name}'`);
1610
1851
  })
1611
1852
  );
1612
- program.command("feed <ks>").description("Feed data into a knowledge source (from a file/folder via --path, a website via --url, or inline via --text/--stdin)").option("--path <path>", "File or folder to ingest (text, markdown, code, html)").option("--url <url>", "Website URL to scrape and ingest (crawls same-host pages linked from the seed)").option("--crawler <crawler>", "Crawler backend for --url: auto, crawlee, playwright, puppeteer (default auto; playwright/puppeteer must be installed globally)", ((v) => v.toLowerCase())).option("--max-pages <n>", "Max pages to fetch when crawling --url (default 999)", parseInt).option("--depth <n>", "Max link depth from the seed URL (default 5; 0 = seed page only)", parseInt).option("--text <text>", "Inline text entry").option("--stdin", "Read entry text from stdin").option("--title <title>", "Title for manual entries").option("--source-name <name>", "Source name for manual entries (default 'manual')").option("--tags <tags>", "Comma-separated tags").option("--metadata <key=value...>", "Custom metadata entries", ((v, prev) => [...prev, v]), []).option("--replace", "Replace existing chunks of the same source instead of deduplicating").option("--dry-run", "Parse and chunk only; do not embed or write").option("--chunk-size <n>", "Max chunk length in characters", parseInt).option("--overlap <n>", "Chunk overlap in characters", parseInt).action(
1853
+ program.command("feed <ks>").description("Feed data into a knowledge source (from a file/folder via --path, a website via --url, or inline via --text/--stdin)").option("--path <path>", "File or folder to ingest (text, markdown, code, html)").option("--url <url>", "Website URL to scrape and ingest (crawls same-host pages linked from the seed)").option("--crawler <crawler>", "Crawler backend for --url: auto, crawlee, playwright, puppeteer (default auto; playwright/puppeteer must be installed globally)", ((v) => v.toLowerCase())).option("--max-pages <n>", "Max pages to fetch when crawling --url (default 999)", parseInt).option("--depth <n>", "Max link depth from the seed URL (default 5; 0 = seed page only)", parseInt).option(
1854
+ "--url-filter <glob>",
1855
+ "Only follow --url links whose URL matches this glob (repeatable; a link is followed if it matches any glob, e.g. '**/docs/**')",
1856
+ ((v, prev) => [...prev, v]),
1857
+ []
1858
+ ).option(
1859
+ "--html-filter <selector>",
1860
+ "CSS selector for HTML content \u2014 any DOM selector works (e.g. 'article.post', '#article .content:first-child'); optionally chain steps with ' -> ' (e.g. '#article -> .content'); applies to --path HTML files and --url pages"
1861
+ ).option(
1862
+ "--sitemap <mode>",
1863
+ "Link discovery for --url: 'auto' (default; sitemap.xml, then robots.txt sitemaps, else follow links), 'sitemap-only' (only sitemap.xml / robots.txt sitemaps), 'html-only' (follow links), or a CSS selector to only follow links inside it (e.g. 'nav.docs a')"
1864
+ ).option("--text <text>", "Inline text entry").option("--stdin", "Read entry text from stdin").option("--title <title>", "Title for manual entries").option("--source-name <name>", "Source name for manual entries (default 'manual')").option("--tags <tags>", "Comma-separated tags").option("--metadata <key=value...>", "Custom metadata entries", ((v, prev) => [...prev, v]), []).option("--replace", "Replace existing chunks of the same source instead of deduplicating").option("--dry-run", "Parse and chunk only; do not embed or write").option("--chunk-size <n>", "Max chunk length in characters", parseInt).option("--overlap <n>", "Chunk overlap in characters", parseInt).action(
1613
1865
  (name, opts) => run(async () => {
1614
1866
  await initDb();
1615
1867
  const ks = requireKs(await getKs(name), name);
1616
1868
  const modes = [opts.path, opts.url, opts.text, opts.stdin].filter(Boolean).length;
1617
1869
  if (modes !== 1) throw new Error("provide exactly one of --path, --url, --text or --stdin");
1870
+ const urlFilter = parseGlobs(opts.urlFilter);
1871
+ if (urlFilter && !opts.url) {
1872
+ console.error("warning: --url-filter has no effect without --url");
1873
+ }
1874
+ if (opts.htmlFilter) parseHtmlFilter(opts.htmlFilter);
1875
+ if (opts.htmlFilter && !opts.path && !opts.url) {
1876
+ console.error("warning: --html-filter has no effect without --path or --url");
1877
+ }
1878
+ let sitemap;
1879
+ if (opts.sitemap !== void 0) {
1880
+ sitemap = parseSitemapOption(opts.sitemap);
1881
+ if (sitemap.kind === "selector") validateCssSelector(sitemap.selector);
1882
+ if (!opts.url) console.error("warning: --sitemap has no effect without --url");
1883
+ }
1618
1884
  const common = {
1619
1885
  replace: opts.replace,
1620
1886
  dryRun: opts.dryRun,
@@ -1622,7 +1888,8 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1622
1888
  maxLen: opts.chunkSize && opts.chunkSize > 0 ? opts.chunkSize : void 0,
1623
1889
  overlap: opts.overlap,
1624
1890
  tags: parseTags(opts.tags),
1625
- metadata: parseKeyValue(opts.metadata ?? [])
1891
+ metadata: parseKeyValue(opts.metadata ?? []),
1892
+ htmlFilter: opts.htmlFilter
1626
1893
  };
1627
1894
  let summary;
1628
1895
  const since = await dbClockTimestamp();
@@ -1633,7 +1900,7 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1633
1900
  if (crawler && !CRAWLER_KINDS.includes(crawler)) {
1634
1901
  throw new Error(`unknown crawler '${crawler}' (use auto, crawlee, playwright or puppeteer)`);
1635
1902
  }
1636
- summary = await feedUrl(ks, opts.url, { ...common, crawler, maxPages: opts.maxPages, depth: opts.depth });
1903
+ summary = await feedUrl(ks, opts.url, { ...common, crawler, sitemap, maxPages: opts.maxPages, depth: opts.depth, urlFilter });
1637
1904
  } else {
1638
1905
  const text = opts.stdin ? await readStdin() : opts.text;
1639
1906
  if (!text.trim()) throw new Error("empty input text");