Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions extract/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <body>.
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 <base href>.
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
Expand All @@ -54,15 +64,15 @@ 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)
if err != nil {
return out, err
}
if extracted == nil || extracted.ContentNode == nil {
return extractFullBody(htmlBytes, baseURL)
return extractFullBody(htmlBytes, linkBaseURL)
}

var htmlBuf bytes.Buffer
Expand All @@ -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
}
Expand All @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions extract/extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
80 changes: 80 additions & 0 deletions extract/extractor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,86 @@ func TestParseMetadataRichTags(t *testing.T) {
}
}

func TestParseMetadataHonorsBaseHref(t *testing.T) {
doc, err := documentFromString(`<!doctype html>
<html lang="de">
<head>
<base href="/">
<link rel="canonical" href="de/canonical.html">
</head>
<body>
<a href="de/die_weisheit_des_schamanen.html">Die Weisheit des Schamanen</a>
</body>
</html>`)
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(`<!doctype html>
<html>
<body>
<p><a href="de/die_weisheit_des_schamanen.html">Die Weisheit des Schamanen</a></p>
<img src="de/images/example.png" alt="Beispiel">
</body>
</html>`)

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 <base href>: %q", result.Markdown)
}

if !strings.Contains(
result.Markdown,
"https://example.com/de/images/example.png",
) {
t.Fatalf("markdown image was not resolved against <base href>: %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))
}
36 changes: 36 additions & 0 deletions extract/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,42 @@ type pageMetadata struct {
Links []Link
}

// effectiveBaseURL returns the URL base defined by the document's first
// <base href> element. When no usable HTTP(S) base exists, the original
// document URL remains the base.
//
// Relative links in HTML must be resolved against <base href>, 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 {
Expand Down