detergent 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4ad549b0acaa35ac06eeea236937b7db8df4eb4c19111b1650b7b23738d35be1
4
+ data.tar.gz: b3da9c4ba188b6f750adf27dbe1e9c52403371abb410dbea651a6ff387a2d6bc
5
+ SHA512:
6
+ metadata.gz: 3e88afce9a71395e045a7b7c63f204c9f9d7ff9ba64e36d0d02610ea4884d34432ca853b5721dbc4cf67b1888a63c08062e8a66b242b6288d6f6eca3737b15cd
7
+ data.tar.gz: cce8cdcd13d4b12f6775ead158468822c1c803b3100cacf03ceb548a937870109f82592f1aea5f045334b6327d5e975ea2d9856aea8a6182c08390c6a70322e7
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
2
+ Version 2, December 2004
3
+
4
+ Copyright (C) 2026 Jeff McFadden
5
+
6
+ Everyone is permitted to copy and distribute verbatim or modified
7
+ copies of this license document, and changing it is allowed as long
8
+ as the name is changed.
9
+
10
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
11
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
12
+
13
+ 0. You just DO WHAT THE FUCK YOU WANT TO.
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # Detergent
2
+
3
+ Detergent scrubs junk out of web pages. Give it an HTML document and it removes ads, navigation, scripts, sidebars, and other cruft, then extracts the main article content.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "detergent"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ require "detergent"
17
+
18
+ cleaner = Detergent::Cleaner.new
19
+
20
+ # 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)
25
+
26
+ # Or the title and the extracted content node (a Nokogiri node):
27
+ title, content = cleaner.cleaned_html(dirty_html)
28
+ ```
29
+
30
+ ## How it works
31
+
32
+ 1. A first pass prunes obvious junk (scripts, styles, iframes, nav, headers, footers, hidden elements).
33
+ 2. Each remaining node is scored on how likely it is to be the main content (paragraph count, text length, link density, media, suspicious class names, etc.).
34
+ 3. The highest-scoring node is located and walked up to an appropriate semantic container.
35
+ 4. A second pass cleans that content of empty and suspect elements.
36
+
37
+ ## License
38
+
39
+ [WTFPL](LICENSE)
@@ -0,0 +1,179 @@
1
+ module Detergent
2
+ class Cleaner
3
+
4
+ def initialize
5
+ @node_scorer = NodeScorer.new
6
+ @obvious_junk_matcher = Matchers::ObviousJunkMatcher.new
7
+ @removable_node_matcher = Matchers::RemovableNodeMatcher.new
8
+ end
9
+
10
+ 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 : ""
31
+
32
+ title
33
+ end
34
+
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 : ""
41
+
42
+ body = doc.at('body')
43
+ content = nil
44
+
45
+ if body
46
+ # 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
52
+
53
+ # Find the appropriate root container for that node
54
+ content_root = find_content_root(highest_scoring)
55
+
56
+ # Apply second-pass cleaning to the content
57
+ if content_root
58
+ clean_node(content_root)
59
+ end
60
+
61
+ content = content_root
62
+ end
63
+
64
+ [title, content]
65
+ end
66
+
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)
86
+ end
87
+
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
111
+ end
112
+
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)
139
+
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
144
+ end
145
+
146
+ current = parent
147
+ end
148
+
149
+ best_ancestor
150
+ end
151
+
152
+ # Recursively process an element's children and remove any that are "empty"
153
+ def clean_node(node, within_article: false)
154
+ # Iterate over a copy of the children to avoid modification issues
155
+ node.children.to_a.each do |child|
156
+ if child.element?
157
+ clean_node(child, within_article: within_article || node.name.downcase == "article" ) # process children first
158
+ # Remove the child if it qualifies as "empty"
159
+
160
+ if child.name.downcase == "aside" && !within_article
161
+ child.remove
162
+ elsif removable?(child)
163
+ child.remove
164
+ else
165
+ child.remove_attribute("class") # Cleanup
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ # An element is removable if:
172
+ # - It is a <script> or <style> tag (non-visible content), or other element we don't want
173
+ # - It has no descendant text nodes (ignoring whitespace)
174
+ def removable?(node)
175
+ @removable_node_matcher.match?(node)
176
+ end
177
+
178
+ end
179
+ end
@@ -0,0 +1,25 @@
1
+ module Detergent
2
+ module Matchers
3
+ class ObviousJunkMatcher
4
+ def match?(node)
5
+ tag = node.name.downcase
6
+
7
+ # Always remove these tags
8
+ return true if %w(script style link iframe noscript).include?(tag)
9
+
10
+ # Remove hidden elements
11
+ 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
+
17
+ # Remove structural navigation
18
+ return true if %w(nav header footer).include?(tag)
19
+ return true if node['role'].to_s.downcase == 'navigation'
20
+
21
+ false
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,42 @@
1
+ module Detergent
2
+ module Matchers
3
+ class RemovableNodeMatcher
4
+
5
+ def match?(node)
6
+ # Remove button elements with Share or Save aria-labels
7
+ if node.name.downcase == "button"
8
+ aria_label = node['aria-label'].to_s.downcase
9
+ return true if ["share", "save"].include?(aria_label)
10
+ end
11
+
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
+ # Elements we don't care about
18
+ return true if %w(link iframe script style footer nav form select textarea).include?(node.name.downcase)
19
+
20
+ # Get rid of elements with classnames that look suspect
21
+ 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) }
23
+
24
+ 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) }
26
+
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) }
30
+
31
+ # 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)
33
+
34
+ # Don't remove if any descendants are imgs, etc:
35
+ return false if node.xpath(".//img | .//picture | .//figure").any?
36
+
37
+ # Using XPath to find any descendant text node that contains non-whitespace characters.
38
+ node.xpath(".//text()[normalize-space()]").empty?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,74 @@
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
11
+
12
+ module Detergent
13
+ class NodeScorer
14
+ def score(node)
15
+ return node.readify_score unless node.readify_score.nil?
16
+ return 0 unless node.element?
17
+
18
+ score = 0
19
+ tag = node.name.downcase
20
+
21
+ # 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
45
+
46
+ # Bonus for images/figures (suggests article content)
47
+ media_count = node.xpath('.//img | .//picture | .//figure').count
48
+ score += media_count * 10
49
+
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
56
+ end
57
+
58
+ # Bonus for blockquotes and lists (often in articles)
59
+ score += node.xpath('.//blockquote').count * 10
60
+ score += node.xpath('.//ul | .//ol').count * 5
61
+
62
+ # Penalty for suspicious classes/ids
63
+ classes = node['class'].to_s.downcase
64
+ ids = node['id'].to_s.downcase
65
+
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')
70
+
71
+ node.readify_score = [score, 0].max # Don't return negative scores
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,3 @@
1
+ module Detergent
2
+ VERSION = "1.0.0"
3
+ end
data/lib/detergent.rb ADDED
@@ -0,0 +1,10 @@
1
+ require "nokogiri"
2
+
3
+ require_relative "detergent/version"
4
+ require_relative "detergent/node_scorer"
5
+ require_relative "detergent/matchers/obvious_junk_matcher"
6
+ require_relative "detergent/matchers/removable_node_matcher"
7
+ require_relative "detergent/cleaner"
8
+
9
+ module Detergent
10
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: detergent
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeff McFadden
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: nokogiri
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.15'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.15'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ description: 'Detergent scrubs junk out of web pages: it removes ads, navigation,
55
+ scripts, and other cruft, then extracts the main article content from an HTML document.'
56
+ email:
57
+ - 55709+jeffmcfadden@users.noreply.github.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE
63
+ - README.md
64
+ - lib/detergent.rb
65
+ - lib/detergent/cleaner.rb
66
+ - lib/detergent/matchers/obvious_junk_matcher.rb
67
+ - lib/detergent/matchers/removable_node_matcher.rb
68
+ - lib/detergent/node_scorer.rb
69
+ - lib/detergent/version.rb
70
+ homepage: https://github.com/jeffmcfadden/detergent
71
+ licenses:
72
+ - WTFPL
73
+ metadata:
74
+ homepage_uri: https://github.com/jeffmcfadden/detergent
75
+ source_code_uri: https://github.com/jeffmcfadden/detergent
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 3.1.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.9
91
+ specification_version: 4
92
+ summary: Cleans up website HTML, extracting the main content.
93
+ test_files: []