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 +4 -4
- data/README.md +7 -7
- data/lib/detergent/cleaner.rb +69 -117
- data/lib/detergent/content_locator.rb +82 -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/version.rb +3 -1
- data/lib/detergent.rb +34 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 492c13c265f4d55f83ea2d4e32352ebf0209abf4bf15524ad291d352a7ca56ff
|
|
4
|
+
data.tar.gz: cd5fcadafd69fb5d43e9db36e7b3f2aff3516d792098a982584fb69eab91b2ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
+
# 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
|
data/lib/detergent/cleaner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
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
|
-
|
|
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
|
-
|
|
71
|
+
def extract_title(doc)
|
|
72
|
+
title_node = doc.at('title')
|
|
73
|
+
title_node ? title_node.text.strip : ""
|
|
111
74
|
end
|
|
112
75
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
#
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/detergent/version.rb
CHANGED
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:
|
|
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
|