detergent 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ad549b0acaa35ac06eeea236937b7db8df4eb4c19111b1650b7b23738d35be1
4
- data.tar.gz: b3da9c4ba188b6f750adf27dbe1e9c52403371abb410dbea651a6ff387a2d6bc
3
+ metadata.gz: 1e2a63a0bf0f9524e9d7e4c1b873d2cb0eab8e29873bad005c1545501fa9915a
4
+ data.tar.gz: 03f8f53ce63ffa717b8b5d0981d92a02b307a466393ac9e37299d66e18dcdda8
5
5
  SHA512:
6
- metadata.gz: 3e88afce9a71395e045a7b7c63f204c9f9d7ff9ba64e36d0d02610ea4884d34432ca853b5721dbc4cf67b1888a63c08062e8a66b242b6288d6f6eca3737b15cd
7
- data.tar.gz: cce8cdcd13d4b12f6775ead158468822c1c803b3100cacf03ceb548a937870109f82592f1aea5f045334b6327d5e975ea2d9856aea8a6182c08390c6a70322e7
6
+ metadata.gz: 5963938b39385d4935e6080ce5b4256996f352d8582976edf8b7a95f73fbe5a896bca8e282dcdfce4b62768e4488ddef0f354706bacab04f9cb69d9ea7120b3c
7
+ data.tar.gz: 17391d9c496d0fe55dff25f68fd85e63c2ed3b391db05c1c714c6cce2250c457dc48a5b8f0b2d4a5cf3de19295317cd3eeba9b9b29d83547e990cec2cdf577c5
data/README.md CHANGED
@@ -15,16 +15,20 @@ gem "detergent"
15
15
  ```ruby
16
16
  require "detergent"
17
17
 
18
- cleaner = Detergent::Cleaner.new
19
-
20
18
  # Get a cleaned, self-contained HTML document:
21
- clean_html = cleaner.clean(dirty_html)
22
-
23
- # Or just the title:
24
- title = cleaner.title(dirty_html)
19
+ clean_html = Detergent.clean(dirty_html)
25
20
 
26
21
  # Or the title and the extracted content node (a Nokogiri node):
27
- title, content = cleaner.cleaned_html(dirty_html)
22
+ title, content = Detergent.extract(dirty_html)
23
+
24
+ # Or the extracted main content as Markdown or plain text:
25
+ markdown = Detergent.markdown(dirty_html)
26
+ text = Detergent.text(dirty_html)
27
+
28
+ # A Cleaner instance is reusable if you're processing many pages:
29
+ cleaner = Detergent::Cleaner.new
30
+ clean_html = cleaner.clean(dirty_html)
31
+ title = cleaner.title(dirty_html)
28
32
  ```
29
33
 
30
34
  ## How it works
@@ -1,160 +1,113 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Detergent
4
+ # Orchestrates cleaning: prunes obvious junk, locates the main content,
5
+ # scrubs it, and renders the result back out as a standalone document.
2
6
  class Cleaner
3
-
4
7
  def initialize
5
- @node_scorer = NodeScorer.new
6
8
  @obvious_junk_matcher = Matchers::ObviousJunkMatcher.new
7
9
  @removable_node_matcher = Matchers::RemovableNodeMatcher.new
8
10
  end
9
11
 
12
+ # Returns a complete, standalone HTML document containing only the
13
+ # page's title and its extracted main content.
10
14
  def clean(html)
11
- @html = html
12
- title, body = cleaned_html(@html)
13
-
14
- <<-HTML
15
- <html>
16
- <head>
17
- <meta charset="utf-8">
18
- <meta name="viewport" content="width=device-width, initial-scale=1">
19
- <title>#{title}</title>
20
- </head>
21
- #{body}
22
- </html>
23
- HTML
24
- end
25
-
26
- def title(html)
27
- doc = Nokogiri::HTML(html)
28
-
29
- title_node = doc.at('title')
30
- title = title_node ? title_node.text.strip : ""
15
+ title, content = extract(html)
16
+
17
+ # The content root is usually an inner element (article, main, div),
18
+ # so wrap it in a body tag unless it already is one.
19
+ body = if content.nil? || content.name.downcase == "body"
20
+ content.to_s
21
+ else
22
+ "<body>#{content}</body>"
23
+ end
31
24
 
32
- title
25
+ <<~HTML
26
+ <!DOCTYPE html>
27
+ <html>
28
+ <head>
29
+ <meta charset="utf-8">
30
+ <meta name="viewport" content="width=device-width, initial-scale=1">
31
+ <title>#{CGI.escapeHTML(title.to_s)}</title>
32
+ </head>
33
+ #{body}
34
+ </html>
35
+ HTML
33
36
  end
34
37
 
35
-
36
- def cleaned_html(html)
37
- doc = Nokogiri::HTML(html)
38
-
39
- title_node = doc.at('title')
40
- title = title_node ? title_node.text.strip : ""
38
+ # Returns [title, content]: the page title and the cleaned Nokogiri
39
+ # node containing the main content (nil if none was found).
40
+ def extract(html)
41
+ doc = Nokogiri::HTML5(html)
42
+ title = extract_title(doc)
41
43
 
42
44
  body = doc.at('body')
43
45
  content = nil
44
46
 
45
47
  if body
46
48
  # First remove the most egregious crap, like obvious ads, etc.
47
- prune(node: body, matcher: Matchers::ObviousJunkMatcher.new)
48
-
49
- # Find the highest-scoring node in the cleaned tree
50
- highest_scoring = find_highest_scoring_node(body)
51
- return nil unless highest_scoring
49
+ prune(node: body, matcher: @obvious_junk_matcher)
52
50
 
53
- # Find the appropriate root container for that node
54
- content_root = find_content_root(highest_scoring)
51
+ # Score caches are only valid for a single parse of a single
52
+ # document, so the scorer and locator are built per extract.
53
+ content = ContentLocator.new(NodeScorer.new).locate(body)
55
54
 
56
55
  # Apply second-pass cleaning to the content
57
- if content_root
58
- clean_node(content_root)
56
+ if content
57
+ clean_node(content)
58
+ strip_junk_attributes(content)
59
59
  end
60
-
61
- content = content_root
62
60
  end
63
61
 
64
62
  [title, content]
65
63
  end
66
64
 
67
- private
68
-
69
- def prune(node:, matcher:)
70
- node.children.to_a.each do |child|
71
- if child.element?
72
- # Recurse first
73
- prune(node: child, matcher: matcher)
74
-
75
- child.remove if matcher.match?(child)
76
- end
77
- end
78
-
79
- node
65
+ # Returns the extracted main content as Markdown.
66
+ def markdown(html)
67
+ _, content = extract(html)
68
+ content ? MarkdownRenderer.new.render(content) : ""
80
69
  end
81
70
 
82
- # Scores a node based on how likely it is to be main content
83
- # Higher scores indicate more likely to be the primary article content
84
- def score_node(node)
85
- @node_scorer.score(node)
71
+ # Returns the extracted main content as plain text.
72
+ def text(html)
73
+ _, content = extract(html)
74
+ content ? TextRenderer.new.render(content) : ""
86
75
  end
87
76
 
88
- # Finds the node with the highest content score
89
- # Returns the node with the highest score in the tree
90
- def find_highest_scoring_node(node)
91
- return nil unless node.element?
92
-
93
- best_node = node
94
- best_score = score_node(node)
95
-
96
- # Recursively check all child elements
97
- node.children.each do |child|
98
- next unless child.element?
99
-
100
- candidate = find_highest_scoring_node(child)
101
- if candidate
102
- candidate_score = score_node(candidate)
103
- if candidate_score > best_score
104
- best_node = candidate
105
- best_score = candidate_score
106
- end
107
- end
108
- end
109
-
110
- best_node
77
+ def title(html)
78
+ extract_title(Nokogiri::HTML5(html))
111
79
  end
112
80
 
113
- # Finds an appropriate root ancestor for the given node
114
- # Traverses up the tree to find a good container for the main content
115
- # Stops at body or when we find a semantic container
116
- def find_content_root(node)
117
- return node if node.nil? || node.name.downcase == 'body'
118
-
119
- current = node
120
- best_ancestor = node
121
-
122
- # Traverse up the tree
123
- while current.parent && current.parent.element?
124
- parent = current.parent
125
- parent_tag = parent.name.downcase
126
-
127
- # Stop at body
128
- break if parent_tag == 'body'
81
+ private
129
82
 
130
- # Prefer semantic containers
131
- if %w(article main section).include?(parent_tag)
132
- best_ancestor = parent
133
- end
83
+ def extract_title(doc)
84
+ title_node = doc.at('title')
85
+ title_node ? title_node.text.strip : ""
86
+ end
134
87
 
135
- # If parent has significantly more content than current node,
136
- # it might be a better root (captures related content)
137
- parent_score = score_node(parent)
138
- current_score = score_node(current)
88
+ def prune(node:, matcher:)
89
+ node.children.to_a.each do |child|
90
+ next unless child.element?
139
91
 
140
- # Only move up if parent score is meaningfully higher
141
- # (not just marginally better due to including the child)
142
- if parent_score > current_score * 1.3
143
- best_ancestor = parent
92
+ # Match first so we never bother walking a subtree we're removing
93
+ if matcher.match?(child)
94
+ child.remove
95
+ else
96
+ prune(node: child, matcher: matcher)
144
97
  end
145
-
146
- current = parent
147
98
  end
148
99
 
149
- best_ancestor
100
+ node
150
101
  end
151
102
 
152
103
  # Recursively process an element's children and remove any that are "empty"
153
104
  def clean_node(node, within_article: false)
105
+ within_article ||= node.name.downcase == "article"
106
+
154
107
  # Iterate over a copy of the children to avoid modification issues
155
108
  node.children.to_a.each do |child|
156
109
  if child.element?
157
- clean_node(child, within_article: within_article || node.name.downcase == "article" ) # process children first
110
+ clean_node(child, within_article: within_article) # process children first
158
111
  # Remove the child if it qualifies as "empty"
159
112
 
160
113
  if child.name.downcase == "aside" && !within_article
@@ -162,18 +115,29 @@ module Detergent
162
115
  elsif removable?(child)
163
116
  child.remove
164
117
  else
165
- child.remove_attribute("class") # Cleanup
118
+ strip_junk_attributes(child)
166
119
  end
167
120
  end
168
121
  end
169
122
  end
170
123
 
124
+ # Strip presentation and tracking attributes from a kept element.
125
+ # Must run only after removability has been decided, because the
126
+ # matchers read class and style. Keeps id so in-page anchors work.
127
+ def strip_junk_attributes(node)
128
+ node.attribute_nodes.each do |attr|
129
+ name = attr.name.downcase
130
+ if %w[class style].include?(name) || name.start_with?("on", "data-")
131
+ node.remove_attribute(attr.name)
132
+ end
133
+ end
134
+ end
135
+
171
136
  # An element is removable if:
172
137
  # - It is a <script> or <style> tag (non-visible content), or other element we don't want
173
138
  # - It has no descendant text nodes (ignoring whitespace)
174
139
  def removable?(node)
175
140
  @removable_node_matcher.match?(node)
176
141
  end
177
-
178
142
  end
179
143
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Detergent
4
+ # Locates the main content node within a document's body: finds the
5
+ # highest-scoring node in the tree, then walks up its ancestors to find
6
+ # the best root container for the content.
7
+ class ContentLocator
8
+ SEMANTIC_CONTAINERS = %w[article main section].freeze
9
+
10
+ # A parent must beat the best root found so far by this ratio to be
11
+ # promoted — not just score marginally higher from including the child.
12
+ PARENT_PROMOTION_RATIO = 1.3
13
+
14
+ def initialize(scorer)
15
+ @scorer = scorer
16
+ end
17
+
18
+ def locate(body)
19
+ highest_scoring = find_highest_scoring_node(body)
20
+ return nil unless highest_scoring
21
+
22
+ find_content_root(highest_scoring)
23
+ end
24
+
25
+ private
26
+
27
+ def score(node)
28
+ @scorer.score(node)
29
+ end
30
+
31
+ # Finds the node with the highest content score in the tree
32
+ def find_highest_scoring_node(node)
33
+ return nil unless node.element?
34
+
35
+ best_node = node
36
+ best_score = score(node)
37
+
38
+ node.children.each do |child|
39
+ next unless child.element?
40
+
41
+ candidate = find_highest_scoring_node(child)
42
+ if candidate
43
+ candidate_score = score(candidate)
44
+ if candidate_score > best_score
45
+ best_node = candidate
46
+ best_score = candidate_score
47
+ end
48
+ end
49
+ end
50
+
51
+ best_node
52
+ end
53
+
54
+ # Walks up from the given node to find a good container for the main
55
+ # content, preferring semantic containers and ancestors that score
56
+ # meaningfully higher than the best root found so far. Stops at body.
57
+ def find_content_root(node)
58
+ return node if node.name.downcase == 'body'
59
+
60
+ best_ancestor = node
61
+ best_score = score(node)
62
+ current = node
63
+
64
+ while current.parent&.element?
65
+ parent = current.parent
66
+ parent_tag = parent.name.downcase
67
+
68
+ break if parent_tag == 'body'
69
+
70
+ if SEMANTIC_CONTAINERS.include?(parent_tag) ||
71
+ score(parent) > best_score * PARENT_PROMOTION_RATIO
72
+ best_ancestor = parent
73
+ best_score = score(parent)
74
+ end
75
+
76
+ current = parent
77
+ end
78
+
79
+ best_ancestor
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Detergent
4
+ # Renders a cleaned content node as Markdown. Expects the article-shaped
5
+ # markup that comes out of extraction; it is not a general-purpose
6
+ # HTML-to-Markdown converter.
7
+ #
8
+ # The formatting decisions live in small hook methods (heading, link,
9
+ # image, ...) so TextRenderer can subclass this and strip the syntax.
10
+ class MarkdownRenderer
11
+ def render(node)
12
+ render_blocks(node).gsub(/\n{3,}/, "\n\n").strip
13
+ end
14
+
15
+ private
16
+
17
+ INLINE_TAGS = %w[a strong b em i code span img br sub sup small u s
18
+ strike mark abbr time].freeze
19
+
20
+ def inline?(node)
21
+ node.text? || (node.element? && INLINE_TAGS.include?(node.name.downcase))
22
+ end
23
+
24
+ # Walk a container's children, gathering runs of inline content into
25
+ # paragraphs and rendering block elements between them.
26
+ def render_blocks(node)
27
+ out = []
28
+ buffer = +""
29
+
30
+ node.children.each do |child|
31
+ if inline?(child)
32
+ buffer << inline(child)
33
+ elsif child.element?
34
+ out << buffer.strip unless buffer.strip.empty?
35
+ buffer = +""
36
+
37
+ block = render_block(child)
38
+ out << block unless block.empty?
39
+ end
40
+ end
41
+
42
+ out << buffer.strip unless buffer.strip.empty?
43
+ out.join("\n\n")
44
+ end
45
+
46
+ def render_block(node)
47
+ tag = node.name.downcase
48
+
49
+ case tag
50
+ when /\Ah([1-6])\z/
51
+ heading(Regexp.last_match(1).to_i, inline_content(node).strip)
52
+ when 'p'
53
+ inline_content(node).strip
54
+ when 'blockquote'
55
+ blockquote(render_blocks(node))
56
+ when 'ul'
57
+ list(node, ordered: false)
58
+ when 'ol'
59
+ list(node, ordered: true)
60
+ when 'pre'
61
+ preformatted(node.text.rstrip)
62
+ when 'hr'
63
+ rule
64
+ when 'figcaption'
65
+ caption(inline_content(node).strip)
66
+ when 'table'
67
+ table(node)
68
+ else
69
+ # Generic container (div, section, article, figure, ...)
70
+ render_blocks(node)
71
+ end
72
+ end
73
+
74
+ def inline(node)
75
+ return node.text.gsub(/\s+/, " ") if node.text?
76
+
77
+ case node.name.downcase
78
+ when 'br' then "\n"
79
+ when 'img' then image(node)
80
+ when 'a' then link(node)
81
+ when 'strong', 'b' then wrap(node, "**")
82
+ when 'em', 'i' then wrap(node, "*")
83
+ when 'code' then code_span(node)
84
+ else inline_content(node)
85
+ end
86
+ end
87
+
88
+ def inline_content(node)
89
+ node.children.map { |child| inline(child) }.join
90
+ end
91
+
92
+ def list(node, ordered:)
93
+ items = node.children.select { |c| c.element? && c.name.downcase == 'li' }
94
+
95
+ items.each_with_index.map do |li, index|
96
+ marker = ordered ? "#{index + 1}. " : "- "
97
+ indent = " " * marker.length
98
+ marker + render_blocks(li).gsub("\n", "\n#{indent}")
99
+ end.join("\n")
100
+ end
101
+
102
+ def table(node)
103
+ rows = node.xpath('.//tr')
104
+ return "" if rows.empty?
105
+
106
+ lines = rows.map do |row|
107
+ cells = row.xpath('./th | ./td').map { |cell| inline_content(cell).strip }
108
+ "| #{cells.join(' | ')} |"
109
+ end
110
+
111
+ column_count = rows.first.xpath('./th | ./td').length
112
+ lines.insert(1, "|#{' --- |' * column_count}")
113
+ lines.join("\n")
114
+ end
115
+
116
+ # -- Formatting hooks, overridden by TextRenderer --
117
+
118
+ def heading(level, text)
119
+ "#{'#' * level} #{text}"
120
+ end
121
+
122
+ def blockquote(content)
123
+ content.split("\n").map { |line| line.empty? ? ">" : "> #{line}" }.join("\n")
124
+ end
125
+
126
+ def preformatted(text)
127
+ "```\n#{text}\n```"
128
+ end
129
+
130
+ def rule
131
+ "---"
132
+ end
133
+
134
+ def caption(text)
135
+ text.empty? ? "" : "*#{text}*"
136
+ end
137
+
138
+ def link(node)
139
+ text = inline_content(node).strip
140
+ href = node['href'].to_s
141
+ return text if href.empty? || text.empty?
142
+
143
+ "[#{text}](#{href})"
144
+ end
145
+
146
+ def image(node)
147
+ src = node['src'].to_s
148
+ return "" if src.empty?
149
+
150
+ alt = node['alt'].to_s.gsub(/\s+/, " ").strip
151
+ "![#{alt}](#{src})"
152
+ end
153
+
154
+ def wrap(node, marker)
155
+ content = inline_content(node).strip
156
+ content.empty? ? "" : "#{marker}#{content}#{marker}"
157
+ end
158
+
159
+ def code_span(node)
160
+ content = node.text.strip
161
+ content.empty? ? "" : "`#{content}`"
162
+ end
163
+ end
164
+ end
@@ -1,21 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Detergent
2
4
  module Matchers
5
+ # First-pass matcher: junk that can be removed on sight, before any
6
+ # content scoring happens.
3
7
  class ObviousJunkMatcher
4
8
  def match?(node)
5
9
  tag = node.name.downcase
6
10
 
7
11
  # Always remove these tags
8
- return true if %w(script style link iframe noscript).include?(tag)
12
+ return true if JUNK_TAGS.include?(tag)
9
13
 
10
14
  # Remove hidden elements
11
15
  return true if node['aria-hidden'] == 'true'
12
-
13
- # Remove display:none elements
14
- style = node['style'].to_s.downcase
15
- return true if style.include?('display:none') || style.include?('display: none')
16
+ return true if Detergent.display_none?(node)
16
17
 
17
18
  # Remove structural navigation
18
- return true if %w(nav header footer).include?(tag)
19
+ return true if CHROME_TAGS.include?(tag)
19
20
  return true if node['role'].to_s.downcase == 'navigation'
20
21
 
21
22
  false
@@ -1,35 +1,41 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Detergent
2
4
  module Matchers
5
+ # Second-pass matcher: applied within the extracted content to drop
6
+ # leftover chrome, suspect containers, and empty elements.
3
7
  class RemovableNodeMatcher
8
+ REMOVABLE_TAGS = (JUNK_TAGS + %w[footer nav] + FORM_TAGS).freeze
9
+
10
+ SUSPECT_CLASSES = %w[comments-show comments actionbar related-stories navigation
11
+ nodisplay sidebar admz hidden header footer social share].freeze
12
+
13
+ SUSPECT_IDS = %w[header navigation ad admz sidebar related-stories hidden].freeze
4
14
 
5
15
  def match?(node)
16
+ tag = node.name.downcase
17
+
6
18
  # Remove button elements with Share or Save aria-labels
7
- if node.name.downcase == "button"
19
+ if tag == "button"
8
20
  aria_label = node['aria-label'].to_s.downcase
9
21
  return true if ["share", "save"].include?(aria_label)
10
22
  end
11
23
 
12
- # Remove nav with role=navigation
13
- if node.name.downcase == "nav"
14
- return true if node['role'].to_s.downcase == "navigation"
15
- end
16
-
17
24
  # Elements we don't care about
18
- return true if %w(link iframe script style footer nav form select textarea).include?(node.name.downcase)
25
+ return true if REMOVABLE_TAGS.include?(tag)
19
26
 
20
- # Get rid of elements with classnames that look suspect
27
+ # Get rid of elements with classnames or ids that look suspect
21
28
  class_list = node['class'].to_s.downcase.split(" ")
22
- return true if class_list.any?{ %w(comments-show comments actionbar related-stories navigation nodisplay sidebar admz hidden header footer social share).include?(_1) }
29
+ return true if class_list.any? { SUSPECT_CLASSES.include?(_1) }
23
30
 
24
31
  id_list = node['id'].to_s.downcase.split(" ")
25
- return true if id_list.any?{ %w(header navigation ad admz sidebar related-stories hidden).include?(_1) }
32
+ return true if id_list.any? { SUSPECT_IDS.include?(_1) }
26
33
 
27
- # Get rid of elements with inline styles that look suspect
28
- style_list = node['style'].to_s.downcase.split(";")
29
- return true if style_list.any?{ ["display: none", "display:none"].include?(_1) }
34
+ # Get rid of hidden elements
35
+ return true if Detergent.display_none?(node)
30
36
 
31
37
  # Don't remove images, etc in this step, which never have text content:
32
- return false if %w(img picture figure).include?(node.name.downcase)
38
+ return false if MEDIA_TAGS.include?(tag)
33
39
 
34
40
  # Don't remove if any descendants are imgs, etc:
35
41
  return false if node.xpath(".//img | .//picture | .//figure").any?
@@ -1,74 +1,116 @@
1
- # LOL yes, I'm doing this.
2
- class BasicObject
3
- def readify_score=(new_score)
4
- @readify_score = new_score
5
- end
6
-
7
- def readify_score
8
- @readify_score
9
- end
10
- end
1
+ # frozen_string_literal: true
11
2
 
12
3
  module Detergent
4
+ # Scores nodes on how likely they are to be the page's main content.
5
+ #
6
+ # Descendant statistics are computed bottom-up, with each node's stats
7
+ # built from its children's already-computed stats, so scoring every
8
+ # node in a document is O(n) instead of a full-subtree scan per node.
9
+ # A scorer instance is scoped to a single parse of a single document;
10
+ # discard it once the tree is mutated.
13
11
  class NodeScorer
12
+ ARTICLE_TAG_BONUS = 100
13
+ MAIN_TAG_BONUS = 50
14
+ MAIN_ROLE_BONUS = 25
15
+ POINTS_PER_PARAGRAPH = 5
16
+ CHARS_PER_TEXT_POINT = 100
17
+ LINK_DENSITY_PENALTY = 10
18
+ POINTS_PER_MEDIA = 10
19
+ LONG_PARAGRAPH_BONUS = 15
20
+ LONG_PARAGRAPH_LENGTH = 100
21
+ POINTS_PER_BLOCKQUOTE = 10
22
+ POINTS_PER_LIST = 5
23
+ SIDEBAR_PENALTY = 50
24
+ COMMENT_PENALTY = 50
25
+ AD_PENALTY = 70
26
+ SOCIAL_PENALTY = 70
27
+
28
+ def initialize
29
+ @scores = {}.compare_by_identity
30
+ @stats = {}.compare_by_identity
31
+ end
32
+
14
33
  def score(node)
15
- return node.readify_score unless node.readify_score.nil?
34
+ cached = @scores[node]
35
+ return cached unless cached.nil?
16
36
  return 0 unless node.element?
17
37
 
38
+ @scores[node] = compute_score(node)
39
+ end
40
+
41
+ private
42
+
43
+ def compute_score(node)
44
+ stats = stats_for(node)
18
45
  score = 0
19
46
  tag = node.name.downcase
20
47
 
21
48
  # Positive indicators for main content
22
- score += 100 if tag == 'article'
23
- score += 50 if tag == 'main'
24
- score += 25 if node['role'].to_s.downcase == 'main'
25
-
26
- # Count paragraphs - strong indicator of article content
27
- paragraph_count = node.xpath('.//p').count
28
- score += paragraph_count * 5
29
-
30
- # Calculate text length
31
- begin
32
- text_content = node.xpath('.//text()').map(&:text).join
33
- text_length = text_content.strip.length
34
- score += (text_length / 100).to_i # 1 point per 100 characters
35
- rescue StandardError
36
- text_length = 0
37
- end
38
-
39
- # Calculate link density (high link density suggests navigation)
40
- link_count = node.xpath('.//a').count
41
- if text_length > 0
42
- link_density = (link_count.to_f / (text_length / 100.0))
43
- score -= (link_density * 10).to_i # Penalize high link density
44
- end
49
+ score += ARTICLE_TAG_BONUS if tag == 'article'
50
+ score += MAIN_TAG_BONUS if tag == 'main'
51
+ score += MAIN_ROLE_BONUS if node['role'].to_s.downcase == 'main'
45
52
 
46
- # Bonus for images/figures (suggests article content)
47
- media_count = node.xpath('.//img | .//picture | .//figure').count
48
- score += media_count * 10
53
+ # Paragraphs and raw text are strong indicators of article content
54
+ score += stats[:paragraphs] * POINTS_PER_PARAGRAPH
55
+ score += stats[:text_length] / CHARS_PER_TEXT_POINT
56
+ score += stats[:long_paragraphs] * LONG_PARAGRAPH_BONUS
49
57
 
50
- # Bonus for long paragraphs
51
- begin
52
- long_paragraphs = node.xpath('.//p').select { |p| p.text.strip.length > 100 }.count
53
- score += long_paragraphs * 15
54
- rescue StandardError
55
- # Do nothing
58
+ # High link density suggests navigation
59
+ if stats[:text_length] > 0
60
+ link_density = stats[:links].to_f / (stats[:text_length] / 100.0)
61
+ score -= (link_density * LINK_DENSITY_PENALTY).to_i
56
62
  end
57
63
 
58
- # Bonus for blockquotes and lists (often in articles)
59
- score += node.xpath('.//blockquote').count * 10
60
- score += node.xpath('.//ul | .//ol').count * 5
64
+ # Media, blockquotes, and lists all suggest article content
65
+ score += stats[:media] * POINTS_PER_MEDIA
66
+ score += stats[:blockquotes] * POINTS_PER_BLOCKQUOTE
67
+ score += stats[:lists] * POINTS_PER_LIST
61
68
 
62
69
  # Penalty for suspicious classes/ids
63
70
  classes = node['class'].to_s.downcase
64
71
  ids = node['id'].to_s.downcase
65
72
 
66
- score -= 50 if classes.include?('sidebar') || ids.include?('sidebar')
67
- score -= 50 if classes.include?('comment') || ids.include?('comment')
68
- score -= 70 if classes.include?('ad') || ids.include?('ad')
69
- score -= 70 if classes.include?('social') || ids.include?('social')
73
+ score -= SIDEBAR_PENALTY if classes.include?('sidebar') || ids.include?('sidebar')
74
+ score -= COMMENT_PENALTY if classes.include?('comment') || ids.include?('comment')
75
+ score -= AD_PENALTY if classes.include?('ad') || ids.include?('ad')
76
+ score -= SOCIAL_PENALTY if classes.include?('social') || ids.include?('social')
77
+
78
+ [score, 0].max # Don't return negative scores
79
+ end
80
+
81
+ # Statistics about a node's descendants (not the node itself),
82
+ # aggregated from its children's stats in a single pass.
83
+ def stats_for(node)
84
+ cached = @stats[node]
85
+ return cached if cached
86
+
87
+ stats = { text_length: 0, links: 0, paragraphs: 0, long_paragraphs: 0,
88
+ media: 0, blockquotes: 0, lists: 0 }
89
+
90
+ node.children.each do |child|
91
+ if child.text?
92
+ stats[:text_length] += child.text.strip.length
93
+ elsif child.element?
94
+ child_stats = stats_for(child)
95
+ stats.each_key { |key| stats[key] += child_stats[key] }
96
+
97
+ case child.name.downcase
98
+ when 'a'
99
+ stats[:links] += 1
100
+ when 'p'
101
+ stats[:paragraphs] += 1
102
+ stats[:long_paragraphs] += 1 if child_stats[:text_length] > LONG_PARAGRAPH_LENGTH
103
+ when 'img', 'picture', 'figure'
104
+ stats[:media] += 1
105
+ when 'blockquote'
106
+ stats[:blockquotes] += 1
107
+ when 'ul', 'ol'
108
+ stats[:lists] += 1
109
+ end
110
+ end
111
+ end
70
112
 
71
- node.readify_score = [score, 0].max # Don't return negative scores
113
+ @stats[node] = stats
72
114
  end
73
115
  end
74
116
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Detergent
4
+ # Renders a cleaned content node as plain text: the same document
5
+ # structure as MarkdownRenderer (blank-line paragraphs, list bullets)
6
+ # with all Markdown syntax stripped. Images are dropped entirely.
7
+ class TextRenderer < MarkdownRenderer
8
+ private
9
+
10
+ def heading(_level, text)
11
+ text
12
+ end
13
+
14
+ def blockquote(content)
15
+ content
16
+ end
17
+
18
+ def preformatted(text)
19
+ text
20
+ end
21
+
22
+ def rule
23
+ ""
24
+ end
25
+
26
+ def caption(text)
27
+ text
28
+ end
29
+
30
+ def link(node)
31
+ inline_content(node).strip
32
+ end
33
+
34
+ def image(_node)
35
+ ""
36
+ end
37
+
38
+ def wrap(node, _marker)
39
+ inline_content(node).strip
40
+ end
41
+
42
+ def code_span(node)
43
+ node.text.strip
44
+ end
45
+ end
46
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Detergent
2
- VERSION = "1.0.0"
4
+ VERSION = "2.1.0"
3
5
  end
data/lib/detergent.rb CHANGED
@@ -1,10 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
1
4
  require "nokogiri"
2
5
 
6
+ module Detergent
7
+ # Tags that never contain readable content.
8
+ JUNK_TAGS = %w[script style link iframe noscript].freeze
9
+
10
+ # Structural chrome that surrounds the content.
11
+ CHROME_TAGS = %w[nav header footer].freeze
12
+
13
+ # Interactive elements that don't belong in cleaned output.
14
+ FORM_TAGS = %w[form select textarea].freeze
15
+
16
+ # Media elements that legitimately contain no text.
17
+ MEDIA_TAGS = %w[img picture figure].freeze
18
+
19
+ def self.display_none?(node)
20
+ style = node["style"].to_s.downcase
21
+ style.include?("display:none") || style.include?("display: none")
22
+ end
23
+
24
+ # Returns a cleaned, standalone HTML document.
25
+ def self.clean(html)
26
+ Cleaner.new.clean(html)
27
+ end
28
+
29
+ # Returns [title, content]: the page title and the cleaned Nokogiri
30
+ # node containing the main content (nil if none was found).
31
+ def self.extract(html)
32
+ Cleaner.new.extract(html)
33
+ end
34
+
35
+ # Returns the extracted main content as Markdown.
36
+ def self.markdown(html)
37
+ Cleaner.new.markdown(html)
38
+ end
39
+
40
+ # Returns the extracted main content as plain text.
41
+ def self.text(html)
42
+ Cleaner.new.text(html)
43
+ end
44
+ end
45
+
3
46
  require_relative "detergent/version"
4
47
  require_relative "detergent/node_scorer"
48
+ require_relative "detergent/content_locator"
49
+ require_relative "detergent/markdown_renderer"
50
+ require_relative "detergent/text_renderer"
5
51
  require_relative "detergent/matchers/obvious_junk_matcher"
6
52
  require_relative "detergent/matchers/removable_node_matcher"
7
53
  require_relative "detergent/cleaner"
8
-
9
- module Detergent
10
- end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: detergent
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff McFadden
@@ -63,9 +63,12 @@ files:
63
63
  - README.md
64
64
  - lib/detergent.rb
65
65
  - lib/detergent/cleaner.rb
66
+ - lib/detergent/content_locator.rb
67
+ - lib/detergent/markdown_renderer.rb
66
68
  - lib/detergent/matchers/obvious_junk_matcher.rb
67
69
  - lib/detergent/matchers/removable_node_matcher.rb
68
70
  - lib/detergent/node_scorer.rb
71
+ - lib/detergent/text_renderer.rb
69
72
  - lib/detergent/version.rb
70
73
  homepage: https://github.com/jeffmcfadden/detergent
71
74
  licenses: