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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e00871e3ab04f35424e5c563328b1e036bf14a10ec9738da79c81462135e963
4
- data.tar.gz: b600be23e126d45cdfcc8e058398a1096274693be3d985f3051511e718f7c248
3
+ metadata.gz: fe5c0dc45668a4c14b991433606aec83c6fd2672e93533632d608e66b858f03a
4
+ data.tar.gz: eada52c178f26669036609d5431f85245cd06d5de6b3920b010ba91a587ba18c
5
5
  SHA512:
6
- metadata.gz: 5263b607e0e15a25b15ab1df4c846f5228a7c188daf6a78811a67e91bf06fdc46b2b9e188fc4158657223e8665df5de894e3b1a6611be5886ab2489c7d63210c
7
- data.tar.gz: '0884b42ce893b34ff696234b0d094d9a0a8613c5d9eee3b979308c9214eb4a0b67b5247a9b7cb33e0af9e9320922995dd234c5844e0e3169e899da4e6ab687fe'
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
@@ -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
- def initialize
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
- content = ContentLocator.new(NodeScorer.new).locate(body)
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
- render_blocks(node).gsub(/\n{3,}/, "\n\n").strip
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
- @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.4.0"
5
5
  end
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.2.0
4
+ version: 2.4.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,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