detergent 1.0.0 → 2.0.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: 492c13c265f4d55f83ea2d4e32352ebf0209abf4bf15524ad291d352a7ca56ff
4
+ data.tar.gz: cd5fcadafd69fb5d43e9db36e7b3f2aff3516d792098a982584fb69eab91b2ba
5
5
  SHA512:
6
- metadata.gz: 3e88afce9a71395e045a7b7c63f204c9f9d7ff9ba64e36d0d02610ea4884d34432ca853b5721dbc4cf67b1888a63c08062e8a66b242b6288d6f6eca3737b15cd
7
- data.tar.gz: cce8cdcd13d4b12f6775ead158468822c1c803b3100cacf03ceb548a937870109f82592f1aea5f045334b6327d5e975ea2d9856aea8a6182c08390c6a70322e7
6
+ metadata.gz: 1135a0396b72e5f1c3892befd5f9930d5a8193ca9ffd36d42b5d431e7824d0e2a62b2a2a8afbd1084942780257d6651dc066a3efd18995fed81b53b6d3b3cf8c
7
+ data.tar.gz: e9809a3df3521cb9cd78c8c9b54f590de968ca84693454feb0e64e62137b0f1aaecc6df7877fed74f19251ece1ccf415328e68f60e92e8569ce52167d920c881
data/README.md CHANGED
@@ -15,16 +15,16 @@ 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
+ # A Cleaner instance is reusable if you're processing many pages:
25
+ cleaner = Detergent::Cleaner.new
26
+ clean_html = cleaner.clean(dirty_html)
27
+ title = cleaner.title(dirty_html)
28
28
  ```
29
29
 
30
30
  ## How it works
@@ -1,160 +1,101 @@
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
80
- end
81
-
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)
65
+ def title(html)
66
+ extract_title(Nokogiri::HTML5(html))
86
67
  end
87
68
 
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
69
+ private
109
70
 
110
- best_node
71
+ def extract_title(doc)
72
+ title_node = doc.at('title')
73
+ title_node ? title_node.text.strip : ""
111
74
  end
112
75
 
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'
129
-
130
- # Prefer semantic containers
131
- if %w(article main section).include?(parent_tag)
132
- best_ancestor = parent
133
- end
134
-
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)
76
+ def prune(node:, matcher:)
77
+ node.children.to_a.each do |child|
78
+ next unless child.element?
139
79
 
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
80
+ # Match first so we never bother walking a subtree we're removing
81
+ if matcher.match?(child)
82
+ child.remove
83
+ else
84
+ prune(node: child, matcher: matcher)
144
85
  end
145
-
146
- current = parent
147
86
  end
148
87
 
149
- best_ancestor
88
+ node
150
89
  end
151
90
 
152
91
  # Recursively process an element's children and remove any that are "empty"
153
92
  def clean_node(node, within_article: false)
93
+ within_article ||= node.name.downcase == "article"
94
+
154
95
  # Iterate over a copy of the children to avoid modification issues
155
96
  node.children.to_a.each do |child|
156
97
  if child.element?
157
- clean_node(child, within_article: within_article || node.name.downcase == "article" ) # process children first
98
+ clean_node(child, within_article: within_article) # process children first
158
99
  # Remove the child if it qualifies as "empty"
159
100
 
160
101
  if child.name.downcase == "aside" && !within_article
@@ -162,18 +103,29 @@ module Detergent
162
103
  elsif removable?(child)
163
104
  child.remove
164
105
  else
165
- child.remove_attribute("class") # Cleanup
106
+ strip_junk_attributes(child)
166
107
  end
167
108
  end
168
109
  end
169
110
  end
170
111
 
112
+ # Strip presentation and tracking attributes from a kept element.
113
+ # Must run only after removability has been decided, because the
114
+ # matchers read class and style. Keeps id so in-page anchors work.
115
+ def strip_junk_attributes(node)
116
+ node.attribute_nodes.each do |attr|
117
+ name = attr.name.downcase
118
+ if %w[class style].include?(name) || name.start_with?("on", "data-")
119
+ node.remove_attribute(attr.name)
120
+ end
121
+ end
122
+ end
123
+
171
124
  # An element is removable if:
172
125
  # - It is a <script> or <style> tag (non-visible content), or other element we don't want
173
126
  # - It has no descendant text nodes (ignoring whitespace)
174
127
  def removable?(node)
175
128
  @removable_node_matcher.match?(node)
176
129
  end
177
-
178
130
  end
179
131
  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
@@ -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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Detergent
2
- VERSION = "1.0.0"
4
+ VERSION = "2.0.0"
3
5
  end
data/lib/detergent.rb CHANGED
@@ -1,10 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
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
+ end
35
+
3
36
  require_relative "detergent/version"
4
37
  require_relative "detergent/node_scorer"
38
+ require_relative "detergent/content_locator"
5
39
  require_relative "detergent/matchers/obvious_junk_matcher"
6
40
  require_relative "detergent/matchers/removable_node_matcher"
7
41
  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.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff McFadden
@@ -63,6 +63,7 @@ files:
63
63
  - README.md
64
64
  - lib/detergent.rb
65
65
  - lib/detergent/cleaner.rb
66
+ - lib/detergent/content_locator.rb
66
67
  - lib/detergent/matchers/obvious_junk_matcher.rb
67
68
  - lib/detergent/matchers/removable_node_matcher.rb
68
69
  - lib/detergent/node_scorer.rb