| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129 |
- // Package extract finds the readable article in a web page, along with its
- // metadata.
- package extract
- import (
- "errors"
- "net/url"
- "strings"
- "time"
- "unicode/utf8"
- trafilatura "github.com/markusmobius/go-trafilatura"
- "golang.org/x/net/html"
- "golang.org/x/text/language"
- )
- // minTextLen is the shortest article text, in characters, worth turning
- // into a book. Anything shorter is usually a JavaScript shell, an error page
- // or a cookie wall.
- const minTextLen = 200
- // ErrNoContent reports that no readable article was found.
- var ErrNoContent = errors.New("no readable article content found (the page may need JavaScript, which isn't supported yet)")
- // Article is the extracted article.
- type Article struct {
- URL string // final page URL
- Title string
- Author string
- SiteName string
- Description string
- Language string // BCP 47 code, "en" when unknown
- Published time.Time
- ImageURL string // the page's preview image, for the cover; may be empty
- Content *html.Node // the article body; its children are the content
- }
- // Extract finds the article in doc, a parsed page downloaded from pageURL.
- // It may modify doc.
- func Extract(doc *html.Node, pageURL *url.URL) (*Article, error) {
- // Read the page's own metadata first: extraction modifies doc.
- meta := readPageMeta(doc, pageURL)
- res, err := trafilatura.ExtractDocument(doc, trafilatura.Options{
- OriginalURL: pageURL,
- EnableFallback: true,
- IncludeLinks: true,
- IncludeImages: true,
- ExcludeComments: true,
- // The default date search also scans body text and invents dates
- // from stray numbers (an image width of 2000 became 1 January 2000).
- // Fast mode only trusts metadata, URLs and date-like markup.
- HtmlDateMode: trafilatura.Fast,
- })
- if err != nil || res.ContentNode == nil ||
- utf8.RuneCountInString(strings.TrimSpace(res.ContentText)) < minTextLen {
- return nil, ErrNoContent
- }
- // go-trafilatura's metadata comes first; the page's own declarations
- // fill the gaps.
- m := res.Metadata
- a := &Article{
- URL: pageURL.String(),
- Title: first(m.Title, meta.Title, pageURL.Hostname()),
- Author: first(m.Author, meta.Author),
- SiteName: first(m.Sitename, meta.SiteName),
- Description: first(m.Description, meta.Description),
- Language: languageTag(m.Language),
- Published: m.Date,
- ImageURL: meta.Image,
- Content: res.ContentNode,
- }
- if a.Published.IsZero() {
- a.Published = meta.Published
- }
- removeLeadingTitle(a.Content, a.Title)
- return a, nil
- }
- // languageTag returns s as a well-formed BCP 47 tag ("en_US" → "en-US"),
- // or "en" when s is missing or invalid.
- func languageTag(s string) string {
- tag, err := language.Parse(strings.ReplaceAll(strings.TrimSpace(s), "_", "-"))
- if err != nil || tag == language.Und {
- return "en"
- }
- return tag.String()
- }
- // removeLeadingTitle drops the first element of the content when it is a
- // heading repeating the title, which the book already shows.
- func removeLeadingTitle(content *html.Node, title string) {
- first := content.FirstChild
- for first != nil && first.Type == html.TextNode && strings.TrimSpace(first.Data) == "" {
- first = first.NextSibling
- }
- if first == nil || first.Type != html.ElementNode {
- return
- }
- // Match on the tag name: go-trafilatura creates some output elements
- // without setting DataAtom.
- switch first.Data {
- case "h1", "h2":
- if normalize(textContent(first)) == normalize(title) {
- content.RemoveChild(first)
- }
- }
- }
- func textContent(n *html.Node) string {
- var b strings.Builder
- var walk func(*html.Node)
- walk = func(n *html.Node) {
- if n.Type == html.TextNode {
- b.WriteString(n.Data)
- }
- for c := n.FirstChild; c != nil; c = c.NextSibling {
- walk(c)
- }
- }
- walk(n)
- return b.String()
- }
- // normalize lowercases s and collapses whitespace, for comparing titles.
- func normalize(s string) string {
- return strings.Join(strings.Fields(strings.ToLower(s)), " ")
- }
|