readability.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package readability // import "miniflux.app/v2/internal/reader/readability"
  4. import (
  5. "bytes"
  6. "fmt"
  7. "io"
  8. "log/slog"
  9. "math"
  10. "regexp"
  11. "strings"
  12. "github.com/PuerkitoBio/goquery"
  13. "golang.org/x/net/html"
  14. )
  15. const (
  16. defaultTagsToScore = "section,h2,h3,h4,h5,h6,p,td,pre,div"
  17. )
  18. var (
  19. divToPElementsRegexp = regexp.MustCompile(`(?i)<(a|blockquote|dl|div|img|ol|p|pre|table|ul)`)
  20. sentenceRegexp = regexp.MustCompile(`\.( |$)`)
  21. blacklistCandidatesRegexp = regexp.MustCompile(`(?i)popupbody|-ad|g-plus`)
  22. okMaybeItsACandidateRegexp = regexp.MustCompile(`(?i)and|article|body|column|main|shadow`)
  23. 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`)
  24. 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`)
  25. positiveRegexp = regexp.MustCompile(`(?i)article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story`)
  26. )
  27. type candidate struct {
  28. selection *goquery.Selection
  29. score float32
  30. }
  31. func (c *candidate) Node() *html.Node {
  32. return c.selection.Get(0)
  33. }
  34. func (c *candidate) String() string {
  35. id, _ := c.selection.Attr("id")
  36. class, _ := c.selection.Attr("class")
  37. switch {
  38. case id != "" && class != "":
  39. return fmt.Sprintf("%s#%s.%s => %f", c.Node().DataAtom, id, class, c.score)
  40. case id != "":
  41. return fmt.Sprintf("%s#%s => %f", c.Node().DataAtom, id, c.score)
  42. case class != "":
  43. return fmt.Sprintf("%s.%s => %f", c.Node().DataAtom, class, c.score)
  44. }
  45. return fmt.Sprintf("%s => %f", c.Node().DataAtom, c.score)
  46. }
  47. type candidateList map[*html.Node]*candidate
  48. func (c candidateList) String() string {
  49. var output []string
  50. for _, candidate := range c {
  51. output = append(output, candidate.String())
  52. }
  53. return strings.Join(output, ", ")
  54. }
  55. // ExtractContent returns relevant content.
  56. func ExtractContent(page io.Reader) (string, error) {
  57. document, err := goquery.NewDocumentFromReader(page)
  58. if err != nil {
  59. return "", err
  60. }
  61. document.Find("script,style").Each(func(i int, s *goquery.Selection) {
  62. removeNodes(s)
  63. })
  64. transformMisusedDivsIntoParagraphs(document)
  65. removeUnlikelyCandidates(document)
  66. candidates := getCandidates(document)
  67. topCandidate := getTopCandidate(document, candidates)
  68. slog.Debug("Readability parsing",
  69. slog.Any("candidates", candidates),
  70. slog.Any("topCandidate", topCandidate),
  71. )
  72. output := getArticle(topCandidate, candidates)
  73. return output, nil
  74. }
  75. // Now that we have the top candidate, look through its siblings for content that might also be related.
  76. // Things like preambles, content split by ads that we removed, etc.
  77. func getArticle(topCandidate *candidate, candidates candidateList) string {
  78. output := bytes.NewBufferString("<div>")
  79. siblingScoreThreshold := float32(math.Max(10, float64(topCandidate.score*.2)))
  80. topCandidate.selection.Siblings().Union(topCandidate.selection).Each(func(i int, s *goquery.Selection) {
  81. append := false
  82. node := s.Get(0)
  83. if node == topCandidate.Node() {
  84. append = true
  85. } else if c, ok := candidates[node]; ok && c.score >= siblingScoreThreshold {
  86. append = true
  87. }
  88. if s.Is("p") {
  89. linkDensity := getLinkDensity(s)
  90. content := s.Text()
  91. contentLength := len(content)
  92. if contentLength >= 80 && linkDensity < .25 {
  93. append = true
  94. } else if contentLength < 80 && linkDensity == 0 && sentenceRegexp.MatchString(content) {
  95. append = true
  96. }
  97. }
  98. if append {
  99. tag := "div"
  100. if s.Is("p") {
  101. tag = node.Data
  102. }
  103. html, _ := s.Html()
  104. fmt.Fprintf(output, "<%s>%s</%s>", tag, html, tag)
  105. }
  106. })
  107. output.WriteString("</div>")
  108. return output.String()
  109. }
  110. func removeUnlikelyCandidates(document *goquery.Document) {
  111. document.Find("*").Each(func(i int, s *goquery.Selection) {
  112. if s.Length() == 0 || s.Get(0).Data == "html" || s.Get(0).Data == "body" {
  113. return
  114. }
  115. class, _ := s.Attr("class")
  116. id, _ := s.Attr("id")
  117. str := class + id
  118. if blacklistCandidatesRegexp.MatchString(str) || (unlikelyCandidatesRegexp.MatchString(str) && !okMaybeItsACandidateRegexp.MatchString(str)) {
  119. removeNodes(s)
  120. }
  121. })
  122. }
  123. func getTopCandidate(document *goquery.Document, candidates candidateList) *candidate {
  124. var best *candidate
  125. for _, c := range candidates {
  126. if best == nil {
  127. best = c
  128. } else if best.score < c.score {
  129. best = c
  130. }
  131. }
  132. if best == nil {
  133. best = &candidate{document.Find("body"), 0}
  134. }
  135. return best
  136. }
  137. // Loop through all paragraphs, and assign a score to them based on how content-y they look.
  138. // Then add their score to their parent node.
  139. // A score is determined by things like number of commas, class names, etc.
  140. // Maybe eventually link density.
  141. func getCandidates(document *goquery.Document) candidateList {
  142. candidates := make(candidateList)
  143. document.Find(defaultTagsToScore).Each(func(i int, s *goquery.Selection) {
  144. text := s.Text()
  145. // If this paragraph is less than 25 characters, don't even count it.
  146. if len(text) < 25 {
  147. return
  148. }
  149. parent := s.Parent()
  150. parentNode := parent.Get(0)
  151. grandParent := parent.Parent()
  152. var grandParentNode *html.Node
  153. if grandParent.Length() > 0 {
  154. grandParentNode = grandParent.Get(0)
  155. }
  156. if _, found := candidates[parentNode]; !found {
  157. candidates[parentNode] = scoreNode(parent)
  158. }
  159. if grandParentNode != nil {
  160. if _, found := candidates[grandParentNode]; !found {
  161. candidates[grandParentNode] = scoreNode(grandParent)
  162. }
  163. }
  164. // Add a point for the paragraph itself as a base.
  165. contentScore := float32(1.0)
  166. // Add points for any commas within this paragraph.
  167. contentScore += float32(strings.Count(text, ",") + 1)
  168. // For every 100 characters in this paragraph, add another point. Up to 3 points.
  169. contentScore += float32(math.Min(float64(int(len(text)/100.0)), 3))
  170. candidates[parentNode].score += contentScore
  171. if grandParentNode != nil {
  172. candidates[grandParentNode].score += contentScore / 2.0
  173. }
  174. })
  175. // Scale the final candidates score based on link density. Good content
  176. // should have a relatively small link density (5% or less) and be mostly
  177. // unaffected by this operation
  178. for _, candidate := range candidates {
  179. candidate.score *= (1 - getLinkDensity(candidate.selection))
  180. }
  181. return candidates
  182. }
  183. func scoreNode(s *goquery.Selection) *candidate {
  184. c := &candidate{selection: s, score: 0}
  185. switch s.Get(0).DataAtom.String() {
  186. case "div":
  187. c.score += 5
  188. case "pre", "td", "blockquote", "img":
  189. c.score += 3
  190. case "address", "ol", "ul", "dl", "dd", "dt", "li", "form":
  191. c.score -= 3
  192. case "h1", "h2", "h3", "h4", "h5", "h6", "th":
  193. c.score -= 5
  194. }
  195. c.score += getClassWeight(s)
  196. return c
  197. }
  198. // Get the density of links as a percentage of the content
  199. // This is the amount of text that is inside a link divided by the total text in the node.
  200. func getLinkDensity(s *goquery.Selection) float32 {
  201. linkLength := len(s.Find("a").Text())
  202. textLength := len(s.Text())
  203. if textLength == 0 {
  204. return 0
  205. }
  206. return float32(linkLength) / float32(textLength)
  207. }
  208. // Get an elements class/id weight. Uses regular expressions to tell if this
  209. // element looks good or bad.
  210. func getClassWeight(s *goquery.Selection) float32 {
  211. weight := 0
  212. class, _ := s.Attr("class")
  213. id, _ := s.Attr("id")
  214. if class != "" {
  215. if negativeRegexp.MatchString(class) {
  216. weight -= 25
  217. }
  218. if positiveRegexp.MatchString(class) {
  219. weight += 25
  220. }
  221. }
  222. if id != "" {
  223. if negativeRegexp.MatchString(id) {
  224. weight -= 25
  225. }
  226. if positiveRegexp.MatchString(id) {
  227. weight += 25
  228. }
  229. }
  230. return float32(weight)
  231. }
  232. func transformMisusedDivsIntoParagraphs(document *goquery.Document) {
  233. document.Find("div").Each(func(i int, s *goquery.Selection) {
  234. html, _ := s.Html()
  235. if !divToPElementsRegexp.MatchString(html) {
  236. node := s.Get(0)
  237. node.Data = "p"
  238. }
  239. })
  240. }
  241. func removeNodes(s *goquery.Selection) {
  242. s.Each(func(i int, s *goquery.Selection) {
  243. parent := s.Parent()
  244. if parent.Length() > 0 {
  245. parent.Get(0).RemoveChild(s.Get(0))
  246. }
  247. })
  248. }