detergent 2.0.0 → 2.2.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: 492c13c265f4d55f83ea2d4e32352ebf0209abf4bf15524ad291d352a7ca56ff
4
- data.tar.gz: cd5fcadafd69fb5d43e9db36e7b3f2aff3516d792098a982584fb69eab91b2ba
3
+ metadata.gz: 1e00871e3ab04f35424e5c563328b1e036bf14a10ec9738da79c81462135e963
4
+ data.tar.gz: b600be23e126d45cdfcc8e058398a1096274693be3d985f3051511e718f7c248
5
5
  SHA512:
6
- metadata.gz: 1135a0396b72e5f1c3892befd5f9930d5a8193ca9ffd36d42b5d431e7824d0e2a62b2a2a8afbd1084942780257d6651dc066a3efd18995fed81b53b6d3b3cf8c
7
- data.tar.gz: e9809a3df3521cb9cd78c8c9b54f590de968ca84693454feb0e64e62137b0f1aaecc6df7877fed74f19251ece1ccf415328e68f60e92e8569ce52167d920c881
6
+ metadata.gz: 5263b607e0e15a25b15ab1df4c846f5228a7c188daf6a78811a67e91bf06fdc46b2b9e188fc4158657223e8665df5de894e3b1a6611be5886ab2489c7d63210c
7
+ data.tar.gz: '0884b42ce893b34ff696234b0d094d9a0a8613c5d9eee3b979308c9214eb4a0b67b5247a9b7cb33e0af9e9320922995dd234c5844e0e3169e899da4e6ab687fe'
data/README.md CHANGED
@@ -21,6 +21,10 @@ clean_html = Detergent.clean(dirty_html)
21
21
  # Or the title and the extracted content node (a Nokogiri node):
22
22
  title, content = Detergent.extract(dirty_html)
23
23
 
24
+ # Or the extracted main content as Markdown or plain text:
25
+ markdown = Detergent.markdown(dirty_html)
26
+ text = Detergent.text(dirty_html)
27
+
24
28
  # A Cleaner instance is reusable if you're processing many pages:
25
29
  cleaner = Detergent::Cleaner.new
26
30
  clean_html = cleaner.clean(dirty_html)
@@ -62,6 +62,18 @@ module Detergent
62
62
  [title, content]
63
63
  end
64
64
 
65
+ # Returns the extracted main content as Markdown.
66
+ def markdown(html)
67
+ _, content = extract(html)
68
+ content ? MarkdownRenderer.new.render(content) : ""
69
+ end
70
+
71
+ # Returns the extracted main content as plain text.
72
+ def text(html)
73
+ _, content = extract(html)
74
+ content ? TextRenderer.new.render(content) : ""
75
+ end
76
+
65
77
  def title(html)
66
78
  extract_title(Nokogiri::HTML5(html))
67
79
  end
@@ -11,13 +11,23 @@ module Detergent
11
11
  # promoted — not just score marginally higher from including the child.
12
12
  PARENT_PROMOTION_RATIO = 1.3
13
13
 
14
+ # If even the best-scoring node falls below this, the page has no main
15
+ # content to extract. Pages made entirely of links (index pages, link
16
+ # aggregators like the Hacker News front page) score at or near zero
17
+ # everywhere, and without a floor a degenerate winner gets elected —
18
+ # e.g. a logo cell that picked up a few points for containing an image.
19
+ MINIMUM_CONTENT_SCORE = 25
20
+
14
21
  def initialize(scorer)
15
22
  @scorer = scorer
16
23
  end
17
24
 
25
+ # Returns the main content root node, or nil if the page doesn't
26
+ # appear to have main content at all.
18
27
  def locate(body)
19
28
  highest_scoring = find_highest_scoring_node(body)
20
29
  return nil unless highest_scoring
30
+ return nil if score(highest_scoring) < MINIMUM_CONTENT_SCORE
21
31
 
22
32
  find_content_root(highest_scoring)
23
33
  end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Detergent
4
+ # Renders a cleaned content node as Markdown. Expects the article-shaped
5
+ # markup that comes out of extraction; it is not a general-purpose
6
+ # HTML-to-Markdown converter.
7
+ #
8
+ # The formatting decisions live in small hook methods (heading, link,
9
+ # image, ...) so TextRenderer can subclass this and strip the syntax.
10
+ class MarkdownRenderer
11
+ def render(node)
12
+ render_blocks(node).gsub(/\n{3,}/, "\n\n").strip
13
+ end
14
+
15
+ private
16
+
17
+ INLINE_TAGS = %w[a strong b em i code span img br sub sup small u s
18
+ strike mark abbr time].freeze
19
+
20
+ def inline?(node)
21
+ node.text? || (node.element? && INLINE_TAGS.include?(node.name.downcase))
22
+ end
23
+
24
+ # Walk a container's children, gathering runs of inline content into
25
+ # paragraphs and rendering block elements between them.
26
+ def render_blocks(node)
27
+ out = []
28
+ buffer = +""
29
+
30
+ node.children.each do |child|
31
+ if inline?(child)
32
+ buffer << inline(child)
33
+ elsif child.element?
34
+ out << buffer.strip unless buffer.strip.empty?
35
+ buffer = +""
36
+
37
+ block = render_block(child)
38
+ out << block unless block.empty?
39
+ end
40
+ end
41
+
42
+ out << buffer.strip unless buffer.strip.empty?
43
+ out.join("\n\n")
44
+ end
45
+
46
+ def render_block(node)
47
+ tag = node.name.downcase
48
+
49
+ case tag
50
+ when /\Ah([1-6])\z/
51
+ heading(Regexp.last_match(1).to_i, inline_content(node).strip)
52
+ when 'p'
53
+ inline_content(node).strip
54
+ when 'blockquote'
55
+ blockquote(render_blocks(node))
56
+ when 'ul'
57
+ list(node, ordered: false)
58
+ when 'ol'
59
+ list(node, ordered: true)
60
+ when 'pre'
61
+ preformatted(node.text.rstrip)
62
+ when 'hr'
63
+ rule
64
+ when 'figcaption'
65
+ caption(inline_content(node).strip)
66
+ when 'table'
67
+ table(node)
68
+ else
69
+ # Generic container (div, section, article, figure, ...)
70
+ render_blocks(node)
71
+ end
72
+ end
73
+
74
+ def inline(node)
75
+ return node.text.gsub(/\s+/, " ") if node.text?
76
+
77
+ case node.name.downcase
78
+ when 'br' then "\n"
79
+ when 'img' then image(node)
80
+ when 'a' then link(node)
81
+ when 'strong', 'b' then wrap(node, "**")
82
+ when 'em', 'i' then wrap(node, "*")
83
+ when 'code' then code_span(node)
84
+ else inline_content(node)
85
+ end
86
+ end
87
+
88
+ def inline_content(node)
89
+ node.children.map { |child| inline(child) }.join
90
+ end
91
+
92
+ def list(node, ordered:)
93
+ items = node.children.select { |c| c.element? && c.name.downcase == 'li' }
94
+
95
+ items.each_with_index.map do |li, index|
96
+ marker = ordered ? "#{index + 1}. " : "- "
97
+ indent = " " * marker.length
98
+ marker + render_blocks(li).gsub("\n", "\n#{indent}")
99
+ end.join("\n")
100
+ end
101
+
102
+ def table(node)
103
+ rows = node.xpath('.//tr')
104
+ return "" if rows.empty?
105
+
106
+ lines = rows.map do |row|
107
+ cells = row.xpath('./th | ./td').map { |cell| inline_content(cell).strip }
108
+ "| #{cells.join(' | ')} |"
109
+ end
110
+
111
+ column_count = rows.first.xpath('./th | ./td').length
112
+ lines.insert(1, "|#{' --- |' * column_count}")
113
+ lines.join("\n")
114
+ end
115
+
116
+ # -- Formatting hooks, overridden by TextRenderer --
117
+
118
+ def heading(level, text)
119
+ "#{'#' * level} #{text}"
120
+ end
121
+
122
+ def blockquote(content)
123
+ content.split("\n").map { |line| line.empty? ? ">" : "> #{line}" }.join("\n")
124
+ end
125
+
126
+ def preformatted(text)
127
+ "```\n#{text}\n```"
128
+ end
129
+
130
+ def rule
131
+ "---"
132
+ end
133
+
134
+ def caption(text)
135
+ text.empty? ? "" : "*#{text}*"
136
+ end
137
+
138
+ def link(node)
139
+ text = inline_content(node).strip
140
+ href = node['href'].to_s
141
+ return text if href.empty? || text.empty?
142
+
143
+ "[#{text}](#{href})"
144
+ end
145
+
146
+ def image(node)
147
+ src = node['src'].to_s
148
+ return "" if src.empty?
149
+
150
+ alt = node['alt'].to_s.gsub(/\s+/, " ").strip
151
+ "![#{alt}](#{src})"
152
+ end
153
+
154
+ def wrap(node, marker)
155
+ content = inline_content(node).strip
156
+ content.empty? ? "" : "#{marker}#{content}#{marker}"
157
+ end
158
+
159
+ def code_span(node)
160
+ content = node.text.strip
161
+ content.empty? ? "" : "`#{content}`"
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Detergent
4
+ # Renders a cleaned content node as plain text: the same document
5
+ # structure as MarkdownRenderer (blank-line paragraphs, list bullets)
6
+ # with all Markdown syntax stripped. Images are dropped entirely.
7
+ class TextRenderer < MarkdownRenderer
8
+ private
9
+
10
+ def heading(_level, text)
11
+ text
12
+ end
13
+
14
+ def blockquote(content)
15
+ content
16
+ end
17
+
18
+ def preformatted(text)
19
+ text
20
+ end
21
+
22
+ def rule
23
+ ""
24
+ end
25
+
26
+ def caption(text)
27
+ text
28
+ end
29
+
30
+ def link(node)
31
+ inline_content(node).strip
32
+ end
33
+
34
+ def image(_node)
35
+ ""
36
+ end
37
+
38
+ def wrap(node, _marker)
39
+ inline_content(node).strip
40
+ end
41
+
42
+ def code_span(node)
43
+ node.text.strip
44
+ end
45
+ end
46
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Detergent
4
- VERSION = "2.0.0"
4
+ VERSION = "2.2.0"
5
5
  end
data/lib/detergent.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "cgi"
3
+ require "cgi/escape"
4
4
  require "nokogiri"
5
5
 
6
6
  module Detergent
@@ -31,11 +31,23 @@ module Detergent
31
31
  def self.extract(html)
32
32
  Cleaner.new.extract(html)
33
33
  end
34
+
35
+ # Returns the extracted main content as Markdown.
36
+ def self.markdown(html)
37
+ Cleaner.new.markdown(html)
38
+ end
39
+
40
+ # Returns the extracted main content as plain text.
41
+ def self.text(html)
42
+ Cleaner.new.text(html)
43
+ end
34
44
  end
35
45
 
36
46
  require_relative "detergent/version"
37
47
  require_relative "detergent/node_scorer"
38
48
  require_relative "detergent/content_locator"
49
+ require_relative "detergent/markdown_renderer"
50
+ require_relative "detergent/text_renderer"
39
51
  require_relative "detergent/matchers/obvious_junk_matcher"
40
52
  require_relative "detergent/matchers/removable_node_matcher"
41
53
  require_relative "detergent/cleaner"
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: 2.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff McFadden
@@ -64,9 +64,11 @@ files:
64
64
  - lib/detergent.rb
65
65
  - lib/detergent/cleaner.rb
66
66
  - lib/detergent/content_locator.rb
67
+ - lib/detergent/markdown_renderer.rb
67
68
  - lib/detergent/matchers/obvious_junk_matcher.rb
68
69
  - lib/detergent/matchers/removable_node_matcher.rb
69
70
  - lib/detergent/node_scorer.rb
71
+ - lib/detergent/text_renderer.rb
70
72
  - lib/detergent/version.rb
71
73
  homepage: https://github.com/jeffmcfadden/detergent
72
74
  licenses: