@gresmcp/mcp 1.1.0 → 1.3.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
@@ -325,8 +325,8 @@ async function probeModel(baseUrl, model) {
325
325
  init_config();
326
326
 
327
327
  // src/ingest.ts
328
- import { promises as fs } from "fs";
329
- import path from "path";
328
+ import { promises as fs2 } from "fs";
329
+ import path2 from "path";
330
330
 
331
331
  // src/chunk.ts
332
332
  var HEADING_RE = /^(#{1,6})\s+(.+)$/;
@@ -390,8 +390,8 @@ function chunkMarkdown(text, opts = {}) {
390
390
  const m = HEADING_RE.exec(line);
391
391
  if (m) {
392
392
  const level = m[1].length;
393
- const path3 = sections[sections.length - 1].path;
394
- const parent = path3.slice(0, level - 1);
393
+ const path4 = sections[sections.length - 1].path;
394
+ const parent = path4.slice(0, level - 1);
395
395
  sections.push({ path: [...parent, m[2].trim()], body: [line] });
396
396
  } else {
397
397
  sections[sections.length - 1].body.push(line);
@@ -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,13 +571,342 @@ function extractPage(html) {
524
571
  title,
525
572
  description,
526
573
  tags: tags.length > 0 ? tags : void 0,
527
- likelyShell
574
+ likelyShell,
575
+ filterMatched
576
+ };
577
+ }
578
+
579
+ // src/doc.ts
580
+ import mammoth from "mammoth";
581
+ import {
582
+ Doc97File,
583
+ InvalidKeyError,
584
+ OfficeFile,
585
+ OleFileIO,
586
+ isEncrypted
587
+ } from "office-crypto";
588
+ import WordExtractor from "word-extractor";
589
+ var DocError = class extends Error {
590
+ constructor(message, kind) {
591
+ super(message);
592
+ this.kind = kind;
593
+ this.name = "DocError";
594
+ }
595
+ kind;
596
+ };
597
+ var OLE_MAGIC = [208, 207, 17, 224, 161, 177, 26, 225];
598
+ function hasMagic(data, magic) {
599
+ if (data.length < magic.length) return false;
600
+ return magic.every((byte, i) => data[i] === byte);
601
+ }
602
+ function isOle(data) {
603
+ return hasMagic(data, OLE_MAGIC);
604
+ }
605
+ function isZip(data) {
606
+ return data.length >= 4 && data[0] === 80 && data[1] === 75 && data[2] === 3 && data[3] === 4;
607
+ }
608
+ function toDocError(err, prefix) {
609
+ if (err instanceof DocError) return err;
610
+ if (err instanceof InvalidKeyError) {
611
+ return new DocError("wrong password for encrypted Word document", "password");
612
+ }
613
+ const message = err instanceof Error ? err.message : String(err);
614
+ return new DocError(`${prefix}: ${message.slice(0, 200)}`, "corrupt");
615
+ }
616
+ function requirePassword() {
617
+ return new DocError("password-protected Word document (use --file-password)", "password");
618
+ }
619
+ function oleHasStream(data, name) {
620
+ try {
621
+ return new OleFileIO(data).exists(name);
622
+ } catch {
623
+ return false;
624
+ }
625
+ }
626
+ function decryptOoxml(data, password) {
627
+ try {
628
+ if (!password) throw requirePassword();
629
+ const file = OfficeFile(data);
630
+ file.loadKey({ password, verifyPassword: true });
631
+ return file.decrypt();
632
+ } catch (err) {
633
+ throw toDocError(err, "cannot decrypt Word document");
634
+ }
635
+ }
636
+ function decryptDoc97(data, password) {
637
+ try {
638
+ if (!password) throw requirePassword();
639
+ const file = new Doc97File(new OleFileIO(data));
640
+ file.loadKey({ password });
641
+ return file.decrypt();
642
+ } catch (err) {
643
+ throw toDocError(err, "cannot decrypt Word document");
644
+ }
645
+ }
646
+ async function docxToMarkdown(data, encrypted) {
647
+ let html;
648
+ try {
649
+ const result = await mammoth.convertToHtml({ buffer: Buffer.from(data) });
650
+ html = result.value;
651
+ } catch (err) {
652
+ throw toDocError(err, "cannot parse docx");
653
+ }
654
+ html = html.replace(/<img\b[^>]*>/gi, "").replace(
655
+ /<t([dh])\b([^>]*)>([\s\S]*?)<\/t\1>/gi,
656
+ (_m, letter, attrs, inner) => `<t${letter}${attrs}>${inner.replace(/<\/?p(?:\s[^>]*)?>/gi, "").replace(/<br(?:\s[^>]*)?>/gi, " ")}</t${letter}>`
657
+ ).replace(
658
+ /<table\b[^>]*>([\s\S]*?)<\/table>/gi,
659
+ (m, inner) => m.replace(
660
+ /(<tr\b[^>]*>)([\s\S]*?)(<\/tr>)/i,
661
+ (row, open, cells) => open + cells.replace(/<td\b/gi, "<th").replace(/<\/td>/gi, "</th>") + "</tr>"
662
+ )
663
+ );
664
+ const { markdown, title } = htmlToMarkdown(html);
665
+ return {
666
+ markdown: markdown.replace(/\n{3,}/g, "\n\n").trim(),
667
+ title: title || void 0,
668
+ format: "docx",
669
+ ...encrypted ? { encrypted: true } : {}
528
670
  };
529
671
  }
672
+ async function docToText(data, encrypted) {
673
+ try {
674
+ const doc = await new WordExtractor().extract(Buffer.from(data));
675
+ const normalize = (s) => s.replace(/\r\n?/g, "\n").replace(/\f/g, "\n\n").replace(/\n{3,}/g, "\n\n").trim();
676
+ const parts = [doc.getBody(), doc.getTextboxes(), doc.getFootnotes(), doc.getEndnotes()].map(normalize).filter(Boolean);
677
+ return { markdown: parts.join("\n\n"), format: "doc", ...encrypted ? { encrypted: true } : {} };
678
+ } catch (err) {
679
+ throw toDocError(err, "cannot parse doc");
680
+ }
681
+ }
682
+ async function docToDoc(data, opts = {}) {
683
+ if (isZip(data)) return docxToMarkdown(data, false);
684
+ if (isOle(data)) {
685
+ if (oleHasStream(data, "EncryptionInfo")) {
686
+ if (oleHasStream(data, "Workbook") || oleHasStream(data, "PowerPoint Document")) {
687
+ throw new DocError("not a Word document (encrypted Excel/PowerPoint package)", "corrupt");
688
+ }
689
+ const plain = decryptOoxml(data, opts.password);
690
+ return isZip(plain) ? docxToMarkdown(plain, true) : docToText(plain, true);
691
+ }
692
+ if (oleHasStream(data, "WordDocument") || oleHasStream(data, "wordDocument")) {
693
+ if (isEncrypted(data)) {
694
+ const plain = decryptDoc97(data, opts.password);
695
+ return docToText(plain, true);
696
+ }
697
+ return docToText(data, false);
698
+ }
699
+ throw new DocError("not a Word document (unsupported OLE compound file)", "corrupt");
700
+ }
701
+ throw new DocError("not a Word document (missing docx/doc signature)", "corrupt");
702
+ }
703
+
704
+ // src/epub.ts
705
+ import { promises as fs } from "fs";
706
+ import { mkdtempSync } from "fs";
707
+ import { tmpdir } from "os";
708
+ import path from "path";
709
+ import { EPub } from "epub2";
710
+ var EpubError = class extends Error {
711
+ constructor(message, kind) {
712
+ super(message);
713
+ this.kind = kind;
714
+ this.name = "EpubError";
715
+ }
716
+ kind;
717
+ };
718
+ var REWRITE_ROOT_RE = /^\/(?:links|images)\//i;
719
+ var ENCRYPTION_ENTRY = "meta-inf/encryption.xml";
720
+ function toEpubError(err) {
721
+ if (err instanceof EpubError) return err;
722
+ const message = err instanceof Error ? err.message : String(err);
723
+ return new EpubError(`cannot parse EPUB: ${message.slice(0, 200)}`, "corrupt");
724
+ }
725
+ function hasEncryptionEntry(names) {
726
+ return (names ?? []).some((n) => n.toLowerCase() === ENCRYPTION_ENTRY);
727
+ }
728
+ function normalizeHref(href) {
729
+ const raw = href.split("#")[0].trim();
730
+ if (!raw) return "";
731
+ let decoded = raw;
732
+ try {
733
+ decoded = decodeURIComponent(raw);
734
+ } catch {
735
+ }
736
+ return path.posix.normalize(decoded).toLowerCase();
737
+ }
738
+ function cleanChapterHtml(html) {
739
+ return html.replace(/<img\b[^>]*>/gi, "").replace(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi, (m, attrs, inner) => {
740
+ const href = /\shref\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs);
741
+ const url = (href?.[1] ?? href?.[2] ?? "").trim();
742
+ return REWRITE_ROOT_RE.test(url) ? inner : m;
743
+ });
744
+ }
745
+ function tocTitleMap(epub) {
746
+ const titles = /* @__PURE__ */ new Map();
747
+ for (const entry of epub.toc ?? []) {
748
+ const title = entry?.title?.trim();
749
+ if (!title) continue;
750
+ if (entry.id && epub.manifest[entry.id]) {
751
+ const idKey = `id:${entry.id}`;
752
+ if (!titles.has(idKey)) titles.set(idKey, title);
753
+ continue;
754
+ }
755
+ if (entry.href) {
756
+ const hrefKey = normalizeHref(entry.href);
757
+ if (hrefKey && !titles.has(hrefKey)) titles.set(hrefKey, title);
758
+ }
759
+ }
760
+ return titles;
761
+ }
762
+ async function epubToDoc(filePath) {
763
+ let epub;
764
+ try {
765
+ epub = await EPub.createAsync(filePath);
766
+ } catch (err) {
767
+ throw toEpubError(err);
768
+ }
769
+ const encrypted = hasEncryptionEntry(epub.zip?.names);
770
+ const titles = tocTitleMap(epub);
771
+ const parts = [];
772
+ for (const item of epub.flow ?? []) {
773
+ const id = item?.id;
774
+ if (!id) continue;
775
+ let html;
776
+ try {
777
+ html = await epub.getChapterAsync(id);
778
+ } catch {
779
+ continue;
780
+ }
781
+ const { markdown: markdown2 } = htmlToMarkdown(cleanChapterHtml(html));
782
+ if (!markdown2.trim()) continue;
783
+ const heading = titles.get(`id:${id}`) ?? (item.href ? titles.get(normalizeHref(item.href)) : void 0);
784
+ parts.push(heading ? `## ${heading}
785
+
786
+ ${markdown2}` : markdown2);
787
+ }
788
+ const markdown = parts.join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
789
+ if (!markdown) {
790
+ throw new EpubError(
791
+ encrypted ? "DRM-protected EPUB (encryption.xml; no readable text)" : "no extractable text from EPUB",
792
+ encrypted ? "drm" : "corrupt"
793
+ );
794
+ }
795
+ return {
796
+ markdown,
797
+ title: epub.metadata?.title?.trim() || void 0,
798
+ author: epub.metadata?.creator?.trim() || void 0,
799
+ language: epub.metadata?.language?.trim() || void 0,
800
+ chapters: parts.length,
801
+ ...encrypted ? { encrypted: true } : {}
802
+ };
803
+ }
804
+ async function epubFromBuffer(data) {
805
+ const dir = mkdtempSync(path.join(tmpdir(), "gresmcp-epub-"));
806
+ try {
807
+ const file = path.join(dir, "book.epub");
808
+ await fs.writeFile(file, data);
809
+ return await epubToDoc(file);
810
+ } finally {
811
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => {
812
+ });
813
+ }
814
+ }
530
815
 
531
816
  // src/ingest.ts
532
817
  init_db();
533
818
 
819
+ // src/pdf.ts
820
+ import pdf2md from "@opendocsg/pdf2md";
821
+ import { extractText, getMeta } from "unpdf";
822
+ var PAGE_BREAK_MARK = "<!-- PAGE_BREAK -->";
823
+ var PdfError = class extends Error {
824
+ constructor(message, kind) {
825
+ super(message);
826
+ this.kind = kind;
827
+ this.name = "PdfError";
828
+ }
829
+ kind;
830
+ };
831
+ function isPasswordException(err) {
832
+ return err instanceof Error && err.name === "PasswordException";
833
+ }
834
+ function toPdfError(err) {
835
+ if (err instanceof PdfError) return err;
836
+ if (isPasswordException(err)) {
837
+ return new PdfError("wrong password for encrypted PDF", "password");
838
+ }
839
+ const message = err instanceof Error ? err.message : String(err);
840
+ return new PdfError(`cannot parse PDF: ${message.slice(0, 200)}`, "corrupt");
841
+ }
842
+ function titleFromInfo(info) {
843
+ const raw = info?.Title;
844
+ const value = typeof raw === "string" ? raw.trim() : "";
845
+ return value || void 0;
846
+ }
847
+ function imageOnlyFromPages(pages) {
848
+ const out = [];
849
+ for (const page of pages) {
850
+ const hasText = page.items.some((it) => String(it.text ?? "").trim().length > 0);
851
+ if (!hasText) out.push(page.index + 1);
852
+ }
853
+ return out;
854
+ }
855
+ async function extractEncrypted(data, password) {
856
+ try {
857
+ const extractOpts = { mergePages: false, password };
858
+ const metaOpts = { password };
859
+ const [result, meta] = await Promise.all([
860
+ extractText(data, extractOpts),
861
+ getMeta(data, metaOpts).catch(() => void 0)
862
+ ]);
863
+ const pages = result.text;
864
+ const imageOnlyPages = [];
865
+ pages.forEach((text, i) => {
866
+ if (!text.trim()) imageOnlyPages.push(i + 1);
867
+ });
868
+ const markdown = pages.map((p) => p.trim()).filter(Boolean).join("\n\n").trim();
869
+ return {
870
+ markdown,
871
+ title: titleFromInfo(meta?.info),
872
+ pageCount: result.totalPages,
873
+ imageOnlyPages
874
+ };
875
+ } catch (err) {
876
+ throw toPdfError(err);
877
+ }
878
+ }
879
+ async function pdfToDoc(data, opts = {}) {
880
+ let title;
881
+ let pages = [];
882
+ let markdown;
883
+ try {
884
+ markdown = await pdf2md(data, {
885
+ metadataParsed: (meta) => {
886
+ title = titleFromInfo(meta.info);
887
+ },
888
+ pageParsed: (parsed) => {
889
+ pages = parsed;
890
+ }
891
+ });
892
+ } catch (err) {
893
+ if (isPasswordException(err)) {
894
+ if (opts.password) {
895
+ return extractEncrypted(data, opts.password);
896
+ }
897
+ throw new PdfError("password-protected PDF (use --file-password)", "password");
898
+ }
899
+ throw toPdfError(err);
900
+ }
901
+ markdown = markdown.split(PAGE_BREAK_MARK).map((part) => part.replace(/\s+$/, "")).join("\n\n").replace(/\n{3,}/g, "\n\n").trim();
902
+ return {
903
+ markdown,
904
+ title,
905
+ pageCount: pages.length,
906
+ imageOnlyPages: imageOnlyFromPages(pages)
907
+ };
908
+ }
909
+
534
910
  // src/progress.ts
535
911
  var BAR_WIDTH = 20;
536
912
  var PLAIN_STEP = 32;
@@ -544,14 +920,16 @@ function bar(pct2) {
544
920
  function pct(p) {
545
921
  return `${Math.round(Math.max(0, Math.min(1, p)) * 100)}%`;
546
922
  }
923
+ var NOOP_HANDLE = { handled: () => {
924
+ } };
547
925
  var FeedProgress = class {
548
926
  write;
549
927
  interactive;
550
928
  total;
551
929
  rejected;
930
+ slots = [];
552
931
  filesDone = 0;
553
932
  totalChars = 0;
554
- current;
555
933
  blockLines = 0;
556
934
  finished = false;
557
935
  constructor(opts) {
@@ -564,26 +942,13 @@ var FeedProgress = class {
564
942
  return this.interactive;
565
943
  }
566
944
  startFile(source, totalChunks, totalChars) {
567
- if (this.finished) return;
568
- this.current = { source, totalChunks, totalChars, doneChunks: 0, doneChars: 0, printedChunks: 0 };
945
+ if (this.finished) return NOOP_HANDLE;
946
+ const slot = { source, totalChunks, totalChars, doneChunks: 0, doneChars: 0, printedChunks: 0 };
947
+ this.slots.push(slot);
569
948
  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
- }
949
+ return {
950
+ handled: (chunks, chars) => this.handleSlot(slot, chunks, chars)
951
+ };
587
952
  }
588
953
  log(msg) {
589
954
  if (this.finished) return;
@@ -594,9 +959,9 @@ var FeedProgress = class {
594
959
  finish() {
595
960
  if (this.finished) return;
596
961
  this.finished = true;
597
- if (!this.current && this.total === 0 && this.rejected === 0) return;
962
+ if (this.slots.length === 0 && this.total === 0 && this.rejected === 0) return;
598
963
  if (this.interactive) {
599
- this.redraw([this.fileLine(), this.totalLine()]);
964
+ this.redraw(this.lines());
600
965
  this.write("\n");
601
966
  this.blockLines = 0;
602
967
  } else {
@@ -609,25 +974,42 @@ var FeedProgress = class {
609
974
  this.finished = true;
610
975
  this.clearBlock();
611
976
  }
977
+ lines() {
978
+ return [...this.slots.map((slot) => this.fileLine(slot)), this.totalLine()];
979
+ }
980
+ handleSlot(slot, chunks, chars) {
981
+ if (this.finished || chunks <= 0 || slot.doneChunks >= slot.totalChunks) return;
982
+ slot.doneChunks += chunks;
983
+ slot.doneChars += chars;
984
+ this.totalChars += chars;
985
+ if (slot.doneChunks >= slot.totalChunks) {
986
+ this.filesDone++;
987
+ if (this.interactive) {
988
+ this.render();
989
+ } else {
990
+ this.printFileLine(slot);
991
+ }
992
+ this.slots.splice(this.slots.indexOf(slot), 1);
993
+ return;
994
+ }
995
+ if (!this.interactive && slot.doneChunks - slot.printedChunks >= PLAIN_STEP) {
996
+ this.printFileLine(slot);
997
+ } else if (this.interactive) {
998
+ this.render();
999
+ }
1000
+ }
612
1001
  render() {
613
1002
  if (!this.interactive) return;
614
- this.redraw([this.fileLine(), this.totalLine()]);
1003
+ this.redraw(this.lines());
615
1004
  }
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()}
1005
+ printFileLine(slot) {
1006
+ slot.printedChunks = slot.doneChunks;
1007
+ this.write(`${this.fileLine(slot)}
623
1008
  `);
624
- }
625
1009
  }
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}`;
1010
+ fileLine(slot) {
1011
+ const p = slot.totalChunks > 0 ? slot.doneChunks / slot.totalChunks : 1;
1012
+ return `[${bar(p)}] ${pct(p).padStart(4)} ${fmt(slot.doneChunks)}/${fmt(slot.totalChunks)} chunks ${fmt(slot.doneChars)}/${fmt(slot.totalChars)} chars ${slot.source}`;
631
1013
  }
632
1014
  totalLine() {
633
1015
  const p = this.total > 0 ? this.filesDone / this.total : 0;
@@ -664,6 +1046,9 @@ var FeedProgress = class {
664
1046
  // src/ingest.ts
665
1047
  var MD_EXT = /* @__PURE__ */ new Set([".md", ".markdown", ".mdx"]);
666
1048
  var HTML_EXT = /* @__PURE__ */ new Set([".html", ".htm", ".xhtml"]);
1049
+ var PDF_EXT = /* @__PURE__ */ new Set([".pdf"]);
1050
+ var DOC_EXT = /* @__PURE__ */ new Set([".doc", ".docx"]);
1051
+ var EPUB_EXT = /* @__PURE__ */ new Set([".epub"]);
667
1052
  var TEXT_EXT = /* @__PURE__ */ new Set([
668
1053
  ".txt",
669
1054
  ".text",
@@ -751,13 +1136,13 @@ var SKIP_DIRS = /* @__PURE__ */ new Set([
751
1136
  ".cache"
752
1137
  ]);
753
1138
  function isAllowed(ext) {
754
- return MD_EXT.has(ext) || HTML_EXT.has(ext) || TEXT_EXT.has(ext) || CODE_EXT.has(ext);
1139
+ return MD_EXT.has(ext) || HTML_EXT.has(ext) || TEXT_EXT.has(ext) || CODE_EXT.has(ext) || PDF_EXT.has(ext) || DOC_EXT.has(ext) || EPUB_EXT.has(ext);
755
1140
  }
756
1141
  async function walk(root) {
757
1142
  const out = [];
758
- const entries = await fs.readdir(root, { withFileTypes: true });
1143
+ const entries = await fs2.readdir(root, { withFileTypes: true });
759
1144
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
760
- const full = path.join(root, entry.name);
1145
+ const full = path2.join(root, entry.name);
761
1146
  if (entry.isDirectory()) {
762
1147
  if (SKIP_DIRS.has(entry.name)) continue;
763
1148
  out.push(...await walk(full));
@@ -768,50 +1153,114 @@ async function walk(root) {
768
1153
  return out;
769
1154
  }
770
1155
  async function loadTarget(target, opts) {
771
- const stat = await fs.stat(target);
1156
+ const stat = await fs2.stat(target);
772
1157
  const files = stat.isDirectory() ? await walk(target) : [target];
773
1158
  const docs = [];
774
1159
  const skipped = [];
775
1160
  for (const file of files) {
776
- const ext = path.extname(file).toLowerCase();
1161
+ const ext = path2.extname(file).toLowerCase();
777
1162
  if (!isAllowed(ext)) {
778
1163
  skipped.push({ path: file, reason: `unsupported extension '${ext || "(none)"}'` });
779
1164
  continue;
780
1165
  }
781
- let raw;
1166
+ let buf;
782
1167
  try {
783
- raw = await fs.readFile(file, "utf8");
1168
+ buf = await fs2.readFile(file);
784
1169
  } catch {
785
1170
  skipped.push({ path: file, reason: "unreadable" });
786
1171
  continue;
787
1172
  }
788
- if (raw.includes("\0")) {
789
- skipped.push({ path: file, reason: "binary content" });
790
- continue;
791
- }
792
- let text = raw;
1173
+ let text = "";
793
1174
  let title = "";
794
- if (HTML_EXT.has(ext)) {
795
- const conv = htmlToMarkdown(raw);
796
- text = conv.markdown;
797
- title = conv.title ?? "";
798
- } else if (MD_EXT.has(ext)) {
799
- title = mdTitle(raw) ?? "";
800
- }
801
- if (!text.trim()) {
802
- skipped.push({ path: file, reason: "empty" });
803
- continue;
1175
+ let docMeta;
1176
+ let chunks = [];
1177
+ if (PDF_EXT.has(ext)) {
1178
+ let pdf;
1179
+ try {
1180
+ pdf = await pdfToDoc(new Uint8Array(buf), { password: opts.filePassword });
1181
+ } catch (err) {
1182
+ skipped.push({ path: file, reason: err instanceof Error ? err.message : "PDF parse failed" });
1183
+ continue;
1184
+ }
1185
+ text = pdf.markdown;
1186
+ title = pdf.title ?? "";
1187
+ if (pdf.pageCount > 0) docMeta = { pdf_pages: pdf.pageCount };
1188
+ if (pdf.imageOnlyPages.length > 0) {
1189
+ docMeta = { ...docMeta, pdf_image_only_pages: pdf.imageOnlyPages.length };
1190
+ }
1191
+ if (!text.trim()) {
1192
+ skipped.push({
1193
+ path: file,
1194
+ reason: pdf.pageCount > 0 && pdf.imageOnlyPages.length === pdf.pageCount ? "image-only PDF (no text layer; likely scanned)" : "no extractable text from PDF"
1195
+ });
1196
+ continue;
1197
+ }
1198
+ chunks = chunkMarkdown(text, opts);
1199
+ } else if (DOC_EXT.has(ext)) {
1200
+ let word;
1201
+ try {
1202
+ word = await docToDoc(new Uint8Array(buf), { password: opts.filePassword });
1203
+ } catch (err) {
1204
+ skipped.push({ path: file, reason: err instanceof Error ? err.message : "Word document parse failed" });
1205
+ continue;
1206
+ }
1207
+ text = word.markdown;
1208
+ title = word.title ?? "";
1209
+ docMeta = { doc_format: word.format };
1210
+ if (word.encrypted) docMeta.doc_encrypted = true;
1211
+ if (!text.trim()) {
1212
+ skipped.push({ path: file, reason: "no extractable text from Word document" });
1213
+ continue;
1214
+ }
1215
+ chunks = word.format === "docx" ? chunkMarkdown(text, opts) : chunkPlain(text, opts);
1216
+ } else if (EPUB_EXT.has(ext)) {
1217
+ let epub;
1218
+ try {
1219
+ epub = await epubToDoc(file);
1220
+ } catch (err) {
1221
+ skipped.push({ path: file, reason: err instanceof Error ? err.message : "EPUB parse failed" });
1222
+ continue;
1223
+ }
1224
+ text = epub.markdown;
1225
+ title = epub.title ?? "";
1226
+ docMeta = { epub_chapters: epub.chapters };
1227
+ if (epub.author) docMeta.epub_author = epub.author;
1228
+ if (epub.language) docMeta.epub_language = epub.language;
1229
+ if (epub.encrypted) docMeta.epub_encrypted = true;
1230
+ chunks = chunkMarkdown(text, opts);
1231
+ } else {
1232
+ const raw = buf.toString("utf8");
1233
+ if (raw.includes("\0")) {
1234
+ skipped.push({ path: file, reason: "binary content" });
1235
+ continue;
1236
+ }
1237
+ text = raw;
1238
+ if (HTML_EXT.has(ext)) {
1239
+ const conv = htmlToMarkdown(raw, { filter: opts.htmlFilter });
1240
+ text = conv.markdown;
1241
+ title = conv.title ?? "";
1242
+ } else if (MD_EXT.has(ext)) {
1243
+ title = mdTitle(raw) ?? "";
1244
+ }
1245
+ if (!text.trim()) {
1246
+ skipped.push({
1247
+ path: file,
1248
+ reason: HTML_EXT.has(ext) && opts.htmlFilter ? `html filter '${opts.htmlFilter}' matched nothing` : "empty"
1249
+ });
1250
+ continue;
1251
+ }
1252
+ chunks = HTML_EXT.has(ext) || MD_EXT.has(ext) ? chunkMarkdown(text, opts) : chunkPlain(text, opts);
804
1253
  }
805
- if (!title) title = path.basename(file);
806
- const chunks = HTML_EXT.has(ext) || MD_EXT.has(ext) ? chunkMarkdown(text, opts) : chunkPlain(text, opts);
807
1254
  if (chunks.length === 0) {
808
1255
  skipped.push({ path: file, reason: "no chunks produced" });
809
1256
  continue;
810
1257
  }
811
- docs.push({ source: path.resolve(file), title, chunks });
1258
+ if (!title) title = path2.basename(file);
1259
+ docs.push({ source: path2.resolve(file), title, chunks, metadata: docMeta });
812
1260
  }
813
1261
  return { docs, fileCount: files.length, skipped };
814
1262
  }
1263
+ var DOC_CONCURRENCY = 3;
815
1264
  async function feedDocs(ks, docs, opts = {}) {
816
1265
  const log2 = opts.log ?? ((m) => console.log(m));
817
1266
  const progress = opts.progress;
@@ -823,61 +1272,78 @@ async function feedDocs(ks, docs, opts = {}) {
823
1272
  let skippedExisting = 0;
824
1273
  let inserted = 0;
825
1274
  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);
1275
+ const processDoc = async (doc) => {
1276
+ const file = progress?.startFile(
1277
+ doc.source,
1278
+ doc.chunks.length,
1279
+ doc.chunks.reduce((n, c) => n + c.content.length, 0)
1280
+ );
1281
+ if (opts.replace) {
1282
+ const deleted = await deleteBySource(ks, doc.source);
1283
+ deletedForReplace += deleted;
1284
+ }
1285
+ const existing = await sourceChunkHashes(ks, doc.source);
1286
+ const meta = { ...baseMeta, ...doc.metadata ?? {} };
1287
+ if (!("tags" in meta) && cliTags) meta.tags = cliTags;
1288
+ const todo = [];
1289
+ for (const chunk of doc.chunks) {
1290
+ if (!opts.replace && existing.get(chunk.index) === contentHash(chunk.content)) {
1291
+ skippedExisting++;
1292
+ file?.handled(1, chunk.content.length);
851
1293
  continue;
852
1294
  }
853
- if (!announcedEmbed) {
854
- say(`Embedding with ${ks.embedding_model} at ${ks.ollama_url} ...`);
855
- announcedEmbed = true;
1295
+ todo.push(chunk);
1296
+ }
1297
+ if (todo.length === 0) return;
1298
+ if (opts.dryRun) {
1299
+ for (const chunk of todo) file?.handled(1, chunk.content.length);
1300
+ return;
1301
+ }
1302
+ if (!announcedEmbed) {
1303
+ announcedEmbed = true;
1304
+ say(`Embedding with ${ks.embedding_model} at ${ks.ollama_url} ...`);
1305
+ }
1306
+ const contents = todo.map((c) => c.content);
1307
+ const charLens = contents.map((c) => c.length);
1308
+ let reported = 0;
1309
+ const embeddings = await embedBatch(ks.ollama_url, ks.embedding_model, contents, batchSize, (done) => {
1310
+ const delta = done - reported;
1311
+ if (delta <= 0) return;
1312
+ let chars = 0;
1313
+ for (let i = reported; i < done; i++) chars += charLens[i];
1314
+ reported = done;
1315
+ file?.handled(delta, chars);
1316
+ });
1317
+ const rows = todo.map((chunk, i) => ({
1318
+ source: doc.source,
1319
+ title: doc.title,
1320
+ chunk_index: chunk.index,
1321
+ content: chunk.content,
1322
+ metadata: meta,
1323
+ embedding: embeddings[i]
1324
+ }));
1325
+ const rowCount = await insertChunks(ks, rows);
1326
+ inserted += rowCount;
1327
+ };
1328
+ let cursor = 0;
1329
+ let firstError;
1330
+ const runWorker = async () => {
1331
+ while (firstError === void 0) {
1332
+ const index = cursor++;
1333
+ if (index >= docs.length) return;
1334
+ try {
1335
+ await processDoc(docs[index]);
1336
+ } catch (err) {
1337
+ firstError ??= err;
1338
+ return;
856
1339
  }
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
1340
  }
878
- } catch (err) {
1341
+ };
1342
+ const workerCount = Math.min(DOC_CONCURRENCY, docs.length);
1343
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
1344
+ if (firstError !== void 0) {
879
1345
  progress?.close();
880
- throw err;
1346
+ throw firstError;
881
1347
  }
882
1348
  progress?.finish();
883
1349
  return {
@@ -891,7 +1357,7 @@ async function feedDocs(ks, docs, opts = {}) {
891
1357
  };
892
1358
  }
893
1359
  async function feedTarget(ks, target, opts = {}) {
894
- const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180 };
1360
+ const loadOpts = { maxLen: opts.maxLen ?? 1200, overlap: opts.overlap ?? 180, htmlFilter: opts.htmlFilter, filePassword: opts.filePassword };
895
1361
  const { docs, skipped } = await loadTarget(target, loadOpts);
896
1362
  for (const s of skipped) {
897
1363
  (opts.log ?? ((m) => console.log(m)))(`skipped ${s.path}: ${s.reason}`);
@@ -911,14 +1377,39 @@ async function feedText(ks, text, opts = {}) {
911
1377
  }
912
1378
 
913
1379
  // src/scrape.ts
914
- import { mkdtempSync } from "fs";
915
- import { tmpdir } from "os";
916
- import path2 from "path";
1380
+ import { mkdtempSync as mkdtempSync2 } from "fs";
1381
+ import { tmpdir as tmpdir2 } from "os";
1382
+ import path3 from "path";
917
1383
  import { randomUUID } from "crypto";
918
- import { CheerioCrawler, PlaywrightCrawler, PuppeteerCrawler, RequestQueue, log } from "crawlee";
1384
+ import {
1385
+ CheerioCrawler,
1386
+ PlaywrightCrawler,
1387
+ PuppeteerCrawler,
1388
+ Request as CrawleeRequest,
1389
+ RequestQueue,
1390
+ RobotsTxtFile,
1391
+ Sitemap,
1392
+ constructGlobObjectsFromGlobs,
1393
+ filterRequestsByPatterns,
1394
+ log
1395
+ } from "crawlee";
919
1396
  var CRAWLER_KINDS = ["auto", "crawlee", "playwright", "puppeteer"];
920
- 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
- process.env.CRAWLEE_STORAGE_DIR ??= mkdtempSync(path2.join(tmpdir(), "gresmcp-crawl-"));
1397
+ function parseSitemapOption(value) {
1398
+ const trimmed = value.trim();
1399
+ if (!trimmed) {
1400
+ throw new Error("invalid --sitemap '': use auto, sitemap-only, html-only or a CSS selector");
1401
+ }
1402
+ const key = trimmed.toLowerCase();
1403
+ if (key === "auto") return { kind: "auto" };
1404
+ if (key === "sitemap-only") return { kind: "sitemap-only" };
1405
+ if (key === "html-only") return { kind: "html-only" };
1406
+ return { kind: "selector", selector: trimmed };
1407
+ }
1408
+ var BINARY_LINK_RE = /\.(css|js|mjs|cjs|map|png|jpe?g|gif|svg|webp|avif|ico|bmp|tiff?|woff2?|ttf|otf|eot|zip|gz|tgz|tar|rar|7z|bz2|xz|zst|mp3|mp4|m4a|webm|mov|avi|wmv|flv|xlsx?|pptx?|odt|ods|odp|rtf|dmg|exe|msi|apk|iso|bin|wasm|deb|rpm)(\?.*)?$/i;
1409
+ var PDF_URL_RE = /\.pdf(\?|#|$)/i;
1410
+ var DOC_URL_RE = /\.docx?(\?|#|$)/i;
1411
+ var EPUB_URL_RE = /\.epub(\?|#|$)/i;
1412
+ process.env.CRAWLEE_STORAGE_DIR ??= mkdtempSync2(path3.join(tmpdir2(), "gresmcp-crawl-"));
922
1413
  async function freshQueue() {
923
1414
  return RequestQueue.open(`gresmcp-${randomUUID()}`);
924
1415
  }
@@ -993,20 +1484,86 @@ function dedupeDocs(docs) {
993
1484
  }
994
1485
  return [...bySource.values()];
995
1486
  }
1487
+ async function buildPdfDoc(url, data, deps) {
1488
+ try {
1489
+ const pdf = await pdfToDoc(data, { password: deps.filePassword });
1490
+ if (!pdf.markdown.trim()) {
1491
+ const reason = pdf.pageCount > 0 && pdf.imageOnlyPages.length === pdf.pageCount ? "image-only PDF (no text layer; likely scanned)" : "no extractable text from PDF";
1492
+ return { reason };
1493
+ }
1494
+ const chunks = chunkMarkdown(pdf.markdown, { maxLen: deps.maxLen, overlap: deps.overlap });
1495
+ if (chunks.length === 0) return { reason: "no chunks produced" };
1496
+ const metadata = { source_url: url, crawled_at: (/* @__PURE__ */ new Date()).toISOString() };
1497
+ if (pdf.pageCount > 0) metadata.pdf_pages = pdf.pageCount;
1498
+ if (pdf.imageOnlyPages.length > 0) metadata.pdf_image_only_pages = pdf.imageOnlyPages.length;
1499
+ const tags = unionTags(void 0, deps.cliTags);
1500
+ if (tags) metadata.tags = tags;
1501
+ return { doc: { source: url, title: pdf.title?.trim() || titleFor(url), chunks, metadata } };
1502
+ } catch (err) {
1503
+ return { reason: err instanceof Error ? err.message : "PDF parse failed" };
1504
+ }
1505
+ }
1506
+ async function buildEpubDoc(url, data, deps) {
1507
+ try {
1508
+ const epub = await epubFromBuffer(data);
1509
+ if (!epub.markdown.trim()) {
1510
+ return {
1511
+ reason: epub.encrypted ? "DRM-protected EPUB (encryption.xml; no readable text)" : "no extractable text from EPUB"
1512
+ };
1513
+ }
1514
+ const chunks = chunkMarkdown(epub.markdown, { maxLen: deps.maxLen, overlap: deps.overlap });
1515
+ if (chunks.length === 0) return { reason: "no chunks produced" };
1516
+ const metadata = {
1517
+ source_url: url,
1518
+ crawled_at: (/* @__PURE__ */ new Date()).toISOString(),
1519
+ epub_chapters: epub.chapters
1520
+ };
1521
+ if (epub.author) metadata.epub_author = epub.author;
1522
+ if (epub.language) metadata.epub_language = epub.language;
1523
+ if (epub.encrypted) metadata.epub_encrypted = true;
1524
+ const tags = unionTags(void 0, deps.cliTags);
1525
+ if (tags) metadata.tags = tags;
1526
+ return { doc: { source: url, title: epub.title?.trim() || titleFor(url), chunks, metadata } };
1527
+ } catch (err) {
1528
+ return { reason: err instanceof Error ? err.message : "EPUB parse failed" };
1529
+ }
1530
+ }
996
1531
  function shortReason(err) {
997
1532
  const message = (err?.message ?? String(err)).replace(/\s+/g, " ").trim();
998
1533
  const contentType = /served Content-Type ([^,\s]+),/.exec(message);
999
1534
  if (contentType) return `unsupported content-type '${contentType[1]}'`;
1000
1535
  return message.slice(0, 200);
1001
1536
  }
1537
+ async function buildDocDoc(url, data, deps) {
1538
+ try {
1539
+ const word = await docToDoc(data, { password: deps.filePassword });
1540
+ if (!word.markdown.trim()) {
1541
+ return { reason: "no extractable text from Word document" };
1542
+ }
1543
+ const chunkOpts = { maxLen: deps.maxLen, overlap: deps.overlap };
1544
+ const chunks = word.format === "docx" ? chunkMarkdown(word.markdown, chunkOpts) : chunkPlain(word.markdown, chunkOpts);
1545
+ if (chunks.length === 0) return { reason: "no chunks produced" };
1546
+ const metadata = {
1547
+ source_url: url,
1548
+ crawled_at: (/* @__PURE__ */ new Date()).toISOString(),
1549
+ doc_format: word.format
1550
+ };
1551
+ if (word.encrypted) metadata.doc_encrypted = true;
1552
+ const tags = unionTags(void 0, deps.cliTags);
1553
+ if (tags) metadata.tags = tags;
1554
+ return { doc: { source: url, title: word.title?.trim() || titleFor(url), chunks, metadata } };
1555
+ } catch (err) {
1556
+ return { reason: err instanceof Error ? err.message : "Word document parse failed" };
1557
+ }
1558
+ }
1002
1559
  async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1003
1560
  const crawler = new CheerioCrawler({
1004
1561
  requestQueue: queue,
1005
1562
  maxRequestsPerCrawl: Math.max(1, opts.maxPages),
1006
1563
  maxCrawlDepth: Math.max(0, opts.depth),
1007
- additionalMimeTypes: ["text/plain", "text/markdown"],
1564
+ additionalMimeTypes: ["text/plain", "text/markdown", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/epub+zip", "application/zip", "application/octet-stream"],
1008
1565
  async requestHandler(ctx) {
1009
- const { request, response, contentType, body } = ctx;
1566
+ const { request, response, contentType, body, $ } = ctx;
1010
1567
  const url = request.loadedUrl ?? request.url;
1011
1568
  const status = responseStatus(response);
1012
1569
  if (status === void 0 || status < 200 || status >= 300) {
@@ -1016,12 +1573,16 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1016
1573
  const type = contentType?.type ?? "";
1017
1574
  if (type === "text/html" || type === "application/xhtml+xml") {
1018
1575
  const html = typeof body === "string" ? body : toText(body);
1019
- const page = extractPage(html);
1576
+ const page = extractPage(html, { filter: opts.htmlFilter });
1020
1577
  if (page.likelyShell) {
1021
1578
  sink.shellUrls?.push(url);
1022
1579
  sink.skipped.push({ path: url, reason: "likely JS-rendered shell" });
1023
1580
  return;
1024
1581
  }
1582
+ if (opts.htmlFilter && !page.filterMatched) {
1583
+ sink.skipped.push({ path: url, reason: `html filter '${opts.htmlFilter}' matched nothing` });
1584
+ return;
1585
+ }
1025
1586
  if (!page.markdown.trim()) {
1026
1587
  sink.skipped.push({ path: url, reason: "empty" });
1027
1588
  return;
@@ -1032,7 +1593,28 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1032
1593
  return;
1033
1594
  }
1034
1595
  sink.docs.push(doc);
1035
- await ctx.enqueueLinks({ strategy: "same-hostname", exclude: [BINARY_LINK_RE] });
1596
+ if (opts.followLinks !== false) {
1597
+ const selector = opts.linkSelector;
1598
+ if (selector) {
1599
+ if ($(selector).length > 0) {
1600
+ opts.selectorStats.hits += 1;
1601
+ await ctx.enqueueLinks({
1602
+ selector,
1603
+ strategy: "same-hostname",
1604
+ exclude: [BINARY_LINK_RE],
1605
+ globs: opts.urlFilter
1606
+ });
1607
+ } else {
1608
+ opts.selectorStats.misses += 1;
1609
+ }
1610
+ } else {
1611
+ await ctx.enqueueLinks({
1612
+ strategy: "same-hostname",
1613
+ exclude: [BINARY_LINK_RE],
1614
+ globs: opts.urlFilter
1615
+ });
1616
+ }
1617
+ }
1036
1618
  } else if (type === "text/markdown") {
1037
1619
  const doc = buildDoc(url, "md", toText(body), void 0, sinkDeps(opts));
1038
1620
  if (!doc) {
@@ -1047,6 +1629,47 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1047
1629
  return;
1048
1630
  }
1049
1631
  sink.docs.push(doc);
1632
+ } else if (type === "application/pdf") {
1633
+ const data = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
1634
+ const { doc, reason } = await buildPdfDoc(url, data, sinkDeps(opts));
1635
+ if (reason || !doc) {
1636
+ sink.skipped.push({ path: url, reason: reason ?? "PDF produced no document" });
1637
+ return;
1638
+ }
1639
+ sink.docs.push(doc);
1640
+ } else if (type === "application/msword" || type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") {
1641
+ const data = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
1642
+ const { doc, reason } = await buildDocDoc(url, data, sinkDeps(opts));
1643
+ if (reason || !doc) {
1644
+ sink.skipped.push({ path: url, reason: reason ?? "Word document produced no document" });
1645
+ return;
1646
+ }
1647
+ sink.docs.push(doc);
1648
+ } else if (type === "application/epub+zip" || type === "application/zip") {
1649
+ const data = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
1650
+ const { doc, reason } = await buildEpubDoc(url, data, sinkDeps(opts));
1651
+ if (reason || !doc) {
1652
+ sink.skipped.push({ path: url, reason: reason ?? "EPUB produced no document" });
1653
+ return;
1654
+ }
1655
+ sink.docs.push(doc);
1656
+ } else if (type === "application/octet-stream") {
1657
+ const data = typeof body === "string" ? new TextEncoder().encode(body) : new Uint8Array(body);
1658
+ if (EPUB_URL_RE.test(url)) {
1659
+ const { doc: doc2, reason: reason2 } = await buildEpubDoc(url, data, sinkDeps(opts));
1660
+ if (reason2 || !doc2) {
1661
+ sink.skipped.push({ path: url, reason: reason2 ?? "EPUB produced no document" });
1662
+ return;
1663
+ }
1664
+ sink.docs.push(doc2);
1665
+ return;
1666
+ }
1667
+ const { doc, reason } = await buildDocDoc(url, data, sinkDeps(opts));
1668
+ if (reason || !doc) {
1669
+ sink.skipped.push({ path: url, reason: reason ?? "Word document produced no document" });
1670
+ return;
1671
+ }
1672
+ sink.docs.push(doc);
1050
1673
  } else {
1051
1674
  sink.skipped.push({ path: url, reason: `unsupported content-type '${type}'` });
1052
1675
  }
@@ -1064,7 +1687,17 @@ async function crawlWithCheerio(queue, seedUrls, opts, sink) {
1064
1687
  }
1065
1688
  }
1066
1689
  function sinkDeps(opts) {
1067
- return { maxLen: opts.maxLen, overlap: opts.overlap, cliTags: opts.tags };
1690
+ return {
1691
+ maxLen: opts.maxLen,
1692
+ overlap: opts.overlap,
1693
+ cliTags: opts.tags,
1694
+ urlFilter: opts.urlFilter,
1695
+ htmlFilter: opts.htmlFilter,
1696
+ filePassword: opts.filePassword,
1697
+ followLinks: opts.followLinks,
1698
+ linkSelector: opts.linkSelector,
1699
+ selectorStats: opts.selectorStats
1700
+ };
1068
1701
  }
1069
1702
  async function importOptional(name) {
1070
1703
  try {
@@ -1081,16 +1714,86 @@ async function requireBrowserEngine(kind) {
1081
1714
  throw new Error(`--crawler ${kind} requires ${moduleName}, which is not installed (install it with: ${install})`);
1082
1715
  }
1083
1716
  }
1717
+ async function handleBrowserPdf(url, deps) {
1718
+ try {
1719
+ const res = await fetch(url, { redirect: "follow" });
1720
+ if (!res.ok) {
1721
+ deps.sink.skipped.push({ path: url, reason: `HTTP ${res.status}` });
1722
+ return;
1723
+ }
1724
+ const data = new Uint8Array(await res.arrayBuffer());
1725
+ const { doc, reason } = await buildPdfDoc(url, data, deps);
1726
+ if (reason || !doc) {
1727
+ deps.sink.skipped.push({ path: url, reason: reason ?? "PDF produced no document" });
1728
+ return;
1729
+ }
1730
+ deps.sink.docs.push(doc);
1731
+ } catch (err) {
1732
+ deps.sink.skipped.push({ path: url, reason: err instanceof Error ? err.message : "PDF fetch failed" });
1733
+ }
1734
+ }
1735
+ async function handleBrowserDoc(url, deps) {
1736
+ try {
1737
+ const res = await fetch(url, { redirect: "follow" });
1738
+ if (!res.ok) {
1739
+ deps.sink.skipped.push({ path: url, reason: `HTTP ${res.status}` });
1740
+ return;
1741
+ }
1742
+ const data = new Uint8Array(await res.arrayBuffer());
1743
+ const { doc, reason } = await buildDocDoc(url, data, deps);
1744
+ if (reason || !doc) {
1745
+ deps.sink.skipped.push({ path: url, reason: reason ?? "Word document produced no document" });
1746
+ return;
1747
+ }
1748
+ deps.sink.docs.push(doc);
1749
+ } catch (err) {
1750
+ deps.sink.skipped.push({ path: url, reason: err instanceof Error ? err.message : "Word document fetch failed" });
1751
+ }
1752
+ }
1753
+ async function handleBrowserEpub(url, deps) {
1754
+ try {
1755
+ const res = await fetch(url, { redirect: "follow" });
1756
+ if (!res.ok) {
1757
+ deps.sink.skipped.push({ path: url, reason: `HTTP ${res.status}` });
1758
+ return;
1759
+ }
1760
+ const data = new Uint8Array(await res.arrayBuffer());
1761
+ const { doc, reason } = await buildEpubDoc(url, data, deps);
1762
+ if (reason || !doc) {
1763
+ deps.sink.skipped.push({ path: url, reason: reason ?? "EPUB produced no document" });
1764
+ return;
1765
+ }
1766
+ deps.sink.docs.push(doc);
1767
+ } catch (err) {
1768
+ deps.sink.skipped.push({ path: url, reason: err instanceof Error ? err.message : "EPUB fetch failed" });
1769
+ }
1770
+ }
1084
1771
  async function handleBrowserPage(ctx, deps) {
1085
1772
  const request = ctx.request;
1086
1773
  const url = request.loadedUrl ?? request.url;
1774
+ if (PDF_URL_RE.test(url)) {
1775
+ await handleBrowserPdf(url, deps);
1776
+ return;
1777
+ }
1778
+ if (DOC_URL_RE.test(url)) {
1779
+ await handleBrowserDoc(url, deps);
1780
+ return;
1781
+ }
1782
+ if (EPUB_URL_RE.test(url)) {
1783
+ await handleBrowserEpub(url, deps);
1784
+ return;
1785
+ }
1087
1786
  const status = responseStatus(ctx.response);
1088
1787
  if (status === void 0 || status < 200 || status >= 300) {
1089
1788
  deps.sink.skipped.push({ path: url, reason: status === void 0 ? "no HTTP response" : `HTTP ${status}` });
1090
1789
  return;
1091
1790
  }
1092
1791
  const html = await ctx.page.content();
1093
- const page = extractPage(html);
1792
+ const page = extractPage(html, { filter: deps.htmlFilter });
1793
+ if (deps.htmlFilter && !page.filterMatched) {
1794
+ deps.sink.skipped.push({ path: url, reason: `html filter '${deps.htmlFilter}' matched nothing` });
1795
+ return;
1796
+ }
1094
1797
  if (!page.markdown.trim()) {
1095
1798
  deps.sink.skipped.push({ path: url, reason: "empty after render" });
1096
1799
  return;
@@ -1101,7 +1804,29 @@ async function handleBrowserPage(ctx, deps) {
1101
1804
  return;
1102
1805
  }
1103
1806
  deps.sink.docs.push(doc);
1104
- await ctx.enqueueLinks({ strategy: "same-hostname", exclude: [BINARY_LINK_RE] });
1807
+ if (deps.followLinks !== false) {
1808
+ const selector = deps.linkSelector;
1809
+ if (selector) {
1810
+ const elements = await ctx.page.$$(selector);
1811
+ if (elements.length > 0) {
1812
+ deps.selectorStats.hits += 1;
1813
+ await ctx.enqueueLinks({
1814
+ selector,
1815
+ strategy: "same-hostname",
1816
+ exclude: [BINARY_LINK_RE],
1817
+ globs: deps.urlFilter
1818
+ });
1819
+ } else {
1820
+ deps.selectorStats.misses += 1;
1821
+ }
1822
+ } else {
1823
+ await ctx.enqueueLinks({
1824
+ strategy: "same-hostname",
1825
+ exclude: [BINARY_LINK_RE],
1826
+ globs: deps.urlFilter
1827
+ });
1828
+ }
1829
+ }
1105
1830
  }
1106
1831
  async function crawlWithBrowser(kind, queue, seedUrls, opts, sink) {
1107
1832
  const crawler = kind === "playwright" ? new PlaywrightCrawler({
@@ -1142,20 +1867,66 @@ async function crawlWithBrowser(kind, queue, seedUrls, opts, sink) {
1142
1867
  });
1143
1868
  }
1144
1869
  }
1145
- async function crawlWebsite(seed, opts) {
1146
- const seedUrl = normalizeSeedUrl(seed);
1147
- const kind = opts.crawler;
1870
+ function normalizeSitemapUrls(urls) {
1871
+ const out = [];
1872
+ const seen = /* @__PURE__ */ new Set();
1873
+ for (const raw of urls) {
1874
+ let url;
1875
+ try {
1876
+ url = new URL(raw);
1877
+ } catch {
1878
+ continue;
1879
+ }
1880
+ if (url.protocol !== "http:" && url.protocol !== "https:") continue;
1881
+ url.hash = "";
1882
+ const key = url.toString();
1883
+ if (seen.has(key)) continue;
1884
+ seen.add(key);
1885
+ out.push(key);
1886
+ }
1887
+ return out;
1888
+ }
1889
+ function applyUrlFilter(urls, globs) {
1890
+ if (!globs || globs.length === 0) return urls;
1891
+ const requests = urls.map((url) => new CrawleeRequest({ url }));
1892
+ const patterns = constructGlobObjectsFromGlobs(globs);
1893
+ return filterRequestsByPatterns(requests, patterns).map((r) => r.url);
1894
+ }
1895
+ async function loadSitemap(urls) {
1896
+ try {
1897
+ const sitemap = await Sitemap.load(urls, void 0, { reportNetworkErrors: false });
1898
+ return normalizeSitemapUrls(sitemap.urls);
1899
+ } catch {
1900
+ return [];
1901
+ }
1902
+ }
1903
+ async function discoverSitemapUrls(seedUrl) {
1904
+ const origin = new URL(seedUrl).origin;
1905
+ const fromSitemapXml = await loadSitemap([`${origin}/sitemap.xml`]);
1906
+ if (fromSitemapXml.length > 0) return fromSitemapXml;
1907
+ try {
1908
+ const robots = await RobotsTxtFile.find(seedUrl);
1909
+ const referenced = robots.getSitemaps();
1910
+ if (referenced.length > 0) {
1911
+ const fromRobots = await loadSitemap(referenced);
1912
+ if (fromRobots.length > 0) return fromRobots;
1913
+ }
1914
+ } catch {
1915
+ }
1916
+ return void 0;
1917
+ }
1918
+ async function runCrawl(kind, seedUrls, opts) {
1148
1919
  if (kind === "playwright" || kind === "puppeteer") {
1149
1920
  await requireBrowserEngine(kind);
1150
1921
  const sink2 = { docs: [], skipped: [] };
1151
1922
  const queue = await freshQueue();
1152
- await crawlWithBrowser(kind, queue, [seedUrl], opts, sink2);
1923
+ await crawlWithBrowser(kind, queue, seedUrls, opts, sink2);
1153
1924
  return { docs: dedupeDocs(sink2.docs), skipped: sink2.skipped };
1154
1925
  }
1155
1926
  const shellUrls = [];
1156
1927
  const sink = { docs: [], skipped: [], shellUrls };
1157
1928
  const staticQueue = await freshQueue();
1158
- await crawlWithCheerio(staticQueue, [seedUrl], opts, sink);
1929
+ await crawlWithCheerio(staticQueue, seedUrls, opts, sink);
1159
1930
  if (kind !== "auto" || shellUrls.length === 0) {
1160
1931
  return { docs: dedupeDocs(sink.docs), skipped: sink.skipped };
1161
1932
  }
@@ -1174,6 +1945,36 @@ async function crawlWebsite(seed, opts) {
1174
1945
  skipped: [...sink.skipped, ...browserSink.skipped]
1175
1946
  };
1176
1947
  }
1948
+ async function crawlWebsite(seed, opts) {
1949
+ const seedUrl = normalizeSeedUrl(seed);
1950
+ const kind = opts.crawler;
1951
+ const mode = opts.sitemap ?? { kind: "auto" };
1952
+ const selectorStats = { hits: 0, misses: 0 };
1953
+ if (mode.kind === "auto" || mode.kind === "sitemap-only") {
1954
+ const sitemapUrls = await discoverSitemapUrls(seedUrl);
1955
+ if (sitemapUrls) {
1956
+ const filtered = applyUrlFilter(sitemapUrls, opts.urlFilter);
1957
+ const detail = filtered.length < sitemapUrls.length ? `${filtered.length} matching --url-filter` : `${filtered.length} URL(s)`;
1958
+ opts.log(`sitemap: ${sitemapUrls.length} URL(s) discovered, ${detail} to crawl`);
1959
+ return runCrawl(kind, filtered, { ...opts, followLinks: false, selectorStats });
1960
+ }
1961
+ if (mode.kind === "sitemap-only") {
1962
+ opts.log(`warning: no sitemap.xml and no sitemaps in robots.txt found for ${seedUrl}; nothing to ingest`);
1963
+ return { docs: [], skipped: [] };
1964
+ }
1965
+ opts.log("no sitemap found; falling back to HTML link crawling");
1966
+ }
1967
+ const result = await runCrawl(kind, [seedUrl], {
1968
+ ...opts,
1969
+ followLinks: true,
1970
+ linkSelector: mode.kind === "selector" ? mode.selector : void 0,
1971
+ selectorStats
1972
+ });
1973
+ if (mode.kind === "selector" && selectorStats.hits === 0 && selectorStats.misses > 0) {
1974
+ opts.log(`warning: --sitemap selector '${mode.selector}' matched no links`);
1975
+ }
1976
+ return result;
1977
+ }
1177
1978
  async function feedUrl(ks, url, opts = {}) {
1178
1979
  const log2 = opts.log ?? ((m) => console.log(m));
1179
1980
  const kind = opts.crawler ?? "auto";
@@ -1191,6 +1992,10 @@ async function feedUrl(ks, url, opts = {}) {
1191
1992
  maxLen,
1192
1993
  overlap,
1193
1994
  tags: opts.tags,
1995
+ urlFilter: opts.urlFilter,
1996
+ htmlFilter: opts.htmlFilter,
1997
+ filePassword: opts.filePassword,
1998
+ sitemap: opts.sitemap,
1194
1999
  log: log2
1195
2000
  });
1196
2001
  for (const s of skipped) {
@@ -1434,7 +2239,7 @@ async function runChecks(opts) {
1434
2239
  }
1435
2240
 
1436
2241
  // src/version.ts
1437
- var VERSION = "1.1.0";
2242
+ var VERSION = "1.3.0";
1438
2243
 
1439
2244
  // src/cli.ts
1440
2245
  async function run(fn) {
@@ -1475,6 +2280,11 @@ function parseTags(tags) {
1475
2280
  const list = tags.split(",").map((t) => t.trim()).filter(Boolean);
1476
2281
  return list.length > 0 ? list : void 0;
1477
2282
  }
2283
+ function parseGlobs(globs) {
2284
+ if (!globs || globs.length === 0) return void 0;
2285
+ const list = [...new Set(globs.map((g) => g.trim()).filter(Boolean))];
2286
+ return list.length > 0 ? list : void 0;
2287
+ }
1478
2288
  var program = new Command();
1479
2289
  program.name("gresmcp").description("Manage Postgres-backed knowledge sources for the gresmcp MCP server").version(VERSION);
1480
2290
  program.command("init").description("Initialize the database schema (extension, ks table; repairs entry tables)").action(
@@ -1609,12 +2419,40 @@ ksCmd.command("delete <name>").description("Delete a knowledge source and all of
1609
2419
  console.log(`Deleted knowledge source '${name}'`);
1610
2420
  })
1611
2421
  );
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(
2422
+ 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, pdf, doc, docx, epub)").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(
2423
+ "--url-filter <glob>",
2424
+ "Only follow --url links whose URL matches this glob (repeatable; a link is followed if it matches any glob, e.g. '**/docs/**')",
2425
+ ((v, prev) => [...prev, v]),
2426
+ []
2427
+ ).option(
2428
+ "--html-filter <selector>",
2429
+ "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"
2430
+ ).option(
2431
+ "--sitemap <mode>",
2432
+ "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')"
2433
+ ).option(
2434
+ "--file-password <password>",
2435
+ "Password for encrypted files (PDF, DOC/DOCX); applies to --path and --url ingestion. Owner-restricted PDFs without a user password are read without it"
2436
+ ).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
2437
  (name, opts) => run(async () => {
1614
2438
  await initDb();
1615
2439
  const ks = requireKs(await getKs(name), name);
1616
2440
  const modes = [opts.path, opts.url, opts.text, opts.stdin].filter(Boolean).length;
1617
2441
  if (modes !== 1) throw new Error("provide exactly one of --path, --url, --text or --stdin");
2442
+ const urlFilter = parseGlobs(opts.urlFilter);
2443
+ if (urlFilter && !opts.url) {
2444
+ console.error("warning: --url-filter has no effect without --url");
2445
+ }
2446
+ if (opts.htmlFilter) parseHtmlFilter(opts.htmlFilter);
2447
+ if (opts.htmlFilter && !opts.path && !opts.url) {
2448
+ console.error("warning: --html-filter has no effect without --path or --url");
2449
+ }
2450
+ let sitemap;
2451
+ if (opts.sitemap !== void 0) {
2452
+ sitemap = parseSitemapOption(opts.sitemap);
2453
+ if (sitemap.kind === "selector") validateCssSelector(sitemap.selector);
2454
+ if (!opts.url) console.error("warning: --sitemap has no effect without --url");
2455
+ }
1618
2456
  const common = {
1619
2457
  replace: opts.replace,
1620
2458
  dryRun: opts.dryRun,
@@ -1622,7 +2460,9 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1622
2460
  maxLen: opts.chunkSize && opts.chunkSize > 0 ? opts.chunkSize : void 0,
1623
2461
  overlap: opts.overlap,
1624
2462
  tags: parseTags(opts.tags),
1625
- metadata: parseKeyValue(opts.metadata ?? [])
2463
+ metadata: parseKeyValue(opts.metadata ?? []),
2464
+ htmlFilter: opts.htmlFilter,
2465
+ filePassword: opts.filePassword
1626
2466
  };
1627
2467
  let summary;
1628
2468
  const since = await dbClockTimestamp();
@@ -1633,7 +2473,7 @@ program.command("feed <ks>").description("Feed data into a knowledge source (fro
1633
2473
  if (crawler && !CRAWLER_KINDS.includes(crawler)) {
1634
2474
  throw new Error(`unknown crawler '${crawler}' (use auto, crawlee, playwright or puppeteer)`);
1635
2475
  }
1636
- summary = await feedUrl(ks, opts.url, { ...common, crawler, maxPages: opts.maxPages, depth: opts.depth });
2476
+ summary = await feedUrl(ks, opts.url, { ...common, crawler, sitemap, maxPages: opts.maxPages, depth: opts.depth, urlFilter });
1637
2477
  } else {
1638
2478
  const text = opts.stdin ? await readStdin() : opts.text;
1639
2479
  if (!text.trim()) throw new Error("empty input text");