@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/README.md +69 -15
- package/dist/cli.js +998 -158
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +1 -1
- package/dist/mcp.js.map +1 -1
- package/package.json +9 -1
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
|
|
329
|
-
import
|
|
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
|
|
394
|
-
const parent =
|
|
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
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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 (
|
|
962
|
+
if (this.slots.length === 0 && this.total === 0 && this.rejected === 0) return;
|
|
598
963
|
if (this.interactive) {
|
|
599
|
-
this.redraw(
|
|
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(
|
|
1003
|
+
this.redraw(this.lines());
|
|
615
1004
|
}
|
|
616
|
-
printFileLine() {
|
|
617
|
-
|
|
618
|
-
this.
|
|
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
|
|
628
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
1166
|
+
let buf;
|
|
782
1167
|
try {
|
|
783
|
-
|
|
1168
|
+
buf = await fs2.readFile(file);
|
|
784
1169
|
} catch {
|
|
785
1170
|
skipped.push({ path: file, reason: "unreadable" });
|
|
786
1171
|
continue;
|
|
787
1172
|
}
|
|
788
|
-
|
|
789
|
-
skipped.push({ path: file, reason: "binary content" });
|
|
790
|
-
continue;
|
|
791
|
-
}
|
|
792
|
-
let text = raw;
|
|
1173
|
+
let text = "";
|
|
793
1174
|
let title = "";
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1146
|
-
const
|
|
1147
|
-
const
|
|
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,
|
|
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,
|
|
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.
|
|
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(
|
|
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");
|