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 +4 -4
- data/README.md +4 -0
- data/lib/detergent/cleaner.rb +12 -0
- data/lib/detergent/content_locator.rb +10 -0
- data/lib/detergent/markdown_renderer.rb +164 -0
- data/lib/detergent/text_renderer.rb +46 -0
- data/lib/detergent/version.rb +1 -1
- data/lib/detergent.rb +13 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e00871e3ab04f35424e5c563328b1e036bf14a10ec9738da79c81462135e963
|
|
4
|
+
data.tar.gz: b600be23e126d45cdfcc8e058398a1096274693be3d985f3051511e718f7c248
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
data/lib/detergent/cleaner.rb
CHANGED
|
@@ -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
|
+
""
|
|
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
|
data/lib/detergent/version.rb
CHANGED
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.
|
|
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:
|