detergent 2.2.0 → 2.4.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 +42 -0
- data/exe/detergent +58 -0
- data/lib/detergent/cleaner.rb +22 -2
- data/lib/detergent/inspector.rb +146 -0
- data/lib/detergent/link_list_extractor.rb +61 -0
- data/lib/detergent/markdown_renderer.rb +5 -1
- data/lib/detergent/node_scorer.rb +35 -19
- data/lib/detergent/version.rb +1 -1
- data/lib/detergent.rb +2 -0
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe5c0dc45668a4c14b991433606aec83c6fd2672e93533632d608e66b858f03a
|
|
4
|
+
data.tar.gz: eada52c178f26669036609d5431f85245cd06d5de6b3920b010ba91a587ba18c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9ed056835cc1475822f443caa51610abc1dab4000dccf97f1058971f15cf8d9ceb6dbf44c7a91b079b4d6b886a8f239f1ec1408a53311e8d335d17cb34174886
|
|
7
|
+
data.tar.gz: e0ff4553f215510c0aebfcbbe35b2a5cca86ebfc8c755f597157a4c565b9950ad6b188b32610a92c7661635307ac388968a5e6a2fcd157e87463e2582c51810e
|
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).
|
|
@@ -38,6 +75,11 @@ title = cleaner.title(dirty_html)
|
|
|
38
75
|
3. The highest-scoring node is located and walked up to an appropriate semantic container.
|
|
39
76
|
4. A second pass cleans that content of empty and suspect elements.
|
|
40
77
|
|
|
78
|
+
Pages with no article content but a substantial set of title-like links —
|
|
79
|
+
index pages and link aggregators like the Hacker News front page — fall
|
|
80
|
+
back to link-list extraction: the result is a clean list of the page's
|
|
81
|
+
links instead of an article.
|
|
82
|
+
|
|
41
83
|
## License
|
|
42
84
|
|
|
43
85
|
[WTFPL](LICENSE)
|
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,12 @@ 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:), content_pruned(body, scorer),
|
|
9
|
+
# and extraction_strategy(strategy). Used by Inspector to build debug
|
|
10
|
+
# reports.
|
|
11
|
+
def initialize(observer: nil)
|
|
12
|
+
@observer = observer
|
|
8
13
|
@obvious_junk_matcher = Matchers::ObviousJunkMatcher.new
|
|
9
14
|
@removable_node_matcher = Matchers::RemovableNodeMatcher.new
|
|
10
15
|
end
|
|
@@ -50,7 +55,19 @@ module Detergent
|
|
|
50
55
|
|
|
51
56
|
# Score caches are only valid for a single parse of a single
|
|
52
57
|
# document, so the scorer and locator are built per extract.
|
|
53
|
-
|
|
58
|
+
scorer = NodeScorer.new
|
|
59
|
+
@observer&.content_pruned(body, scorer)
|
|
60
|
+
content = ContentLocator.new(scorer).locate(body)
|
|
61
|
+
strategy = content ? :article : nil
|
|
62
|
+
|
|
63
|
+
# No article-shaped content: the page may be a link index (front
|
|
64
|
+
# page, aggregator), whose content is its list of links.
|
|
65
|
+
if content.nil?
|
|
66
|
+
content = LinkListExtractor.new.extract(body)
|
|
67
|
+
strategy = :link_list if content
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@observer&.extraction_strategy(strategy)
|
|
54
71
|
|
|
55
72
|
# Apply second-pass cleaning to the content
|
|
56
73
|
if content
|
|
@@ -91,6 +108,7 @@ module Detergent
|
|
|
91
108
|
|
|
92
109
|
# Match first so we never bother walking a subtree we're removing
|
|
93
110
|
if matcher.match?(child)
|
|
111
|
+
@observer&.node_removed(child, pass: :first_pass)
|
|
94
112
|
child.remove
|
|
95
113
|
else
|
|
96
114
|
prune(node: child, matcher: matcher)
|
|
@@ -111,8 +129,10 @@ module Detergent
|
|
|
111
129
|
# Remove the child if it qualifies as "empty"
|
|
112
130
|
|
|
113
131
|
if child.name.downcase == "aside" && !within_article
|
|
132
|
+
@observer&.node_removed(child, pass: :second_pass)
|
|
114
133
|
child.remove
|
|
115
134
|
elsif removable?(child)
|
|
135
|
+
@observer&.node_removed(child, pass: :second_pass)
|
|
116
136
|
child.remove
|
|
117
137
|
else
|
|
118
138
|
strip_junk_attributes(child)
|
|
@@ -0,0 +1,146 @@
|
|
|
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, :strategy
|
|
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
|
+
case strategy
|
|
67
|
+
when :article
|
|
68
|
+
"Verdict: article content located (best score #{best_score}, threshold #{threshold})"
|
|
69
|
+
when :link_list
|
|
70
|
+
"Verdict: no article content (best score #{best_score} < threshold #{threshold}); extracted link list instead"
|
|
71
|
+
else
|
|
72
|
+
"Verdict: NO MAIN CONTENT (best score #{best_score} < threshold #{threshold})"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def removal_summary(pass, label)
|
|
77
|
+
removed = removals[pass]
|
|
78
|
+
return "#{label}: removed 0 nodes" if removed.empty?
|
|
79
|
+
|
|
80
|
+
tally = removed.tally.sort_by { |_tag, count| -count }
|
|
81
|
+
.map { |tag, count| "#{tag} x#{count}" }.join(", ")
|
|
82
|
+
"#{label}: removed #{removed.length} nodes (#{tally})"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def analyze(html)
|
|
87
|
+
@report = Report.new
|
|
88
|
+
|
|
89
|
+
title, content = Cleaner.new(observer: self).extract(html)
|
|
90
|
+
@report.title = title
|
|
91
|
+
|
|
92
|
+
if content
|
|
93
|
+
@report.content_root = descriptor(content)
|
|
94
|
+
@report.content_root_size = content.to_s.length
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@report
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -- Cleaner observer callbacks --
|
|
101
|
+
|
|
102
|
+
def node_removed(node, pass:)
|
|
103
|
+
@report.removals[pass] << node.name.downcase
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Called after the first pass, before content location, with the same
|
|
107
|
+
# scorer the locator will use — so the report matches its decisions.
|
|
108
|
+
def content_pruned(body, scorer)
|
|
109
|
+
scored = ([body] + body.xpath(".//*").to_a)
|
|
110
|
+
.map { |node| [scorer.score(node), node] }
|
|
111
|
+
.sort_by { |score, _node| -score }
|
|
112
|
+
.first(TOP_NODE_COUNT)
|
|
113
|
+
|
|
114
|
+
# Materialize descriptors/previews now; the tree mutates after this.
|
|
115
|
+
@report.top_nodes = scored.each_with_index.map do |(score, node), index|
|
|
116
|
+
breakdown = index < BREAKDOWN_COUNT ? scorer.explain(node) : nil
|
|
117
|
+
ScoredNode.new(score, descriptor(node), preview(node), breakdown)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@report.best_score = scored.first&.first || 0
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def extraction_strategy(strategy)
|
|
124
|
+
@report.strategy = strategy
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def descriptor(node)
|
|
130
|
+
desc = +node.name.downcase
|
|
131
|
+
|
|
132
|
+
id = node['id'].to_s
|
|
133
|
+
desc << "##{id}" unless id.empty?
|
|
134
|
+
|
|
135
|
+
classes = node['class'].to_s.split(" ")
|
|
136
|
+
desc << ".#{classes.first(3).join('.')}" unless classes.empty?
|
|
137
|
+
|
|
138
|
+
desc
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def preview(node)
|
|
142
|
+
text = node.text.gsub(/\s+/, " ").strip
|
|
143
|
+
text.length > 60 ? "#{text[0, 60]}..." : text
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Detergent
|
|
4
|
+
# Fallback extractor for index pages: link aggregators, front pages, and
|
|
5
|
+
# directories (e.g. the Hacker News front page) where the "content" is a
|
|
6
|
+
# list of links to other pages. The article locator correctly rejects
|
|
7
|
+
# these — link density is its strongest navigation signal — so when it
|
|
8
|
+
# finds nothing, this extractor looks for a substantial collection of
|
|
9
|
+
# title-like links and returns them as a synthetic list node that flows
|
|
10
|
+
# through the normal cleaning and rendering pipeline.
|
|
11
|
+
class LinkListExtractor
|
|
12
|
+
# Links with less text than this are treated as chrome ("login",
|
|
13
|
+
# "hide", "42 comments"), not titles.
|
|
14
|
+
MIN_LINK_TEXT_LENGTH = 15
|
|
15
|
+
|
|
16
|
+
# Fewer title-like links than this isn't an index page.
|
|
17
|
+
MIN_LINKS = 10
|
|
18
|
+
|
|
19
|
+
# Returns a synthetic <ul> of the page's title-like links, or nil if
|
|
20
|
+
# the page doesn't look like a link index.
|
|
21
|
+
def extract(body)
|
|
22
|
+
links = title_like_links(body)
|
|
23
|
+
return nil if links.length < MIN_LINKS
|
|
24
|
+
|
|
25
|
+
build_list(body.document, links)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def title_like_links(body)
|
|
31
|
+
seen_hrefs = {}
|
|
32
|
+
|
|
33
|
+
body.css("a").filter_map do |link|
|
|
34
|
+
href = link["href"].to_s
|
|
35
|
+
next if href.empty? || href.start_with?("#", "javascript:")
|
|
36
|
+
|
|
37
|
+
text = link.text.gsub(/\s+/, " ").strip
|
|
38
|
+
next if text.length < MIN_LINK_TEXT_LENGTH
|
|
39
|
+
next if seen_hrefs[href]
|
|
40
|
+
|
|
41
|
+
seen_hrefs[href] = true
|
|
42
|
+
[text, href]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_list(doc, links)
|
|
47
|
+
list = Nokogiri::XML::Node.new("ul", doc)
|
|
48
|
+
|
|
49
|
+
links.each do |text, href|
|
|
50
|
+
item = Nokogiri::XML::Node.new("li", doc)
|
|
51
|
+
anchor = Nokogiri::XML::Node.new("a", doc)
|
|
52
|
+
anchor["href"] = href
|
|
53
|
+
anchor.content = text
|
|
54
|
+
item.add_child(anchor)
|
|
55
|
+
list.add_child(item)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
list
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -9,7 +9,11 @@ module Detergent
|
|
|
9
9
|
# image, ...) so TextRenderer can subclass this and strip the syntax.
|
|
10
10
|
class MarkdownRenderer
|
|
11
11
|
def render(node)
|
|
12
|
-
|
|
12
|
+
# Route through render_block so a root that is itself a block
|
|
13
|
+
# element (a ul, a blockquote) gets its block treatment; generic
|
|
14
|
+
# containers and fragments fall through to render_blocks.
|
|
15
|
+
output = node.element? ? render_block(node) : render_blocks(node)
|
|
16
|
+
output.gsub(/\n{3,}/, "\n\n").strip
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
private
|
|
@@ -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
|
@@ -46,8 +46,10 @@ end
|
|
|
46
46
|
require_relative "detergent/version"
|
|
47
47
|
require_relative "detergent/node_scorer"
|
|
48
48
|
require_relative "detergent/content_locator"
|
|
49
|
+
require_relative "detergent/link_list_extractor"
|
|
49
50
|
require_relative "detergent/markdown_renderer"
|
|
50
51
|
require_relative "detergent/text_renderer"
|
|
51
52
|
require_relative "detergent/matchers/obvious_junk_matcher"
|
|
52
53
|
require_relative "detergent/matchers/removable_node_matcher"
|
|
53
54
|
require_relative "detergent/cleaner"
|
|
55
|
+
require_relative "detergent/inspector"
|
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.4.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,19 @@ 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
|
|
70
|
+
- lib/detergent/link_list_extractor.rb
|
|
67
71
|
- lib/detergent/markdown_renderer.rb
|
|
68
72
|
- lib/detergent/matchers/obvious_junk_matcher.rb
|
|
69
73
|
- lib/detergent/matchers/removable_node_matcher.rb
|