readability.go 10 KB

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