From 4261e84eb8a3730c7716f0e1645e6d70249fd277 Mon Sep 17 00:00:00 2001 From: Yoomitech <301026915+Yoomitech@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:12:39 +0200 Subject: [PATCH] fix(extract): honor HTML base href for relative URLs --- extract/content.go | 22 ++++++++--- extract/extractor.go | 10 ++++- extract/extractor_test.go | 80 +++++++++++++++++++++++++++++++++++++++ extract/metadata.go | 36 ++++++++++++++++++ 4 files changed, 140 insertions(+), 8 deletions(-) diff --git a/extract/content.go b/extract/content.go index c8fc852..c19dbf0 100644 --- a/extract/content.go +++ b/extract/content.go @@ -40,9 +40,19 @@ const minCleanTextRunes = 250 // comes back too thin for a page that clearly had content, it transparently // falls back to full-body extraction. When clean is false it skips article // detection entirely and converts the whole readable . -func extractContent(htmlBytes []byte, baseURL string, clean bool) (contentResult, error) { +// extractContent keeps the fetched document URL separate from the effective +// HTML link base. This matters for pages using . +func extractContent( + htmlBytes []byte, + documentURL string, + linkBaseURL string, + clean bool, +) (contentResult, error) { + if strings.TrimSpace(linkBaseURL) == "" { + linkBaseURL = documentURL + } if !clean { - return extractFullBody(htmlBytes, baseURL) + return extractFullBody(htmlBytes, linkBaseURL) } var out contentResult @@ -54,7 +64,7 @@ func extractContent(htmlBytes []byte, baseURL string, clean bool) (contentResult IncludeLinks: true, Deduplicate: true, } - if parsed, err := url.Parse(baseURL); err == nil { + if parsed, err := url.Parse(documentURL); err == nil { opts.OriginalURL = parsed } extracted, err := trafilatura.Extract(bytes.NewReader(htmlBytes), opts) @@ -62,7 +72,7 @@ func extractContent(htmlBytes []byte, baseURL string, clean bool) (contentResult return out, err } if extracted == nil || extracted.ContentNode == nil { - return extractFullBody(htmlBytes, baseURL) + return extractFullBody(htmlBytes, linkBaseURL) } var htmlBuf bytes.Buffer @@ -75,7 +85,7 @@ func extractContent(htmlBytes []byte, baseURL string, clean bool) (contentResult out.Description = strings.TrimSpace(extracted.Metadata.Description) out.Lang = strings.TrimSpace(extracted.Metadata.Language) - markdown, err := htmlToMarkdown(out.HTML, baseURL) + markdown, err := htmlToMarkdown(out.HTML, linkBaseURL) if err != nil { return out, err } @@ -84,7 +94,7 @@ func extractContent(htmlBytes []byte, baseURL string, clean bool) (contentResult // trafilatura was too aggressive: the cleaned article is near-empty but the // raw page had real visible text. Prefer the fuller readable-body pass. if len([]rune(out.Text)) < minCleanTextRunes { - if full, ferr := extractFullBody(htmlBytes, baseURL); ferr == nil && + if full, ferr := extractFullBody(htmlBytes, linkBaseURL); ferr == nil && len([]rune(full.Text)) > len([]rune(out.Text)) { // Keep trafilatura's metadata (title/description/lang) when present; // it is usually cleaner than what we derive from the full body. diff --git a/extract/extractor.go b/extract/extractor.go index cec0580..69d8a9a 100644 --- a/extract/extractor.go +++ b/extract/extractor.go @@ -135,8 +135,14 @@ func buildResult(req ExtractRequest, resp *FetchResponse, mode string, startedAt if err != nil { return nil, err } - metadata := parseMetadata(doc, req.URL) - content, contentErr := extractContent(body, req.URL, !req.FullPage) + linkBaseURL := effectiveBaseURL(doc, req.URL) + metadata := parseMetadata(doc, linkBaseURL) + content, contentErr := extractContent( + body, + req.URL, + linkBaseURL, + !req.FullPage, + ) result := &ExtractResult{ URL: req.URL, Title: firstNonEmpty(content.Title, metadata.Title), diff --git a/extract/extractor_test.go b/extract/extractor_test.go index 316aa41..71527ac 100644 --- a/extract/extractor_test.go +++ b/extract/extractor_test.go @@ -305,6 +305,86 @@ func TestParseMetadataRichTags(t *testing.T) { } } +func TestParseMetadataHonorsBaseHref(t *testing.T) { + doc, err := documentFromString(` + + + + + + + Die Weisheit des Schamanen + +`) + if err != nil { + t.Fatal(err) + } + + documentURL := "https://example.com/de/ein_wolf_kommt_zu_wort.html" + linkBaseURL := effectiveBaseURL(doc, documentURL) + + if linkBaseURL != "https://example.com/" { + t.Fatalf("effective base URL = %q, want %q", linkBaseURL, "https://example.com/") + } + + meta := parseMetadata(doc, linkBaseURL) + + if meta.Canonical != "https://example.com/de/canonical.html" { + t.Fatalf("canonical = %q", meta.Canonical) + } + + if len(meta.Links) != 1 { + t.Fatalf("link count = %d, want 1", len(meta.Links)) + } + + want := "https://example.com/de/die_weisheit_des_schamanen.html" + if meta.Links[0].URL != want { + t.Fatalf("resolved link = %q, want %q", meta.Links[0].URL, want) + } + + if strings.Contains(meta.Links[0].URL, "/de/de/") { + t.Fatalf("resolved link contains duplicated language path: %q", meta.Links[0].URL) + } +} + +func TestExtractContentHonorsBaseHref(t *testing.T) { + htmlBytes := []byte(` + + +

Die Weisheit des Schamanen

+ Beispiel + +`) + + result, err := extractContent( + htmlBytes, + "https://example.com/de/ein_wolf_kommt_zu_wort.html", + "https://example.com/", + false, + ) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains( + result.Markdown, + "https://example.com/de/die_weisheit_des_schamanen.html", + ) { + t.Fatalf("markdown link was not resolved against : %q", result.Markdown) + } + + if !strings.Contains( + result.Markdown, + "https://example.com/de/images/example.png", + ) { + t.Fatalf("markdown image was not resolved against : %q", result.Markdown) + } + + if strings.Contains(result.Markdown, "/de/de/") { + t.Fatalf("markdown contains duplicated language path: %q", result.Markdown) + } +} + func documentFromString(raw string) (*goquery.Document, error) { return goquery.NewDocumentFromReader(strings.NewReader(raw)) } diff --git a/extract/metadata.go b/extract/metadata.go index e34c1ca..37450ff 100644 --- a/extract/metadata.go +++ b/extract/metadata.go @@ -20,6 +20,42 @@ type pageMetadata struct { Links []Link } +// effectiveBaseURL returns the URL base defined by the document's first +// element. When no usable HTTP(S) base exists, the original +// document URL remains the base. +// +// Relative links in HTML must be resolved against , not blindly +// against the fetched document URL. +func effectiveBaseURL(doc *goquery.Document, documentURL string) string { + documentURL = strings.TrimSpace(documentURL) + if doc == nil || documentURL == "" { + return documentURL + } + + baseHref, ok := doc.Find("base[href]").First().Attr("href") + baseHref = strings.TrimSpace(baseHref) + if !ok || baseHref == "" { + return documentURL + } + + resolved := resolveURL(documentURL, baseHref) + if resolved == "" { + return documentURL + } + + parsed, err := url.Parse(resolved) + if err != nil { + return documentURL + } + + // A document base for fetched web content must remain an HTTP(S) URL. + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return documentURL + } + + return parsed.String() +} + func parseMetadata(doc *goquery.Document, baseURL string) pageMetadata { var meta pageMetadata if doc == nil {