detergent 2.2.0 → 2.3.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 +37 -0
- data/exe/detergent +58 -0
- data/lib/detergent/cleaner.rb +11 -2
- data/lib/detergent/inspector.rb +139 -0
- data/lib/detergent/node_scorer.rb +35 -19
- data/lib/detergent/version.rb +1 -1
- data/lib/detergent.rb +1 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27c1381c7afa9541cb1285a2f31c418d9c1d392fd5990abb7c1ee4a5ef630f24
|
|
4
|
+
data.tar.gz: 4935ce56bfef6771ab9cdbd2a431d9931ab374df7e4dffa876c75f95ee3c671c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b5f9a0ce17b8ac37ccfa764780709f4bff83a3c527c7b2d5bd68ed633369aeea93a662d81e680d3b4e90679b518478a72cebb8a24b97990df9a857aebf55dcd
|
|
7
|
+
data.tar.gz: 7030d8857c008565da627a56b4071de605bf3b5ecc6b5bdebd91a1bc20b5db991c14953b81da910567b9810d257089449dccfa0e33ed2fc7bfb82e390dda3111
|
data/README.md
CHANGED
|
@@ -31,6 +31,43 @@ clean_html = cleaner.clean(dirty_html)
|
|
|
31
31
|
title = cleaner.title(dirty_html)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
## Command line
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
detergent article.html # extracted content as Markdown
|
|
38
|
+
detergent --format text https://example.com/post
|
|
39
|
+
detergent --format html article.html # cleaned standalone document
|
|
40
|
+
detergent --format title article.html
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Debugging
|
|
44
|
+
|
|
45
|
+
When extraction picks the wrong node (or nothing at all), the Inspector
|
|
46
|
+
reports every decision: what each pass removed, the top-scoring nodes with
|
|
47
|
+
point-by-point score breakdowns, and whether the best candidate cleared the
|
|
48
|
+
minimum content score.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
$ detergent --format debug page.html
|
|
52
|
+
|
|
53
|
+
Detergent 2.3.0 debug report
|
|
54
|
+
Title: "Hacker News"
|
|
55
|
+
Verdict: NO MAIN CONTENT (best score 10 < threshold 25)
|
|
56
|
+
|
|
57
|
+
First pass (obvious junk): removed 1 nodes (script x1)
|
|
58
|
+
|
|
59
|
+
Top scoring nodes after first pass:
|
|
60
|
+
10 td
|
|
61
|
+
+10 1 media elements
|
|
62
|
+
0 tr#48825749.athing.submission 20.Tenda firmware (multiple versions) co...
|
|
63
|
+
-34 link density 3.45 (3 links)
|
|
64
|
+
...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or from Ruby: `puts Detergent::Inspector.new.analyze(html)` — the returned
|
|
68
|
+
report object also exposes `located?`, `best_score`, `top_nodes`, and
|
|
69
|
+
`removals` for programmatic use.
|
|
70
|
+
|
|
34
71
|
## How it works
|
|
35
72
|
|
|
36
73
|
1. A first pass prunes obvious junk (scripts, styles, iframes, nav, headers, footers, hidden elements).
|
data/exe/detergent
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "detergent"
|
|
5
|
+
require "optparse"
|
|
6
|
+
|
|
7
|
+
format = "markdown"
|
|
8
|
+
|
|
9
|
+
parser = OptionParser.new do |opts|
|
|
10
|
+
opts.banner = <<~BANNER
|
|
11
|
+
Usage: detergent [options] <file or URL>
|
|
12
|
+
|
|
13
|
+
Extracts the main content from a web page.
|
|
14
|
+
BANNER
|
|
15
|
+
|
|
16
|
+
opts.on("-f", "--format FORMAT", %w[html markdown text title debug],
|
|
17
|
+
"Output format: html, markdown, text, title, or debug (default: markdown)") do |value|
|
|
18
|
+
format = value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
opts.on("-v", "--version", "Print the version") do
|
|
22
|
+
puts Detergent::VERSION
|
|
23
|
+
exit
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
parser.parse!
|
|
27
|
+
|
|
28
|
+
source = ARGV.shift
|
|
29
|
+
abort parser.to_s if source.nil?
|
|
30
|
+
|
|
31
|
+
def fetch(url, limit = 5)
|
|
32
|
+
abort "Error: too many redirects" if limit.zero?
|
|
33
|
+
|
|
34
|
+
response = Net::HTTP.get_response(URI(url))
|
|
35
|
+
case response
|
|
36
|
+
when Net::HTTPRedirection
|
|
37
|
+
fetch(URI.join(url, response["location"]).to_s, limit - 1)
|
|
38
|
+
when Net::HTTPSuccess
|
|
39
|
+
response.body
|
|
40
|
+
else
|
|
41
|
+
abort "Error: HTTP #{response.code} fetching #{url}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
html = if source.match?(%r{\Ahttps?://})
|
|
46
|
+
require "net/http"
|
|
47
|
+
fetch(source)
|
|
48
|
+
else
|
|
49
|
+
File.read(source)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
case format
|
|
53
|
+
when "html" then puts Detergent.clean(html)
|
|
54
|
+
when "markdown" then puts Detergent.markdown(html)
|
|
55
|
+
when "text" then puts Detergent.text(html)
|
|
56
|
+
when "title" then puts Detergent::Cleaner.new.title(html)
|
|
57
|
+
when "debug" then puts Detergent::Inspector.new.analyze(html)
|
|
58
|
+
end
|
data/lib/detergent/cleaner.rb
CHANGED
|
@@ -4,7 +4,11 @@ module Detergent
|
|
|
4
4
|
# Orchestrates cleaning: prunes obvious junk, locates the main content,
|
|
5
5
|
# scrubs it, and renders the result back out as a standalone document.
|
|
6
6
|
class Cleaner
|
|
7
|
-
|
|
7
|
+
# observer (optional) receives instrumentation callbacks during
|
|
8
|
+
# extraction: node_removed(node, pass:) and content_pruned(body, scorer).
|
|
9
|
+
# Used by Inspector to build debug reports.
|
|
10
|
+
def initialize(observer: nil)
|
|
11
|
+
@observer = observer
|
|
8
12
|
@obvious_junk_matcher = Matchers::ObviousJunkMatcher.new
|
|
9
13
|
@removable_node_matcher = Matchers::RemovableNodeMatcher.new
|
|
10
14
|
end
|
|
@@ -50,7 +54,9 @@ module Detergent
|
|
|
50
54
|
|
|
51
55
|
# Score caches are only valid for a single parse of a single
|
|
52
56
|
# document, so the scorer and locator are built per extract.
|
|
53
|
-
|
|
57
|
+
scorer = NodeScorer.new
|
|
58
|
+
@observer&.content_pruned(body, scorer)
|
|
59
|
+
content = ContentLocator.new(scorer).locate(body)
|
|
54
60
|
|
|
55
61
|
# Apply second-pass cleaning to the content
|
|
56
62
|
if content
|
|
@@ -91,6 +97,7 @@ module Detergent
|
|
|
91
97
|
|
|
92
98
|
# Match first so we never bother walking a subtree we're removing
|
|
93
99
|
if matcher.match?(child)
|
|
100
|
+
@observer&.node_removed(child, pass: :first_pass)
|
|
94
101
|
child.remove
|
|
95
102
|
else
|
|
96
103
|
prune(node: child, matcher: matcher)
|
|
@@ -111,8 +118,10 @@ module Detergent
|
|
|
111
118
|
# Remove the child if it qualifies as "empty"
|
|
112
119
|
|
|
113
120
|
if child.name.downcase == "aside" && !within_article
|
|
121
|
+
@observer&.node_removed(child, pass: :second_pass)
|
|
114
122
|
child.remove
|
|
115
123
|
elsif removable?(child)
|
|
124
|
+
@observer&.node_removed(child, pass: :second_pass)
|
|
116
125
|
child.remove
|
|
117
126
|
else
|
|
118
127
|
strip_junk_attributes(child)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Detergent
|
|
4
|
+
# Instruments a full extraction run and reports every decision made
|
|
5
|
+
# along the way: what the first pass removed, how the tree scored (with
|
|
6
|
+
# per-node score breakdowns), whether the best candidate cleared the
|
|
7
|
+
# minimum-score threshold, which content root was chosen, and what the
|
|
8
|
+
# second pass removed.
|
|
9
|
+
#
|
|
10
|
+
# puts Detergent::Inspector.new.analyze(html)
|
|
11
|
+
#
|
|
12
|
+
# Also available from the command line:
|
|
13
|
+
#
|
|
14
|
+
# detergent --format debug page.html
|
|
15
|
+
class Inspector
|
|
16
|
+
TOP_NODE_COUNT = 10
|
|
17
|
+
BREAKDOWN_COUNT = 3
|
|
18
|
+
|
|
19
|
+
ScoredNode = Struct.new(:score, :descriptor, :preview, :breakdown)
|
|
20
|
+
|
|
21
|
+
class Report
|
|
22
|
+
attr_accessor :title, :best_score, :content_root, :content_root_size, :top_nodes
|
|
23
|
+
attr_reader :removals
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@removals = { first_pass: [], second_pass: [] }
|
|
27
|
+
@top_nodes = []
|
|
28
|
+
@best_score = 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def located?
|
|
32
|
+
!content_root.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
lines = ["Detergent #{Detergent::VERSION} debug report"]
|
|
37
|
+
lines << "Title: #{title.inspect}"
|
|
38
|
+
lines << verdict
|
|
39
|
+
lines << ""
|
|
40
|
+
lines << removal_summary(:first_pass, "First pass (obvious junk)")
|
|
41
|
+
lines << ""
|
|
42
|
+
lines << "Top scoring nodes after first pass:"
|
|
43
|
+
|
|
44
|
+
top_nodes.each do |node|
|
|
45
|
+
lines << format(" %5d %-38s %s", node.score, node.descriptor, node.preview)
|
|
46
|
+
node.breakdown&.each do |label, points|
|
|
47
|
+
lines << format(" %+5d %s", points, label)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
lines << ""
|
|
52
|
+
lines << if located?
|
|
53
|
+
"Content root: #{content_root} (#{content_root_size} chars of HTML)"
|
|
54
|
+
else
|
|
55
|
+
"Content root: (none)"
|
|
56
|
+
end
|
|
57
|
+
lines << removal_summary(:second_pass, "Second pass (within content)")
|
|
58
|
+
lines.join("\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def verdict
|
|
64
|
+
threshold = ContentLocator::MINIMUM_CONTENT_SCORE
|
|
65
|
+
|
|
66
|
+
if located?
|
|
67
|
+
"Verdict: content located (best score #{best_score}, threshold #{threshold})"
|
|
68
|
+
else
|
|
69
|
+
"Verdict: NO MAIN CONTENT (best score #{best_score} < threshold #{threshold})"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def removal_summary(pass, label)
|
|
74
|
+
removed = removals[pass]
|
|
75
|
+
return "#{label}: removed 0 nodes" if removed.empty?
|
|
76
|
+
|
|
77
|
+
tally = removed.tally.sort_by { |_tag, count| -count }
|
|
78
|
+
.map { |tag, count| "#{tag} x#{count}" }.join(", ")
|
|
79
|
+
"#{label}: removed #{removed.length} nodes (#{tally})"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def analyze(html)
|
|
84
|
+
@report = Report.new
|
|
85
|
+
|
|
86
|
+
title, content = Cleaner.new(observer: self).extract(html)
|
|
87
|
+
@report.title = title
|
|
88
|
+
|
|
89
|
+
if content
|
|
90
|
+
@report.content_root = descriptor(content)
|
|
91
|
+
@report.content_root_size = content.to_s.length
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@report
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# -- Cleaner observer callbacks --
|
|
98
|
+
|
|
99
|
+
def node_removed(node, pass:)
|
|
100
|
+
@report.removals[pass] << node.name.downcase
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Called after the first pass, before content location, with the same
|
|
104
|
+
# scorer the locator will use — so the report matches its decisions.
|
|
105
|
+
def content_pruned(body, scorer)
|
|
106
|
+
scored = ([body] + body.xpath(".//*").to_a)
|
|
107
|
+
.map { |node| [scorer.score(node), node] }
|
|
108
|
+
.sort_by { |score, _node| -score }
|
|
109
|
+
.first(TOP_NODE_COUNT)
|
|
110
|
+
|
|
111
|
+
# Materialize descriptors/previews now; the tree mutates after this.
|
|
112
|
+
@report.top_nodes = scored.each_with_index.map do |(score, node), index|
|
|
113
|
+
breakdown = index < BREAKDOWN_COUNT ? scorer.explain(node) : nil
|
|
114
|
+
ScoredNode.new(score, descriptor(node), preview(node), breakdown)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@report.best_score = scored.first&.first || 0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def descriptor(node)
|
|
123
|
+
desc = +node.name.downcase
|
|
124
|
+
|
|
125
|
+
id = node['id'].to_s
|
|
126
|
+
desc << "##{id}" unless id.empty?
|
|
127
|
+
|
|
128
|
+
classes = node['class'].to_s.split(" ")
|
|
129
|
+
desc << ".#{classes.first(3).join('.')}" unless classes.empty?
|
|
130
|
+
|
|
131
|
+
desc
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def preview(node)
|
|
135
|
+
text = node.text.gsub(/\s+/, " ").strip
|
|
136
|
+
text.length > 60 ? "#{text[0, 60]}..." : text
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -35,47 +35,63 @@ module Detergent
|
|
|
35
35
|
return cached unless cached.nil?
|
|
36
36
|
return 0 unless node.element?
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
total = components_for(node).sum { |_label, points| points }
|
|
39
|
+
@scores[node] = [total, 0].max # Don't return negative scores
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the score as [label, points] pairs explaining where every
|
|
43
|
+
# point came from. Debugging aid used by Inspector.
|
|
44
|
+
def explain(node)
|
|
45
|
+
return [] unless node.element?
|
|
46
|
+
|
|
47
|
+
components_for(node)
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
private
|
|
42
51
|
|
|
43
|
-
def
|
|
52
|
+
def components_for(node)
|
|
44
53
|
stats = stats_for(node)
|
|
45
|
-
score = 0
|
|
46
54
|
tag = node.name.downcase
|
|
55
|
+
parts = []
|
|
47
56
|
|
|
48
57
|
# Positive indicators for main content
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
parts << ["<article> tag", ARTICLE_TAG_BONUS] if tag == 'article'
|
|
59
|
+
parts << ["<main> tag", MAIN_TAG_BONUS] if tag == 'main'
|
|
60
|
+
parts << ["role=main", MAIN_ROLE_BONUS] if node['role'].to_s.downcase == 'main'
|
|
52
61
|
|
|
53
62
|
# Paragraphs and raw text are strong indicators of article content
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
63
|
+
if stats[:paragraphs] > 0
|
|
64
|
+
parts << ["#{stats[:paragraphs]} paragraphs", stats[:paragraphs] * POINTS_PER_PARAGRAPH]
|
|
65
|
+
end
|
|
66
|
+
if (text_points = stats[:text_length] / CHARS_PER_TEXT_POINT) > 0
|
|
67
|
+
parts << ["#{stats[:text_length]} chars of text", text_points]
|
|
68
|
+
end
|
|
69
|
+
if stats[:long_paragraphs] > 0
|
|
70
|
+
parts << ["#{stats[:long_paragraphs]} long paragraphs", stats[:long_paragraphs] * LONG_PARAGRAPH_BONUS]
|
|
71
|
+
end
|
|
57
72
|
|
|
58
73
|
# High link density suggests navigation
|
|
59
|
-
if stats[:text_length] > 0
|
|
74
|
+
if stats[:text_length] > 0 && stats[:links] > 0
|
|
60
75
|
link_density = stats[:links].to_f / (stats[:text_length] / 100.0)
|
|
61
|
-
|
|
76
|
+
parts << ["link density #{link_density.round(2)} (#{stats[:links]} links)",
|
|
77
|
+
-(link_density * LINK_DENSITY_PENALTY).to_i]
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
# Media, blockquotes, and lists all suggest article content
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
parts << ["#{stats[:media]} media elements", stats[:media] * POINTS_PER_MEDIA] if stats[:media] > 0
|
|
82
|
+
parts << ["#{stats[:blockquotes]} blockquotes", stats[:blockquotes] * POINTS_PER_BLOCKQUOTE] if stats[:blockquotes] > 0
|
|
83
|
+
parts << ["#{stats[:lists]} lists", stats[:lists] * POINTS_PER_LIST] if stats[:lists] > 0
|
|
68
84
|
|
|
69
85
|
# Penalty for suspicious classes/ids
|
|
70
86
|
classes = node['class'].to_s.downcase
|
|
71
87
|
ids = node['id'].to_s.downcase
|
|
72
88
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
parts << ["suspect 'sidebar' class/id", -SIDEBAR_PENALTY] if classes.include?('sidebar') || ids.include?('sidebar')
|
|
90
|
+
parts << ["suspect 'comment' class/id", -COMMENT_PENALTY] if classes.include?('comment') || ids.include?('comment')
|
|
91
|
+
parts << ["suspect 'ad' class/id", -AD_PENALTY] if classes.include?('ad') || ids.include?('ad')
|
|
92
|
+
parts << ["suspect 'social' class/id", -SOCIAL_PENALTY] if classes.include?('social') || ids.include?('social')
|
|
77
93
|
|
|
78
|
-
|
|
94
|
+
parts
|
|
79
95
|
end
|
|
80
96
|
|
|
81
97
|
# Statistics about a node's descendants (not the node itself),
|
data/lib/detergent/version.rb
CHANGED
data/lib/detergent.rb
CHANGED
metadata
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: detergent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jeff McFadden
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
@@ -55,15 +55,18 @@ description: 'Detergent scrubs junk out of web pages: it removes ads, navigation
|
|
|
55
55
|
scripts, and other cruft, then extracts the main article content from an HTML document.'
|
|
56
56
|
email:
|
|
57
57
|
- 55709+jeffmcfadden@users.noreply.github.com
|
|
58
|
-
executables:
|
|
58
|
+
executables:
|
|
59
|
+
- detergent
|
|
59
60
|
extensions: []
|
|
60
61
|
extra_rdoc_files: []
|
|
61
62
|
files:
|
|
62
63
|
- LICENSE
|
|
63
64
|
- README.md
|
|
65
|
+
- exe/detergent
|
|
64
66
|
- lib/detergent.rb
|
|
65
67
|
- lib/detergent/cleaner.rb
|
|
66
68
|
- lib/detergent/content_locator.rb
|
|
69
|
+
- lib/detergent/inspector.rb
|
|
67
70
|
- lib/detergent/markdown_renderer.rb
|
|
68
71
|
- lib/detergent/matchers/obvious_junk_matcher.rb
|
|
69
72
|
- lib/detergent/matchers/removable_node_matcher.rb
|