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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e00871e3ab04f35424e5c563328b1e036bf14a10ec9738da79c81462135e963
4
- data.tar.gz: b600be23e126d45cdfcc8e058398a1096274693be3d985f3051511e718f7c248
3
+ metadata.gz: 27c1381c7afa9541cb1285a2f31c418d9c1d392fd5990abb7c1ee4a5ef630f24
4
+ data.tar.gz: 4935ce56bfef6771ab9cdbd2a431d9931ab374df7e4dffa876c75f95ee3c671c
5
5
  SHA512:
6
- metadata.gz: 5263b607e0e15a25b15ab1df4c846f5228a7c188daf6a78811a67e91bf06fdc46b2b9e188fc4158657223e8665df5de894e3b1a6611be5886ab2489c7d63210c
7
- data.tar.gz: '0884b42ce893b34ff696234b0d094d9a0a8613c5d9eee3b979308c9214eb4a0b67b5247a9b7cb33e0af9e9320922995dd234c5844e0e3169e899da4e6ab687fe'
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
@@ -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
- def initialize
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
- content = ContentLocator.new(NodeScorer.new).locate(body)
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
- @scores[node] = compute_score(node)
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 compute_score(node)
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
- 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'
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
- score += stats[:paragraphs] * POINTS_PER_PARAGRAPH
55
- score += stats[:text_length] / CHARS_PER_TEXT_POINT
56
- score += stats[:long_paragraphs] * LONG_PARAGRAPH_BONUS
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
- score -= (link_density * LINK_DENSITY_PENALTY).to_i
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
- score += stats[:media] * POINTS_PER_MEDIA
66
- score += stats[:blockquotes] * POINTS_PER_BLOCKQUOTE
67
- score += stats[:lists] * POINTS_PER_LIST
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
- 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')
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
- [score, 0].max # Don't return negative scores
94
+ parts
79
95
  end
80
96
 
81
97
  # Statistics about a node's descendants (not the node itself),
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Detergent
4
- VERSION = "2.2.0"
4
+ VERSION = "2.3.0"
5
5
  end
data/lib/detergent.rb CHANGED
@@ -51,3 +51,4 @@ require_relative "detergent/text_renderer"
51
51
  require_relative "detergent/matchers/obvious_junk_matcher"
52
52
  require_relative "detergent/matchers/removable_node_matcher"
53
53
  require_relative "detergent/cleaner"
54
+ 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.2.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff McFadden
8
- bindir: bin
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