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 +7 -0
- data/LICENSE +13 -0
- data/README.md +39 -0
- data/lib/detergent/cleaner.rb +179 -0
- data/lib/detergent/matchers/obvious_junk_matcher.rb +25 -0
- data/lib/detergent/matchers/removable_node_matcher.rb +42 -0
- data/lib/detergent/node_scorer.rb +74 -0
- data/lib/detergent/version.rb +3 -0
- data/lib/detergent.rb +10 -0
- metadata +93 -0
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
|
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: []
|