readability.go 8.7 KB

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