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 +4 -4
- data/README.md +11 -7
- data/lib/detergent/cleaner.rb +78 -114
- data/lib/detergent/content_locator.rb +82 -0
- data/lib/detergent/markdown_renderer.rb +164 -0
- data/lib/detergent/matchers/obvious_junk_matcher.rb +7 -6
- data/lib/detergent/matchers/removable_node_matcher.rb +20 -14
- data/lib/detergent/node_scorer.rb +93 -51
- data/lib/detergent/text_renderer.rb +46 -0
- data/lib/detergent/version.rb +3 -1
- data/lib/detergent.rb +46 -3
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e2a63a0bf0f9524e9d7e4c1b873d2cb0eab8e29873bad005c1545501fa9915a
|
|
4
|
+
data.tar.gz: 03f8f53ce63ffa717b8b5d0981d92a02b307a466393ac9e37299d66e18dcdda8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/detergent/cleaner.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
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
|
-
#
|
|
54
|
-
|
|
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
|
|
58
|
-
clean_node(
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
83
|
+
def extract_title(doc)
|
|
84
|
+
title_node = doc.at('title')
|
|
85
|
+
title_node ? title_node.text.strip : ""
|
|
86
|
+
end
|
|
134
87
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
#
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
""
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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?{
|
|
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?{
|
|
32
|
+
return true if id_list.any? { SUSPECT_IDS.include?(_1) }
|
|
26
33
|
|
|
27
|
-
# Get rid of elements
|
|
28
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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 +=
|
|
23
|
-
score +=
|
|
24
|
-
score +=
|
|
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
|
-
#
|
|
47
|
-
|
|
48
|
-
score +=
|
|
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
|
-
#
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
score
|
|
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
|
-
#
|
|
59
|
-
score +=
|
|
60
|
-
score +=
|
|
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 -=
|
|
67
|
-
score -=
|
|
68
|
-
score -=
|
|
69
|
-
score -=
|
|
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
|
|
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
|
data/lib/detergent/version.rb
CHANGED
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
|
|
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:
|