Old page
Old body
diff --git a/common/constants.py b/common/constants.py index 417628089e..febfd34071 100644 --- a/common/constants.py +++ b/common/constants.py @@ -142,6 +142,7 @@ class FileSource(StrEnum): LOCAL = "" KNOWLEDGEBASE = "knowledgebase" RSS = "rss" + SITEMAP = "sitemap" S3 = "s3" NOTION = "notion" REST_API = "rest_api" diff --git a/common/data_source/__init__.py b/common/data_source/__init__.py index 909f4c22ae..fdb2871f2b 100644 --- a/common/data_source/__init__.py +++ b/common/data_source/__init__.py @@ -63,6 +63,7 @@ from .rss_connector import RSSConnector from .salesforce_connector import SalesforceConnector from .seafile_connector import SeaFileConnector from .sharepoint_connector import SharePointConnector +from .sitemap_connector import SitemapConnector from .slack_connector import SlackConnector from .teams_connector import TeamsConnector from .webdav_connector import WebDAVConnector @@ -83,6 +84,7 @@ CONNECTOR_BY_SOURCE: dict[str, type] = { FileSource.GOOGLE_DRIVE: GoogleDriveConnector, FileSource.JIRA: JiraConnector, FileSource.SHAREPOINT: SharePointConnector, + FileSource.SITEMAP: SitemapConnector, FileSource.SLACK: SlackConnector, FileSource.TEAMS: TeamsConnector, FileSource.WEBDAV: WebDAVConnector, @@ -160,6 +162,7 @@ __all__ = [ "SalesforceConnector", "SeaFileConnector", "SharePointConnector", + "SitemapConnector", "SlackConnector", "TeamsConnector", "TextSection", diff --git a/common/data_source/config.py b/common/data_source/config.py index 46089ff62c..da3335760a 100644 --- a/common/data_source/config.py +++ b/common/data_source/config.py @@ -44,6 +44,7 @@ class DocumentSource(str, Enum): """Document sources""" RSS = "rss" + SITEMAP = "sitemap" S3 = "s3" NOTION = "notion" REST_API = "rest_api" diff --git a/common/data_source/html_utils.py b/common/data_source/html_utils.py index 9fd33a7b0e..e64ea1963a 100644 --- a/common/data_source/html_utils.py +++ b/common/data_source/html_utils.py @@ -48,8 +48,13 @@ def format_element_text(element_text: str, link_href: str | None) -> str: return f"[{element_text_no_newlines}]({link_href})" -def parse_html_with_trafilatura(html_content: str) -> str: - """Parse HTML content using trafilatura.""" +def parse_html_with_trafilatura(html_content: str, output_format: str = "txt") -> str: + """Parse HTML content using trafilatura. + + ``output_format`` is passed through to trafilatura: ``"txt"`` (default) yields the + flat text used by the web connectors, ``"markdown"`` keeps headings, lists, links + and tables as Markdown (see :func:`web_html_to_markdown`). + """ import trafilatura # type: ignore from trafilatura.settings import use_config # type: ignore @@ -59,6 +64,24 @@ def parse_html_with_trafilatura(html_content: str) -> str: config.set("DEFAULT", "include_images", "True") config.set("DEFAULT", "include_formatting", "True") + if output_format == "markdown": + # trafilatura reads the include_* switches from keyword arguments, not from the + # config object, so they are passed explicitly here: links and tables are kept + # as Markdown, images are dropped (noise for retrieval), and favor_recall keeps + # short but relevant sections. Markdown is whitespace-sensitive (nested lists, + # tables), so only the ends are trimmed. + extracted_text = trafilatura.extract( + html_content, + config=config, + output_format="markdown", + include_links=True, + include_tables=True, + include_images=False, + include_formatting=True, + favor_recall=True, + ) + return extracted_text.strip() if extracted_text else "" + extracted_text = trafilatura.extract(html_content, config=config) return strip_excessive_newlines_and_spaces(extracted_text) if extracted_text else "" @@ -155,11 +178,20 @@ def parse_html_page_basic(text: str | BytesIO | IO[bytes]) -> str: return format_document_soup(soup) -def web_html_cleanup( +def _clean_web_soup( page_content: str | bs4.BeautifulSoup, - mintlify_cleanup_enabled: bool = True, - additional_element_types_to_discard: list[str] | None = None, -) -> ParsedHTML: + mintlify_cleanup_enabled: bool, + additional_element_types_to_discard: list[str] | None, + remove_title: bool = True, +) -> tuple[str | None, bs4.BeautifulSoup]: + """Shared pre-processing of web pages: title extraction and removal of boilerplate. + + Boilerplate is dropped by CSS class (``WEB_CONNECTOR_IGNORED_CLASSES``, plus the + Mintlify ones when enabled) and by tag (``WEB_CONNECTOR_IGNORED_ELEMENTS`` such as + nav/footer/script, plus any caller-provided tags). The ``
Old body
New body with a guide and an external one and a download link.
Future body
`}, + "https://example.com/undated": {"text/html", `Undated body
`}, + "https://example.com/brochure.pdf": {"application/pdf", "%PDF-1.4 brochure"}, + "https://example.com/docs/guide.pdf": {"application/octet-stream", "%PDF-1.7 guide"}, + "https://cdn.other.org/ext.pdf": {"application/pdf", "%PDF-1.7 external"}, + "https://example.com/docs/manual.pdf?download=1#page=2": {"application/pdf", "%PDF-1.7 manual"}, + } +} + +func drainSitemapSession(t *testing.T, session SyncSession) ([]SyncBatch, []SourceDocument) { + t.Helper() + var ( + batches []SyncBatch + documents []SourceDocument + ) + for { + batch, err := session.NextBatch(context.Background()) + if errors.Is(err, io.EOF) { + return batches, documents + } + if err != nil { + t.Fatalf("NextBatch failed: %v", err) + } + batches = append(batches, batch) + documents = append(documents, batch.Documents...) + } +} + +func sitemapDocIDs(documents []SourceDocument) []string { + ids := make([]string, 0, len(documents)) + for _, doc := range documents { + ids = append(ids, doc.Metadata["url"].(string)) + } + return ids +} + +func expectedSitemapSourceID(rawURL string) string { + sum := md5.Sum([]byte(rawURL)) + return "sitemap:" + hex.EncodeToString(sum[:]) +} + +// TestSitemapConnectorFullSync verifies index recursion, deduplication, +// HTML to Markdown conversion, PDF detection, and batch checkpoints. +func TestSitemapConnectorFullSync(t *testing.T) { + connector := newSitemapTestConnector(t, map[string]any{"batch_size": 2}, defaultSitemapFixture()) + + session, err := connector.OpenSync(t.Context(), SyncRequest{FromBeginning: true, WindowEnd: mustTime(t, "2026-01-05T00:00:00Z")}) + if err != nil { + t.Fatalf("OpenSync failed: %v", err) + } + batches, documents := drainSitemapSession(t, session) + + want := []string{"https://example.com/old", "https://example.com/new", "https://example.com/future", "https://example.com/undated", "https://example.com/brochure.pdf"} + if got := sitemapDocIDs(documents); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("documents = %v, want %v", got, want) + } + if len(batches) != 3 { + t.Fatalf("batches = %d, want 3", len(batches)) + } + if batches[0].Checkpoint == nil || batches[0].Checkpoint.SourceID != expectedSitemapSourceID("https://example.com/new") { + t.Fatalf("first checkpoint = %+v", batches[0].Checkpoint) + } + + old := documents[0] + if old.SourceID != expectedSitemapSourceID("https://example.com/old") { + t.Fatalf("source id = %s", old.SourceID) + } + if old.SemanticIdentifier != "example.com/old" { + t.Fatalf("semantic identifier = %q", old.SemanticIdentifier) + } + if old.Extension != ".md" { + t.Fatalf("extension = %q, want .md", old.Extension) + } + if body := string(old.Blob); !strings.Contains(body, "# Old page") || !strings.Contains(body, "Old body") || strings.Contains(body, "Home") || strings.Contains(body, "Footer") { + t.Fatalf("markdown body = %q", body) + } + if old.UpdatedAt != mustTime(t, "2026-01-01T00:00:00Z") { + t.Fatalf("updated at = %v", old.UpdatedAt) + } + if old.Fingerprint == "" || old.Metadata["sitemap_url"] != "https://example.com/sitemap.xml" { + t.Fatalf("fingerprint/metadata = %q / %v", old.Fingerprint, old.Metadata) + } + + pdf := documents[4] + if pdf.Extension != ".pdf" || string(pdf.Blob) != "%PDF-1.4 brochure" { + t.Fatalf("pdf document = %+v", pdf) + } + if pdf.SemanticIdentifier != "example.com/brochure.pdf" { + t.Fatalf("pdf semantic identifier = %q", pdf.SemanticIdentifier) + } +} + +// TestSitemapConnectorIncrementalSync verifiessee link after
') == "see link after" + + +_PARAGRAPH = "" + " ".join(["This paragraph describes the installation process in enough detail to count as real content."] * 3) + "
" +# Realistic size on purpose: on very short documents trafilatura falls back to its +# plain-text baseline and emits no Markdown structure. +PAGE = f"""Install the CLI first.
{_PARAGRAPH} +