1
0

extract.go 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. // Package extract finds the readable article in a web page, along with its
  2. // metadata.
  3. package extract
  4. import (
  5. "errors"
  6. "net/url"
  7. "strings"
  8. "time"
  9. "unicode/utf8"
  10. trafilatura "github.com/markusmobius/go-trafilatura"
  11. "golang.org/x/net/html"
  12. "golang.org/x/text/language"
  13. )
  14. // minTextLen is the shortest article text, in characters, worth turning
  15. // into a book. Anything shorter is usually a JavaScript shell, an error page
  16. // or a cookie wall.
  17. const minTextLen = 200
  18. // ErrNoContent reports that no readable article was found.
  19. var ErrNoContent = errors.New("no readable article content found (the page may need JavaScript, which isn't supported yet)")
  20. // Article is the extracted article.
  21. type Article struct {
  22. URL string // final page URL
  23. Title string
  24. Author string
  25. SiteName string
  26. Description string
  27. Language string // BCP 47 code, "en" when unknown
  28. Published time.Time
  29. ImageURL string // the page's preview image, for the cover; may be empty
  30. Content *html.Node // the article body; its children are the content
  31. }
  32. // Extract finds the article in doc, a parsed page downloaded from pageURL.
  33. // It may modify doc.
  34. func Extract(doc *html.Node, pageURL *url.URL) (*Article, error) {
  35. // Read the page's own metadata first: extraction modifies doc.
  36. meta := readPageMeta(doc, pageURL)
  37. res, err := trafilatura.ExtractDocument(doc, trafilatura.Options{
  38. OriginalURL: pageURL,
  39. EnableFallback: true,
  40. IncludeLinks: true,
  41. IncludeImages: true,
  42. ExcludeComments: true,
  43. // The default date search also scans body text and invents dates
  44. // from stray numbers (an image width of 2000 became 1 January 2000).
  45. // Fast mode only trusts metadata, URLs and date-like markup.
  46. HtmlDateMode: trafilatura.Fast,
  47. })
  48. if err != nil || res.ContentNode == nil ||
  49. utf8.RuneCountInString(strings.TrimSpace(res.ContentText)) < minTextLen {
  50. return nil, ErrNoContent
  51. }
  52. // go-trafilatura's metadata comes first; the page's own declarations
  53. // fill the gaps.
  54. m := res.Metadata
  55. a := &Article{
  56. URL: pageURL.String(),
  57. Title: first(m.Title, meta.Title, pageURL.Hostname()),
  58. Author: first(m.Author, meta.Author),
  59. SiteName: first(m.Sitename, meta.SiteName),
  60. Description: first(m.Description, meta.Description),
  61. Language: languageTag(m.Language),
  62. Published: m.Date,
  63. ImageURL: meta.Image,
  64. Content: res.ContentNode,
  65. }
  66. if a.Published.IsZero() {
  67. a.Published = meta.Published
  68. }
  69. removeLeadingTitle(a.Content, a.Title)
  70. return a, nil
  71. }
  72. // languageTag returns s as a well-formed BCP 47 tag ("en_US" → "en-US"),
  73. // or "en" when s is missing or invalid.
  74. func languageTag(s string) string {
  75. tag, err := language.Parse(strings.ReplaceAll(strings.TrimSpace(s), "_", "-"))
  76. if err != nil || tag == language.Und {
  77. return "en"
  78. }
  79. return tag.String()
  80. }
  81. // removeLeadingTitle drops the first element of the content when it is a
  82. // heading repeating the title, which the book already shows.
  83. func removeLeadingTitle(content *html.Node, title string) {
  84. first := content.FirstChild
  85. for first != nil && first.Type == html.TextNode && strings.TrimSpace(first.Data) == "" {
  86. first = first.NextSibling
  87. }
  88. if first == nil || first.Type != html.ElementNode {
  89. return
  90. }
  91. // Match on the tag name: go-trafilatura creates some output elements
  92. // without setting DataAtom.
  93. switch first.Data {
  94. case "h1", "h2":
  95. if normalize(textContent(first)) == normalize(title) {
  96. content.RemoveChild(first)
  97. }
  98. }
  99. }
  100. func textContent(n *html.Node) string {
  101. var b strings.Builder
  102. var walk func(*html.Node)
  103. walk = func(n *html.Node) {
  104. if n.Type == html.TextNode {
  105. b.WriteString(n.Data)
  106. }
  107. for c := n.FirstChild; c != nil; c = c.NextSibling {
  108. walk(c)
  109. }
  110. }
  111. walk(n)
  112. return b.String()
  113. }
  114. // normalize lowercases s and collapses whitespace, for comparing titles.
  115. func normalize(s string) string {
  116. return strings.Join(strings.Fields(strings.ToLower(s)), " ")
  117. }