readability.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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. "strings"
  9. "miniflux.app/v2/internal/urllib"
  10. "github.com/PuerkitoBio/goquery"
  11. "golang.org/x/net/html"
  12. )
  13. const defaultTagsToScore = "section,h2,h3,h4,h5,h6,p,td,pre,div"
  14. var (
  15. strongCandidatesToRemove = [...]string{"popupbody", "-ad", "g-plus"}
  16. maybeCandidateToRemove = [...]string{"and", "article", "body", "column", "main", "shadow", "content"}
  17. unlikelyCandidateToRemove = [...]string{"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"}
  18. positiveKeywords = [...]string{"article", "blog", "body", "content", "entry", "h-entry", "hentry", "main", "page", "pagination", "post", "story", "text"}
  19. negativeKeywords = [...]string{"author", "banner", "byline", "com-", "combx", "comment", "contact", "dateline", "foot", "hid", "masthead", "media", "meta", "modal", "outbrain", "promo", "related", "scroll", "share", "shopping", "shoutbox", "sidebar", "skyscraper", "sponsor", "tags", "tool", "widget", "writtenby"}
  20. )
  21. type candidate struct {
  22. selection *goquery.Selection
  23. score float32
  24. }
  25. func (c *candidate) Node() *html.Node {
  26. if c.selection.Length() == 0 {
  27. return nil
  28. }
  29. return c.selection.Get(0)
  30. }
  31. func (c *candidate) String() string {
  32. node := c.Node()
  33. if node == nil {
  34. return fmt.Sprintf("empty => %f", c.score)
  35. }
  36. id, _ := c.selection.Attr("id")
  37. class, _ := c.selection.Attr("class")
  38. switch {
  39. case id != "" && class != "":
  40. return fmt.Sprintf("%s#%s.%s => %f", node.DataAtom, id, class, c.score)
  41. case id != "":
  42. return fmt.Sprintf("%s#%s => %f", node.DataAtom, id, c.score)
  43. case class != "":
  44. return fmt.Sprintf("%s.%s => %f", node.DataAtom, class, c.score)
  45. }
  46. return fmt.Sprintf("%s => %f", node.DataAtom, c.score)
  47. }
  48. type candidateList map[*html.Node]*candidate
  49. func (c candidateList) String() string {
  50. output := make([]string, 0, len(c))
  51. for _, candidate := range c {
  52. output = append(output, candidate.String())
  53. }
  54. return strings.Join(output, ", ")
  55. }
  56. // ExtractContent returns relevant content.
  57. func ExtractContent(page io.Reader) (baseURL string, extractedContent string, err error) {
  58. document, err := goquery.NewDocumentFromReader(page)
  59. if err != nil {
  60. return "", "", err
  61. }
  62. if hrefValue, exists := document.FindMatcher(goquery.Single("head base")).Attr("href"); exists {
  63. hrefValue = strings.TrimSpace(hrefValue)
  64. if urllib.IsAbsoluteURL(hrefValue) {
  65. baseURL = hrefValue
  66. }
  67. }
  68. document.Find("script,style").Remove()
  69. removeUnlikelyCandidates(document)
  70. transformMisusedDivsIntoParagraphs(document)
  71. candidates := getCandidates(document)
  72. topCandidate := getTopCandidate(document, candidates)
  73. slog.Debug("Readability parsing",
  74. slog.String("base_url", baseURL),
  75. slog.String("candidates", candidates.String()),
  76. slog.String("topCandidate", topCandidate.String()),
  77. )
  78. extractedContent = getArticle(topCandidate, candidates)
  79. return baseURL, extractedContent, nil
  80. }
  81. func getSelectionLength(s *goquery.Selection) int {
  82. return sumMapOnSelection(s, func(s string) int { return len(s) })
  83. }
  84. func getSelectionCommaCount(s *goquery.Selection) int {
  85. return sumMapOnSelection(s, func(s string) int { return strings.Count(s, ",") })
  86. }
  87. // sumMapOnSelection maps `f` on the selection, and return the sum of the result.
  88. // This construct is used instead of goquery.Selection's .Text() method,
  89. // to avoid materializing the text to simply map/sum on it, saving a significant
  90. // amount of memory of large selections, and reducing the pressure on the garbage-collector.
  91. func sumMapOnSelection(s *goquery.Selection, f func(str string) int) int {
  92. var recursiveFunction func(*html.Node) int
  93. recursiveFunction = func(n *html.Node) int {
  94. total := 0
  95. if n.Type == html.TextNode {
  96. total += f(n.Data)
  97. }
  98. if n.FirstChild != nil {
  99. for c := n.FirstChild; c != nil; c = c.NextSibling {
  100. total += recursiveFunction(c)
  101. }
  102. }
  103. return total
  104. }
  105. sum := 0
  106. for _, n := range s.Nodes {
  107. sum += recursiveFunction(n)
  108. }
  109. return sum
  110. }
  111. // Now that we have the top candidate, look through its siblings for content that might also be related.
  112. // Things like preambles, content split by ads that we removed, etc.
  113. func getArticle(topCandidate *candidate, candidates candidateList) string {
  114. var output strings.Builder
  115. output.WriteString("<div>")
  116. siblingScoreThreshold := max(10, topCandidate.score/5)
  117. topCandidate.selection.Siblings().Union(topCandidate.selection).Each(func(i int, s *goquery.Selection) {
  118. append := false
  119. tag := "div"
  120. node := s.Get(0)
  121. topNode := topCandidate.Node()
  122. if topNode != nil && node == topNode {
  123. append = true
  124. } else if c, ok := candidates[node]; ok && c.score >= siblingScoreThreshold {
  125. append = true
  126. } else if s.Is("p") {
  127. tag = node.Data
  128. linkDensity := getLinkDensity(s)
  129. contentLength := getSelectionLength(s)
  130. if contentLength >= 80 {
  131. if linkDensity < .25 {
  132. append = true
  133. }
  134. } else {
  135. if linkDensity == 0 {
  136. // It's a small selection, so .Text doesn't impact performances too much.
  137. if containsSentence(s.Text()) {
  138. append = true
  139. }
  140. }
  141. }
  142. }
  143. if append {
  144. html, _ := s.Html()
  145. output.WriteByte('<')
  146. output.WriteString(tag)
  147. output.WriteByte('>')
  148. output.WriteString(html)
  149. output.WriteString("</")
  150. output.WriteString(tag)
  151. output.WriteByte('>')
  152. }
  153. })
  154. output.WriteString("</div>")
  155. return output.String()
  156. }
  157. func shouldRemoveCandidate(str string) bool {
  158. str = strings.ToLower(str)
  159. // Those candidates have no false-positives, no need to check against `maybeCandidate`
  160. for _, strongCandidateToRemove := range strongCandidatesToRemove {
  161. if strings.Contains(str, strongCandidateToRemove) {
  162. return true
  163. }
  164. }
  165. isFalsePositive := false
  166. for _, maybeCandidateToRemove := range maybeCandidateToRemove {
  167. if strings.Contains(str, maybeCandidateToRemove) {
  168. isFalsePositive = true
  169. break
  170. }
  171. }
  172. for _, unlikelyCandidateToRemove := range unlikelyCandidateToRemove {
  173. if strings.Contains(str, unlikelyCandidateToRemove) {
  174. return !isFalsePositive
  175. }
  176. }
  177. return false
  178. }
  179. func removeUnlikelyCandidates(document *goquery.Document) {
  180. // Only select tags with either a class or an id attribute,
  181. // and never the html nor body tags, as we don't want to ever remove them.
  182. selector := "[class]:not(body,html)" + "," + "[id]:not(body,html)"
  183. for _, s := range document.Find(selector).EachIter() {
  184. if s.Length() == 0 {
  185. continue
  186. }
  187. // Don't remove elements within code blocks (pre or code tags)
  188. if s.Closest("pre,code").Length() > 0 {
  189. continue
  190. }
  191. if class, ok := s.Attr("class"); ok && shouldRemoveCandidate(class) {
  192. s.Remove()
  193. } else if id, ok := s.Attr("id"); ok && shouldRemoveCandidate(id) {
  194. s.Remove()
  195. }
  196. }
  197. }
  198. func getTopCandidate(document *goquery.Document, candidates candidateList) *candidate {
  199. var best *candidate
  200. for _, c := range candidates {
  201. if best == nil {
  202. best = c
  203. } else if best.score < c.score {
  204. best = c
  205. }
  206. }
  207. if best == nil {
  208. best = &candidate{document.Find("body"), 0}
  209. }
  210. return best
  211. }
  212. // Loop through all paragraphs, and assign a score to them based on how content-y they look.
  213. // Then add their score to their parent node.
  214. // A score is determined by things like number of commas, class names, etc.
  215. func getCandidates(document *goquery.Document) candidateList {
  216. candidates := make(candidateList)
  217. document.Find(defaultTagsToScore).Each(func(i int, s *goquery.Selection) {
  218. textLen := getSelectionLength(s)
  219. // If this paragraph is less than 25 characters, don't even count it.
  220. if textLen < 25 {
  221. return
  222. }
  223. // Add a point for the paragraph itself as a base.
  224. contentScore := 1
  225. // Add points for any commas within this paragraph.
  226. contentScore += getSelectionCommaCount(s) + 1
  227. // For every 100 characters in this paragraph, add another point. Up to 3 points.
  228. contentScore += min(textLen/100, 3)
  229. parent := s.Parent()
  230. parentNode := parent.Get(0)
  231. if _, found := candidates[parentNode]; !found {
  232. candidates[parentNode] = scoreNode(parent)
  233. }
  234. candidates[parentNode].score += float32(contentScore)
  235. // The score of the current node influences its grandparent's one as well, but scaled to 50%.
  236. grandParent := parent.Parent()
  237. if grandParent.Length() > 0 {
  238. grandParentNode := grandParent.Get(0)
  239. if _, found := candidates[grandParentNode]; !found {
  240. candidates[grandParentNode] = scoreNode(grandParent)
  241. }
  242. candidates[grandParentNode].score += float32(contentScore) / 2.0
  243. }
  244. })
  245. // Scale the final candidates score based on link density. Good content
  246. // should have a relatively small link density (5% or less) and be mostly
  247. // unaffected by this operation
  248. for _, candidate := range candidates {
  249. candidate.score *= (1 - getLinkDensity(candidate.selection))
  250. }
  251. return candidates
  252. }
  253. func scoreNode(s *goquery.Selection) *candidate {
  254. c := &candidate{selection: s, score: 0}
  255. // Check if selection is empty to avoid panic
  256. if s.Length() == 0 {
  257. return c
  258. }
  259. switch s.Get(0).Data {
  260. case "div":
  261. c.score += 5
  262. case "pre", "td", "blockquote", "img":
  263. c.score += 3
  264. case "address", "ol", "ul", "dl", "dd", "dt", "li", "form":
  265. c.score -= 3
  266. case "h1", "h2", "h3", "h4", "h5", "h6", "th":
  267. c.score -= 5
  268. }
  269. if class, ok := s.Attr("class"); ok {
  270. c.score += getWeight(class)
  271. }
  272. if id, ok := s.Attr("id"); ok {
  273. c.score += getWeight(id)
  274. }
  275. return c
  276. }
  277. // Get the density of links as a percentage of the content
  278. // This is the amount of text that is inside a link divided by the total text in the node.
  279. func getLinkDensity(s *goquery.Selection) float32 {
  280. sum := getSelectionLength(s)
  281. if sum == 0 {
  282. return 0
  283. }
  284. linkLength := getSelectionLength(s.Find("a"))
  285. return float32(linkLength) / float32(sum)
  286. }
  287. func getWeight(s string) float32 {
  288. s = strings.ToLower(s)
  289. for _, keyword := range negativeKeywords {
  290. if strings.Contains(s, keyword) {
  291. return -25
  292. }
  293. }
  294. for _, keyword := range positiveKeywords {
  295. if strings.Contains(s, keyword) {
  296. return +25
  297. }
  298. }
  299. return 0
  300. }
  301. func transformMisusedDivsIntoParagraphs(document *goquery.Document) {
  302. document.Find("div").Each(func(i int, s *goquery.Selection) {
  303. nodes := s.Children().Nodes
  304. if len(nodes) == 0 {
  305. s.Nodes[0].Data = "p"
  306. return
  307. }
  308. for _, node := range nodes {
  309. switch node.Data {
  310. case "a", "blockquote", "div", "dl",
  311. "img", "ol", "p", "pre",
  312. "table", "ul":
  313. return
  314. default:
  315. s.Nodes[0].Data = "p"
  316. }
  317. }
  318. })
  319. }
  320. func containsSentence(content string) bool {
  321. return strings.HasSuffix(content, ".") || strings.Contains(content, ". ")
  322. }