detergent 2.0.0 → 2.1.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/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: 1e2a63a0bf0f9524e9d7e4c1b873d2cb0eab8e29873bad005c1545501fa9915a
|
|
4
|
+
data.tar.gz: 03f8f53ce63ffa717b8b5d0981d92a02b307a466393ac9e37299d66e18dcdda8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5963938b39385d4935e6080ce5b4256996f352d8582976edf8b7a95f73fbe5a896bca8e282dcdfce4b62768e4488ddef0f354706bacab04f9cb69d9ea7120b3c
|
|
7
|
+
data.tar.gz: 17391d9c496d0fe55dff25f68fd85e63c2ed3b391db05c1c714c6cce2250c457dc48a5b8f0b2d4a5cf3de19295317cd3eeba9b9b29d83547e990cec2cdf577c5
|
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
|
|
@@ -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.1.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:
|