| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305 |
- // Copyright 2017 Frédéric Guillot. All rights reserved.
- // Use of this source code is governed by the Apache 2.0
- // license that can be found in the LICENSE file.
- package readability
- import (
- "bytes"
- "fmt"
- "io"
- "math"
- "regexp"
- "strings"
- "github.com/PuerkitoBio/goquery"
- "github.com/miniflux/miniflux/logger"
- "golang.org/x/net/html"
- )
- const (
- defaultTagsToScore = "section,h2,h3,h4,h5,h6,p,td,pre,div"
- )
- var (
- divToPElementsRegexp = regexp.MustCompile(`(?i)<(a|blockquote|dl|div|img|ol|p|pre|table|ul)`)
- sentenceRegexp = regexp.MustCompile(`\.( |$)`)
- blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
- okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
- unlikelyCandidatesRegexp = regexp.MustCompile(`(?i)banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|header|legends|menu|modal|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote`)
- negativeRegexp = regexp.MustCompile(`(?i)hidden|^hid$|hid$|hid|^hid |banner|combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|modal|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget|byline|author|dateline|writtenby|p-author`)
- positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
- )
- type candidate struct {
- selection *goquery.Selection
- score float32
- }
- func (c *candidate) Node() *html.Node {
- return c.selection.Get(0)
- }
- func (c *candidate) String() string {
- id, _ := c.selection.Attr("id")
- class, _ := c.selection.Attr("class")
- if id != "" && class != "" {
- return fmt.Sprintf("%s#%s.%s => %f", c.Node().DataAtom, id, class, c.score)
- } else if id != "" {
- return fmt.Sprintf("%s#%s => %f", c.Node().DataAtom, id, c.score)
- } else if class != "" {
- return fmt.Sprintf("%s.%s => %f", c.Node().DataAtom, class, c.score)
- }
- return fmt.Sprintf("%s => %f", c.Node().DataAtom, c.score)
- }
- type candidateList map[*html.Node]*candidate
- func (c candidateList) String() string {
- var output []string
- for _, candidate := range c {
- output = append(output, candidate.String())
- }
- return strings.Join(output, ", ")
- }
- // ExtractContent returns relevant content.
- func ExtractContent(page io.Reader) (string, error) {
- document, err := goquery.NewDocumentFromReader(page)
- if err != nil {
- return "", err
- }
- document.Find("script,style,noscript").Each(func(i int, s *goquery.Selection) {
- removeNodes(s)
- })
- transformMisusedDivsIntoParagraphs(document)
- removeUnlikelyCandidates(document)
- candidates := getCandidates(document)
- logger.Debug("[Readability] Candidates: %v", candidates)
- topCandidate := getTopCandidate(document, candidates)
- logger.Debug("[Readability] TopCandidate: %v", topCandidate)
- output := getArticle(topCandidate, candidates)
- return output, nil
- }
- // Now that we have the top candidate, look through its siblings for content that might also be related.
- // Things like preambles, content split by ads that we removed, etc.
- func getArticle(topCandidate *candidate, candidates candidateList) string {
- output := bytes.NewBufferString("<div>")
- siblingScoreThreshold := float32(math.Max(10, float64(topCandidate.score*.2)))
- topCandidate.selection.Siblings().Union(topCandidate.selection).Each(func(i int, s *goquery.Selection) {
- append := false
- node := s.Get(0)
- if node == topCandidate.Node() {
- append = true
- } else if c, ok := candidates[node]; ok && c.score >= siblingScoreThreshold {
- append = true
- }
- if s.Is("p") {
- linkDensity := getLinkDensity(s)
- content := s.Text()
- contentLength := len(content)
- if contentLength >= 80 && linkDensity < .25 {
- append = true
- } else if contentLength < 80 && linkDensity == 0 && sentenceRegexp.MatchString(content) {
- append = true
- }
- }
- if append {
- tag := "div"
- if s.Is("p") {
- tag = node.Data
- }
- html, _ := s.Html()
- fmt.Fprintf(output, "<%s>%s</%s>", tag, html, tag)
- }
- })
- output.Write([]byte("</div>"))
- return output.String()
- }
- func removeUnlikelyCandidates(document *goquery.Document) {
- document.Find("*").Not("html,body").Each(func(i int, s *goquery.Selection) {
- class, _ := s.Attr("class")
- id, _ := s.Attr("id")
- str := class + id
- if blacklistCandidatesRegexp.MatchString(str) || (unlikelyCandidatesRegexp.MatchString(str) && !okMaybeItsACandidateRegexp.MatchString(str)) {
- removeNodes(s)
- }
- })
- }
- func getTopCandidate(document *goquery.Document, candidates candidateList) *candidate {
- var best *candidate
- for _, c := range candidates {
- if best == nil {
- best = c
- } else if best.score < c.score {
- best = c
- }
- }
- if best == nil {
- best = &candidate{document.Find("body"), 0}
- }
- return best
- }
- // Loop through all paragraphs, and assign a score to them based on how content-y they look.
- // Then add their score to their parent node.
- // A score is determined by things like number of commas, class names, etc.
- // Maybe eventually link density.
- func getCandidates(document *goquery.Document) candidateList {
- candidates := make(candidateList)
- document.Find(defaultTagsToScore).Each(func(i int, s *goquery.Selection) {
- text := s.Text()
- // If this paragraph is less than 25 characters, don't even count it.
- if len(text) < 25 {
- return
- }
- parent := s.Parent()
- parentNode := parent.Get(0)
- grandParent := parent.Parent()
- var grandParentNode *html.Node
- if grandParent.Length() > 0 {
- grandParentNode = grandParent.Get(0)
- }
- if _, found := candidates[parentNode]; !found {
- candidates[parentNode] = scoreNode(parent)
- }
- if grandParentNode != nil {
- if _, found := candidates[grandParentNode]; !found {
- candidates[grandParentNode] = scoreNode(grandParent)
- }
- }
- // Add a point for the paragraph itself as a base.
- contentScore := float32(1.0)
- // Add points for any commas within this paragraph.
- contentScore += float32(strings.Count(text, ",") + 1)
- // For every 100 characters in this paragraph, add another point. Up to 3 points.
- contentScore += float32(math.Min(float64(int(len(text)/100.0)), 3))
- candidates[parentNode].score += contentScore
- if grandParentNode != nil {
- candidates[grandParentNode].score += contentScore / 2.0
- }
- })
- // Scale the final candidates score based on link density. Good content
- // should have a relatively small link density (5% or less) and be mostly
- // unaffected by this operation
- for _, candidate := range candidates {
- candidate.score = candidate.score * (1 - getLinkDensity(candidate.selection))
- }
- return candidates
- }
- func scoreNode(s *goquery.Selection) *candidate {
- c := &candidate{selection: s, score: 0}
- switch s.Get(0).DataAtom.String() {
- case "div":
- c.score += 5
- case "pre", "td", "blockquote", "img":
- c.score += 3
- case "address", "ol", "ul", "dl", "dd", "dt", "li", "form":
- c.score -= 3
- case "h1", "h2", "h3", "h4", "h5", "h6", "th":
- c.score -= 5
- }
- c.score += getClassWeight(s)
- return c
- }
- // Get the density of links as a percentage of the content
- // This is the amount of text that is inside a link divided by the total text in the node.
- func getLinkDensity(s *goquery.Selection) float32 {
- linkLength := len(s.Find("a").Text())
- textLength := len(s.Text())
- if textLength == 0 {
- return 0
- }
- return float32(linkLength) / float32(textLength)
- }
- // Get an elements class/id weight. Uses regular expressions to tell if this
- // element looks good or bad.
- func getClassWeight(s *goquery.Selection) float32 {
- weight := 0
- class, _ := s.Attr("class")
- id, _ := s.Attr("id")
- if class != "" {
- if negativeRegexp.MatchString(class) {
- weight -= 25
- }
- if positiveRegexp.MatchString(class) {
- weight += 25
- }
- }
- if id != "" {
- if negativeRegexp.MatchString(id) {
- weight -= 25
- }
- if positiveRegexp.MatchString(id) {
- weight += 25
- }
- }
- return float32(weight)
- }
- func transformMisusedDivsIntoParagraphs(document *goquery.Document) {
- document.Find("div").Each(func(i int, s *goquery.Selection) {
- html, _ := s.Html()
- if !divToPElementsRegexp.MatchString(html) {
- node := s.Get(0)
- node.Data = "p"
- }
- })
- }
- func removeNodes(s *goquery.Selection) {
- s.Each(func(i int, s *goquery.Selection) {
- parent := s.Parent()
- if parent.Length() > 0 {
- parent.Get(0).RemoveChild(s.Get(0))
- }
- })
- }
|