jabbah 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: da38bea94b75acc44485f8bd90544db3358abba97a2644ad5e2c779ee0661eee
4
+ data.tar.gz: 050f44d09b431c091543f8fb0b3bc0b692f4fb2403ead7daddea419b04ee9c4e
5
+ SHA512:
6
+ metadata.gz: b05e83a520b8525b9caf56e000bd778586e2e0a5cfb7ff901857654a77b37f1ec79fa6c85267c1a6b994a9b867d19623e7b1beb8172c0669a6380fd99445abc2
7
+ data.tar.gz: 6f6c4c9c53f110040697dd6382db714dbfc2b61e3d4b8ffdebf647edaaaf73c869aa4df0a3c5caa6fc07455b7d0e1c84adfe18f08ecb9f51a0e04dce9be55a45
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-21
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Jabbah
2
+
3
+ Jabbah (ν Scorpii, from Arabic *jabha*, “forehead”) is a dependency-free
4
+ Ruby HTML parser for static documents and reader applications. It builds a
5
+ tolerant tree, supports a useful CSS selector subset, serializes safely, and
6
+ includes conservative sanitization and article extraction. It never executes
7
+ JavaScript.
8
+
9
+ ## Features
10
+
11
+ - Error-tolerant HTML and fragment parsing with implicit element closing
12
+ - Elements, text, comments, doctypes, attributes, traversal, and cloning
13
+ - Tag, id, class, attribute, descendant/child, and common structural selectors
14
+ - UTF-8, BOM, and declared charset decoding using Ruby's standard library
15
+ - `docs`, `feed`, and `mail` sanitization profiles with XSS-safe URL handling
16
+ - Remote-image blocking with `data-blocked-src`, `cid:` preservation, and callbacks
17
+ - Readability-style article extraction without network access or native extensions
18
+
19
+ ## Installation
20
+
21
+ ```ruby
22
+ gem "jabbah"
23
+ ```
24
+
25
+ ```sh
26
+ gem install jabbah
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ ```ruby
32
+ require "jabbah"
33
+
34
+ document = Jabbah.parse("<article><h1>Hello</h1><p>Welcome.</p></article>")
35
+ puts document.at("article").text
36
+ puts document.to_html
37
+
38
+ safe = Jabbah::Sanitize.clean(document, profile: :feed, base_url: "https://example.test/")
39
+ article = Jabbah::Extract.article(safe)
40
+ puts article[:title] if article
41
+ ```
42
+
43
+ `Jabbah.parse` returns a `Jabbah::Document`; `Jabbah.fragment` parses a
44
+ fragment without adding `html`, `head`, or `body` wrappers. `Document#at`
45
+ returns the first match and `#search` returns all matches.
46
+
47
+ Sanitization returns a cloned document. Remote images are blocked by default;
48
+ the number blocked is available as `Document#blocked_count`. Link targets are
49
+ forced to `_blank` with `noopener noreferrer`.
50
+
51
+ ## Development
52
+
53
+ ```sh
54
+ bundle install
55
+ bundle exec rake test
56
+ ```
57
+
58
+ Jabbah has no runtime dependencies and requires Ruby 3.2 or newer.
59
+
60
+ ## License
61
+
62
+ Jabbah is released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,25 @@
1
+ # ADR 001: Tree and serialization only, no DOM API
2
+
3
+ - Status: accepted
4
+ - Date: 2026-09-21
5
+ - Author: Yudai Takada
6
+
7
+ ## Context
8
+
9
+ Jabbah is shared by reader-mode applications that need to inspect and safely
10
+ render static HTML. A browser-compatible live DOM would require JavaScript,
11
+ mutation observers, layout, and a large API surface that these applications do
12
+ not need.
13
+
14
+ ## Decision
15
+
16
+ Jabbah exposes a snapshot tree of document, element, text, comment, and doctype
17
+ nodes. It provides traversal, CSS selector queries, cloning, and deterministic
18
+ serialization. It does not implement a browser DOM, event dispatch, JavaScript,
19
+ or layout. Sanitization operates on a cloned snapshot and returns a document.
20
+
21
+ ## Consequences
22
+
23
+ The library remains dependency-free and small enough for local documents,
24
+ feeds, and mail. Applications that need live DOM behavior must use a dedicated
25
+ browser engine; that is intentionally outside Jabbah's scope.
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jabbah
4
+ class Document
5
+ attr_reader :encoding, :blocked_count
6
+
7
+ def self.parse(input, encoding: :auto)
8
+ text, detected = EncodingSupport.decode(input, encoding)
9
+ document = Parser.new(text, fragment: false).parse
10
+ document.instance_variable_set(:@encoding, detected)
11
+ document
12
+ end
13
+
14
+ def self.fragment(input, context: "div")
15
+ text, detected = EncodingSupport.decode(input, :auto)
16
+ document = Parser.new(text, fragment: true, context: context).parse
17
+ document.instance_variable_set(:@encoding, detected)
18
+ document
19
+ end
20
+
21
+ def initialize(tree, encoding: Encoding::UTF_8)
22
+ @tree = tree
23
+ @encoding = encoding
24
+ @blocked_count = 0
25
+ end
26
+
27
+ def root
28
+ @tree.children.find(&:element?) || @tree
29
+ end
30
+
31
+ def children = @tree.children
32
+ def head = root.element? ? root.children.find { |node| node.element? && node.name == "head" } : nil
33
+ def body = root.element? ? root.children.find { |node| node.element? && node.name == "body" } : nil
34
+ def title = search("title").first&.text&.strip
35
+ def doctype = @tree.children.find { |node| node.type == :doctype }&.data
36
+ def text = @tree.text
37
+
38
+ def at(selector)
39
+ search(selector).first
40
+ end
41
+
42
+ def search(selector)
43
+ Selector.search(@tree, selector)
44
+ end
45
+
46
+ def each(&block) = @tree.each(&block)
47
+ def to_html(indent: nil) = @tree.to_html(indent: indent)
48
+ def clone = Document.new(@tree.clone, encoding: encoding)
49
+
50
+ def mark_blocked!(count)
51
+ @blocked_count = count
52
+ self
53
+ end
54
+ end
55
+
56
+ module EncodingSupport
57
+ module_function
58
+
59
+ def decode(input, requested)
60
+ bytes = input.to_s.dup
61
+ bytes.force_encoding(Encoding::BINARY)
62
+ if requested != :auto
63
+ encoding = Encoding.find(requested.to_s)
64
+ return [bytes.force_encoding(encoding).encode(Encoding::UTF_8, invalid: :replace, undef: :replace), encoding]
65
+ end
66
+
67
+ if bytes.start_with?("\xEF\xBB\xBF".b)
68
+ return [bytes.byteslice(3..).force_encoding(Encoding::UTF_8).scrub, Encoding::UTF_8]
69
+ end
70
+ if bytes.start_with?("\xFF\xFE".b)
71
+ return [bytes.byteslice(2..).force_encoding(Encoding::UTF_16LE).encode(Encoding::UTF_8, invalid: :replace), Encoding::UTF_16LE]
72
+ end
73
+ if bytes.start_with?("\xFE\xFF".b)
74
+ return [bytes.byteslice(2..).force_encoding(Encoding::UTF_16BE).encode(Encoding::UTF_8, invalid: :replace), Encoding::UTF_16BE]
75
+ end
76
+
77
+ ascii = bytes.byteslice(0, 4096).to_s.force_encoding(Encoding::ASCII_8BIT)
78
+ declared = ascii[/<meta[^>]+charset\s*=\s*["']?\s*([A-Za-z0-9._-]+)/i, 1]
79
+ if declared
80
+ begin
81
+ encoding = Encoding.find(declared)
82
+ return [bytes.force_encoding(encoding).encode(Encoding::UTF_8, invalid: :replace, undef: :replace), encoding]
83
+ rescue ArgumentError
84
+ # Fall through to UTF-8 for unknown declarations.
85
+ end
86
+ end
87
+ [bytes.force_encoding(Encoding::UTF_8).scrub, Encoding::UTF_8]
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jabbah
4
+ module Extract
5
+ module_function
6
+
7
+ POSITIVE = /article|content|entry|main|page|post|story|text|body/i
8
+ NEGATIVE = /ad|advert|comment|footer|header|nav|promo|related|share|sidebar|social|sponsor/i
9
+
10
+ def article(document)
11
+ doc = document.is_a?(Document) ? document : Document.parse(document)
12
+ candidates = doc.search("article, main, section, div, td")
13
+ scored = candidates.filter_map do |node|
14
+ score = score_node(node)
15
+ [score, node] if score >= 1
16
+ end
17
+ best = scored.max_by(&:first)&.last
18
+ return nil unless best
19
+
20
+ title = doc.title || doc.at("h1")&.text&.strip
21
+ byline_node = doc.search("[class], [id]").find { |node| node["class"].to_s.match?(/author|byline/i) || node["id"].to_s.match?(/author|byline/i) }
22
+ excerpt = normalize(best.text)[0, 240]
23
+ {title: title, byline: byline_node&.text&.strip, content: best, excerpt: excerpt}
24
+ end
25
+
26
+ def score_node(node)
27
+ text = normalize(node.text)
28
+ return -100 if text.length < 40
29
+
30
+ paragraphs = node.descendants.count { |child| child.element? && %w[p pre].include?(child.name) }
31
+ return -100 if paragraphs.zero? && node.name == "div"
32
+
33
+ links = node.search("a").sum { |link| normalize(link.text).length }
34
+ density = text.length - [links * 2, text.length].min
35
+ classes = "#{node["id"]} #{node["class"]}"
36
+ score = (density / 100.0) + (paragraphs * 3)
37
+ score += 25 if classes.match?(POSITIVE)
38
+ score -= 35 if classes.match?(NEGATIVE)
39
+ score
40
+ end
41
+
42
+ def normalize(text)
43
+ text.to_s.gsub(/\s+/, " ").strip
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Jabbah
6
+ class Node
7
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
8
+
9
+ attr_accessor :parent
10
+ attr_reader :type, :name, :attributes, :children, :data
11
+
12
+ def initialize(type, name: nil, attributes: {}, data: nil)
13
+ @type = type
14
+ @name = name&.downcase
15
+ @attributes = attributes.each_with_object({}) { |(key, value), result| result[key.to_s.downcase] = value }
16
+ @data = data
17
+ @children = []
18
+ @parent = nil
19
+ end
20
+
21
+ def element? = type == :element
22
+ def text? = type == :text
23
+ def comment? = type == :comment
24
+ def document? = type == :document
25
+
26
+ def [](key)
27
+ return @data if key.to_s == "text" && text?
28
+
29
+ @attributes[key.to_s.downcase]
30
+ end
31
+
32
+ def []=(key, value)
33
+ raise Error, "attributes are only available on elements" unless element?
34
+
35
+ @attributes[key.to_s.downcase] = value
36
+ end
37
+
38
+ def add_child(child)
39
+ child.remove if child.parent
40
+ child.parent = self
41
+ @children << child
42
+ child
43
+ end
44
+
45
+ def prepend_child(child)
46
+ child.remove if child.parent
47
+ child.parent = self
48
+ @children.unshift(child)
49
+ child
50
+ end
51
+
52
+ def remove
53
+ parent&.children&.delete(self)
54
+ @parent = nil
55
+ self
56
+ end
57
+
58
+ def each(&block)
59
+ return enum_for(:each) unless block
60
+
61
+ @children.each(&block)
62
+ end
63
+
64
+ def ancestors
65
+ result = []
66
+ current = parent
67
+ while current
68
+ result << current
69
+ current = current.parent
70
+ end
71
+ result
72
+ end
73
+
74
+ def descendants(&block)
75
+ nodes = []
76
+ walk = lambda do |node|
77
+ node.children.each do |child|
78
+ nodes << child
79
+ walk.call(child)
80
+ end
81
+ end
82
+ walk.call(self)
83
+ return nodes.each(&block) if block
84
+
85
+ nodes.each
86
+ end
87
+
88
+ def at(selector)
89
+ search(selector).first
90
+ end
91
+
92
+ def search(selector)
93
+ Selector.search(self, selector)
94
+ end
95
+
96
+ def text
97
+ return data.to_s if text?
98
+ return "" if %i[comment doctype].include?(type)
99
+
100
+ children.map(&:text).join
101
+ end
102
+
103
+ def clone
104
+ copy = Node.new(type, name: name, attributes: attributes.dup, data: data)
105
+ children.each { |child| copy.add_child(child.clone) }
106
+ copy
107
+ end
108
+
109
+ def to_html(indent: nil, level: 0)
110
+ case type
111
+ when :document
112
+ return render_children(nil, level) unless indent
113
+
114
+ children.map { |child| "#{indent * level}#{child.to_html(indent: indent, level: level)}" }.join("\n")
115
+ when :text
116
+ CGI.escapeHTML(data.to_s)
117
+ when :comment
118
+ "<!--#{data}-->"
119
+ when :doctype
120
+ "<!DOCTYPE #{data || "html"}>"
121
+ when :element
122
+ opening = "<#{name}#{render_attributes}>"
123
+ return opening if VOID_ELEMENTS.include?(name)
124
+ closing = "</#{name}>"
125
+ return "#{opening}#{render_children(nil, level)}#{closing}" unless indent
126
+ return "#{opening}#{closing}" if children.empty?
127
+ return "#{opening}#{render_children(nil, level)}#{closing}" if children.none?(&:element?)
128
+
129
+ body = children.map { |child| "#{indent * (level + 1)}#{child.to_html(indent: indent, level: level + 1)}" }.join("\n")
130
+ "#{opening}\n#{body}\n#{indent * level}#{closing}"
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def render_attributes
137
+ attributes.map do |key, value|
138
+ value.nil? ? " #{key}" : " #{key}=\"#{CGI.escapeHTML(value.to_s)}\""
139
+ end.join
140
+ end
141
+
142
+ def render_children(indent, level)
143
+ children.map { |child| child.to_html(indent: indent, level: level) }.join
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+
5
+ module Jabbah
6
+ class Parser
7
+ BLOCK_ELEMENTS = %w[address article aside blockquote div dl fieldset footer form h1 h2 h3 h4 h5 h6 header hr main nav ol p pre section table ul].freeze
8
+ RAW_ELEMENTS = %w[script style].freeze
9
+
10
+ def initialize(source, fragment:, context: "div")
11
+ @source = source.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
12
+ @fragment = fragment
13
+ @context = context.to_s.downcase
14
+ @document = Node.new(:document)
15
+ @stack = [@document]
16
+ @index = 0
17
+ end
18
+
19
+ def parse
20
+ scan
21
+ normalize_document unless @fragment
22
+ Document.new(@document)
23
+ end
24
+
25
+ private
26
+
27
+ def scan
28
+ while @index < @source.length
29
+ if @source[@index] != "<"
30
+ add_text(read_text)
31
+ elsif @source[@index, 4] == "<!--"
32
+ add_comment
33
+ elsif @source[@index, 9].to_s.downcase == "<!doctype"
34
+ add_doctype
35
+ elsif @source[@index, 9] == "<![CDATA["
36
+ add_cdata
37
+ elsif @source[@index, 2] == "</"
38
+ close_tag
39
+ elsif @source[@index, 2] == "<?"
40
+ skip_processing_instruction
41
+ else
42
+ add_tag
43
+ end
44
+ end
45
+ end
46
+
47
+ def read_text
48
+ finish = @source.index("<", @index) || @source.length
49
+ text = @source[@index...finish]
50
+ @index = finish
51
+ CGI.unescapeHTML(text)
52
+ end
53
+
54
+ def add_text(text)
55
+ return if text.empty?
56
+
57
+ current = @stack.last
58
+ if current.children.last&.text?
59
+ current.children.last.data << text
60
+ else
61
+ current.add_child(Node.new(:text, data: text))
62
+ end
63
+ end
64
+
65
+ def add_comment
66
+ finish = @source.index("-->", @index + 4)
67
+ finish ||= @source.length
68
+ data = @source[(@index + 4)...finish]
69
+ @stack.last.add_child(Node.new(:comment, data: data))
70
+ @index = [finish + 3, @source.length].min
71
+ end
72
+
73
+ def add_doctype
74
+ finish = @source.index(">", @index + 2) || @source.length - 1
75
+ raw = @source[(@index + 2)...finish].strip
76
+ raw = raw.sub(/^doctype\s*/i, "")
77
+ @document.add_child(Node.new(:doctype, data: raw.empty? ? "html" : raw))
78
+ @index = finish + 1
79
+ end
80
+
81
+ def add_cdata
82
+ finish = @source.index("]]>", @index + 9)
83
+ finish ||= @source.length
84
+ add_text(@source[(@index + 9)...finish])
85
+ @index = [finish + 3, @source.length].min
86
+ end
87
+
88
+ def skip_processing_instruction
89
+ finish = @source.index(">", @index + 2) || @source.length - 1
90
+ @index = finish + 1
91
+ end
92
+
93
+ def close_tag
94
+ match = @source[(@index + 2)..].match(/\A\s*([A-Za-z][\w:-]*)[^>]*>/)
95
+ unless match
96
+ add_text("<")
97
+ @index += 1
98
+ return
99
+ end
100
+ name = match[1].downcase
101
+ @index += match[0].length + 2
102
+ position = @stack.rindex { |node| node.element? && node.name == name }
103
+ @stack.slice!(position..-1) if position && position.positive?
104
+ end
105
+
106
+ def add_tag
107
+ match = @source[(@index + 1)..].match(/\A\s*([A-Za-z][\w:-]*)(.*?)(\/?)>/m)
108
+ unless match
109
+ add_text("<")
110
+ @index += 1
111
+ return
112
+ end
113
+ name = match[1].downcase
114
+ attributes = parse_attributes(match[2])
115
+ explicit_close = !match[3].empty?
116
+ close_open_elements(name)
117
+ node = Node.new(:element, name: name, attributes: attributes)
118
+ @stack.last.add_child(node)
119
+ @index += match[0].length + 1
120
+ return if explicit_close || Node::VOID_ELEMENTS.include?(name)
121
+
122
+ if RAW_ELEMENTS.include?(name)
123
+ @stack << node
124
+ end_tag = @source.downcase.index("</#{name}", @index)
125
+ if end_tag
126
+ add_text(@source[@index...end_tag])
127
+ @index = end_tag
128
+ close_tag
129
+ else
130
+ add_text(@source[@index..])
131
+ @index = @source.length
132
+ end
133
+ else
134
+ @stack << node
135
+ end
136
+ end
137
+
138
+ def parse_attributes(source)
139
+ attributes = {}
140
+ index = 0
141
+ while index < source.length
142
+ index += 1 while index < source.length && source[index] =~ /\s/
143
+ break if index >= source.length
144
+ name_match = source[index..].match(/\A([^\s=\/>]+)/)
145
+ break unless name_match
146
+ name = name_match[1].downcase
147
+ index += name_match[0].length
148
+ index += 1 while index < source.length && source[index] =~ /\s/
149
+ value = nil
150
+ if source[index] == "="
151
+ index += 1
152
+ index += 1 while index < source.length && source[index] =~ /\s/
153
+ if ["\"", "'"].include?(source[index])
154
+ quote = source[index]
155
+ index += 1
156
+ finish = source.index(quote, index) || source.length
157
+ value = source[index...finish]
158
+ index = [finish + 1, source.length].min
159
+ else
160
+ finish = source[index..].index(/\s/) || (source.length - index)
161
+ value = source[index, finish]
162
+ index += finish
163
+ end
164
+ end
165
+ attributes[name] ||= value.nil? ? "" : CGI.unescapeHTML(value)
166
+ end
167
+ attributes
168
+ end
169
+
170
+ def close_open_elements(name)
171
+ current = @stack.last
172
+ return unless current.element?
173
+
174
+ if name == "li"
175
+ close_until("li")
176
+ elsif %w[dt dd].include?(name)
177
+ close_until("dt", "dd")
178
+ elsif %w[tr].include?(name)
179
+ close_until("tr")
180
+ elsif %w[td th].include?(name)
181
+ close_until("td", "th")
182
+ elsif name == "option"
183
+ close_until("option")
184
+ elsif @stack.any? { |node| node.element? && node.name == "p" } && (BLOCK_ELEMENTS.include?(name) || name == "p")
185
+ close_until("p")
186
+ elsif current.name == "a" && name == "a"
187
+ close_until("a")
188
+ end
189
+ end
190
+
191
+ def close_until(*names)
192
+ position = @stack.rindex { |node| node.element? && names.include?(node.name) }
193
+ @stack.slice!(position..-1) if position && position.positive?
194
+ end
195
+
196
+ def normalize_document
197
+ html = @document.children.find { |node| node.element? && node.name == "html" }
198
+ unless html
199
+ html = Node.new(:element, name: "html")
200
+ original = @document.children.reject { |node| node.type == :doctype }
201
+ original.each(&:remove)
202
+ @document.add_child(html)
203
+ original.each { |node| html.add_child(node) }
204
+ end
205
+ head = html.children.find { |node| node.element? && node.name == "head" }
206
+ body = html.children.find { |node| node.element? && node.name == "body" }
207
+ unless head
208
+ head = Node.new(:element, name: "head")
209
+ html.prepend_child(head)
210
+ end
211
+ unless body
212
+ body = Node.new(:element, name: "body")
213
+ html.add_child(body)
214
+ end
215
+ # HTML5 places title/meta/link before body; move only obvious head content.
216
+ html.children.reject { |node| node.equal?(head) || node.equal?(body) }.each do |node|
217
+ next unless node.element? && %w[base link meta title style].include?(node.name)
218
+
219
+ node.remove
220
+ head.add_child(node)
221
+ end
222
+ html.children.reject { |node| node.equal?(head) || node.equal?(body) }.each do |node|
223
+ next if node.parent.equal?(body)
224
+
225
+ node.remove
226
+ body.add_child(node)
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Jabbah
6
+ module Sanitize
7
+ DROP_ELEMENTS = %w[base embed form iframe link object script style].freeze
8
+ PROFILES = {
9
+ docs: {allow_style: false, allow_remote_images: false},
10
+ feed: {allow_style: false, allow_remote_images: false},
11
+ mail: {allow_style: false, allow_remote_images: false}
12
+ }.freeze
13
+ DEFAULT = PROFILES[:docs]
14
+ SAFE_ELEMENTS = %w[a abbr article aside b blockquote body br caption cite code col colgroup dd del details div dl dt em figcaption figure h1 h2 h3 h4 h5 h6 head header hr html i img ins kbd li main mark meta nav ol p pre q rp rt ruby s samp section small span strong sub summary sup table tbody td tfoot th thead time title tr u ul var].freeze
15
+ GLOBAL_ATTRIBUTES = %w[aria-label aria-describedby aria-hidden class dir id lang role title].freeze
16
+ URL_ATTRIBUTES = %w[href src cite action poster].freeze
17
+
18
+ module_function
19
+
20
+ def clean(document, profile: :docs, allow: DEFAULT, base_url: nil, on_blocked: nil)
21
+ source = document.is_a?(Document) ? document.clone : Document.parse(document)
22
+ options = profile.is_a?(Hash) ? profile : PROFILES.fetch(profile.to_sym) { DEFAULT }
23
+ allowed = allow.is_a?(Hash) ? allow : DEFAULT
24
+ blocked = 0
25
+ walk = lambda do |node|
26
+ node.children.dup.each do |child|
27
+ if child.element?
28
+ if DROP_ELEMENTS.include?(child.name) || !SAFE_ELEMENTS.include?(child.name)
29
+ child.remove
30
+ next
31
+ end
32
+ if child.name == "meta" && child["http-equiv"].to_s.casecmp("refresh").zero?
33
+ child.remove
34
+ next
35
+ end
36
+ sanitize_attributes(child, options, allowed, base_url) do |url|
37
+ blocked += 1
38
+ on_blocked&.call(url)
39
+ end
40
+ walk.call(child)
41
+ elsif child.comment?
42
+ child.remove
43
+ end
44
+ end
45
+ end
46
+ walk.call(source.instance_variable_get(:@tree))
47
+ source.mark_blocked!(blocked)
48
+ end
49
+
50
+ def sanitize_attributes(node, options, allowed, base_url)
51
+ node.attributes.keys.each do |name|
52
+ value = node[name]
53
+ unless attribute_allowed?(name, node.name, allowed)
54
+ node.attributes.delete(name)
55
+ next
56
+ end
57
+ if name.start_with?("on") || name == "style"
58
+ node.attributes.delete(name)
59
+ next
60
+ end
61
+ if URL_ATTRIBUTES.include?(name)
62
+ url = clean_url(value, base_url: base_url, attribute: name, node: node,
63
+ allow_remote_images: options[:allow_remote_images])
64
+ if url == :blocked_image
65
+ original = value.to_s
66
+ node.attributes.delete(name)
67
+ node["data-blocked-src"] = original
68
+ yield original
69
+ elsif url == :invalid
70
+ node.attributes.delete(name)
71
+ else
72
+ node[name] = url
73
+ end
74
+ end
75
+ end
76
+ if node.name == "a" && node["href"]
77
+ node["target"] = "_blank"
78
+ rel = node["rel"].to_s.split
79
+ node["rel"] = (rel + %w[noopener noreferrer]).uniq.join(" ")
80
+ end
81
+ end
82
+
83
+ def attribute_allowed?(name, _element, allowed)
84
+ return true if GLOBAL_ATTRIBUTES.include?(name) || name.start_with?("aria-") || name.start_with?("data-")
85
+ return true if allowed.is_a?(Hash) && allowed[name]
86
+
87
+ %w[alt charset content height href http-equiv id media name rel role src target type width].include?(name)
88
+ end
89
+
90
+ def clean_url(value, base_url:, attribute:, node:, allow_remote_images: false)
91
+ original = value.to_s.strip.gsub(/[\x00-\x20]/, "")
92
+ return :invalid if original.empty?
93
+ downcased = original.downcase
94
+ if downcased.start_with?("data:")
95
+ return :invalid unless node.name == "img" && original.match?(/\Adata:image\/(?:png|gif|jpe?g|webp);base64,[a-z0-9+\/=]+\z/i)
96
+ return original
97
+ end
98
+ return :invalid if downcased.start_with?("javascript:", "vbscript:", "file:")
99
+ return :invalid if attribute != "src" && downcased.start_with?("cid:")
100
+ return original if downcased.start_with?("cid:")
101
+
102
+ if node.name == "img" && !allow_remote_images && downcased.match?(%r{\A(?:https?:)?//})
103
+ return :blocked_image
104
+ end
105
+ return original unless base_url
106
+
107
+ URI.join(base_url.to_s, original).to_s
108
+ rescue URI::InvalidURIError
109
+ :invalid
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jabbah
4
+ module Selector
5
+ module_function
6
+
7
+ def search(root, expression)
8
+ groups = split_groups(expression.to_s)
9
+ root.descendants.select { |node| node.element? && groups.any? { |group| matches?(node, group) } }
10
+ end
11
+
12
+ def matches?(node, group)
13
+ parts = parse_group(group)
14
+ return false if parts.empty? || !simple_match?(node, parts[-1][0])
15
+
16
+ current = node
17
+ (parts.length - 2).downto(0) do |index|
18
+ simple, combinator = parts[index]
19
+ if combinator == :child
20
+ current = current.parent
21
+ return false unless current&.element? && simple_match?(current, simple)
22
+ else
23
+ current = current.ancestors.find { |ancestor| ancestor.element? && simple_match?(ancestor, simple) }
24
+ return false unless current
25
+ end
26
+ end
27
+ true
28
+ end
29
+
30
+ def split_groups(expression)
31
+ groups = []
32
+ start = 0
33
+ quote = nil
34
+ depth = 0
35
+ expression.each_char.with_index do |char, index|
36
+ if quote
37
+ quote = nil if char == quote
38
+ elsif ["\"", "'"].include?(char)
39
+ quote = char
40
+ elsif char == "[" || char == "("
41
+ depth += 1
42
+ elsif char == "]" || char == ")"
43
+ depth -= 1
44
+ elsif char == "," && depth.zero?
45
+ groups << expression[start...index].strip
46
+ start = index + 1
47
+ end
48
+ end
49
+ groups << expression[start..].to_s.strip
50
+ groups.reject(&:empty?)
51
+ end
52
+
53
+ def parse_group(group)
54
+ simples = []
55
+ combinators = []
56
+ buffer = +""
57
+ quote = nil
58
+ depth = 0
59
+ whitespace = false
60
+ flush = lambda do
61
+ next if buffer.strip.empty?
62
+
63
+ simples << parse_simple(buffer.strip)
64
+ buffer.clear
65
+ if simples.length > 1 && combinators.length < simples.length - 1
66
+ combinators << (whitespace ? :descendant : :child)
67
+ end
68
+ whitespace = false
69
+ end
70
+
71
+ group.each_char do |char|
72
+ if quote
73
+ buffer << char
74
+ quote = nil if char == quote
75
+ elsif ["\"", "'"].include?(char)
76
+ quote = char
77
+ buffer << char
78
+ elsif char == "[" || char == "("
79
+ depth += 1
80
+ buffer << char
81
+ elsif char == "]" || char == ")"
82
+ depth -= 1
83
+ buffer << char
84
+ elsif depth.zero? && char == ">"
85
+ flush.call
86
+ whitespace = false
87
+ combinators << :child if simples.length > combinators.length
88
+ elsif depth.zero? && char.match?(/\s/)
89
+ flush.call
90
+ whitespace = true
91
+ else
92
+ buffer << char
93
+ end
94
+ end
95
+ flush.call
96
+ # The scanner above records explicit child combinators and implicit spaces;
97
+ # normalize the relation list to one relation per adjacent simple selector.
98
+ relations = group_relations(group, simples.length)
99
+ simples.each_with_index.map { |simple, index| [simple, relations[index]] }
100
+ end
101
+
102
+ def group_relations(group, count)
103
+ return [] if count < 2
104
+
105
+ relations = []
106
+ quote = nil
107
+ depth = 0
108
+ pending_space = false
109
+ group.each_char do |char|
110
+ if quote
111
+ quote = nil if char == quote
112
+ elsif ["\"", "'"].include?(char)
113
+ quote = char
114
+ elsif char == "[" || char == "("
115
+ depth += 1
116
+ elsif char == "]" || char == ")"
117
+ depth -= 1
118
+ elsif depth.zero? && char.match?(/\s/)
119
+ pending_space = true
120
+ elsif depth.zero? && char == ">"
121
+ relations << :child
122
+ pending_space = false
123
+ elsif depth.zero? && !char.match?(/\s/)
124
+ if pending_space && relations.length < count - 1
125
+ relations << :descendant unless relations.last == :child
126
+ end
127
+ pending_space = false
128
+ end
129
+ end
130
+ relations.fill(:descendant, relations.length...count - 1)
131
+ end
132
+
133
+ def parse_simple(source)
134
+ result = {tag: nil, ids: [], classes: [], attrs: [], pseudos: []}
135
+ rest = source.dup
136
+ if (tag = rest[/\A(?:[A-Za-z][\w-]*|\*)/])
137
+ result[:tag] = tag.downcase unless tag == "*"
138
+ rest = rest[tag.length..]
139
+ end
140
+ until rest.empty?
141
+ case rest[0]
142
+ when "#", "."
143
+ marker = rest[0]
144
+ match = rest[1..].match(/\A[\w:-]+/)
145
+ break unless match
146
+ result[marker == "#" ? :ids : :classes] << match[0]
147
+ rest = rest[match[0].length + 1..]
148
+ when "["
149
+ finish = matching_bracket(rest, "[", "]")
150
+ break unless finish
151
+ content = rest[1...finish].strip
152
+ if (match = content.match(/\A([^\s~|^$*!=]+)\s*(?:(\^=|\$=|\*=|~=|\|=|!=|=)\s*["']?(.*?)["']?)?\z/))
153
+ result[:attrs] << [match[1].downcase, match[2], match[3]&.strip]
154
+ end
155
+ rest = rest[(finish + 1)..]
156
+ when ":"
157
+ match = rest.match(/\A:([\w-]+)(?:\((.*?)\))?/)
158
+ break unless match
159
+ result[:pseudos] << [match[1].downcase, match[2]]
160
+ rest = rest[match[0].length..]
161
+ else
162
+ rest = rest[1..]
163
+ end
164
+ end
165
+ result
166
+ end
167
+
168
+ def matching_bracket(text, opening, closing)
169
+ depth = 0
170
+ quote = nil
171
+ text.each_char.with_index do |char, index|
172
+ if quote
173
+ quote = nil if char == quote
174
+ elsif ["\"", "'"].include?(char)
175
+ quote = char
176
+ elsif char == opening
177
+ depth += 1
178
+ elsif char == closing
179
+ depth -= 1
180
+ return index if depth.zero?
181
+ end
182
+ end
183
+ nil
184
+ end
185
+
186
+ def simple_match?(node, simple)
187
+ return false unless node.element?
188
+ return false if simple[:tag] && node.name != simple[:tag]
189
+ return false unless simple[:ids].all? { |id| node["id"] == id }
190
+ classes = node["class"].to_s.split
191
+ return false unless simple[:classes].all? { |name| classes.include?(name) }
192
+ return false unless simple[:attrs].all? { |attribute| attribute_match?(node, attribute) }
193
+ simple[:pseudos].all? { |pseudo, value| pseudo_match?(node, pseudo, value) }
194
+ end
195
+
196
+ def attribute_match?(node, (name, operator, expected))
197
+ actual = node[name]
198
+ return !actual.nil? if operator.nil?
199
+ return false if actual.nil?
200
+
201
+ case operator
202
+ when "=" then actual == expected
203
+ when "!=" then actual != expected
204
+ when "^=" then actual.start_with?(expected.to_s)
205
+ when "$=" then actual.end_with?(expected.to_s)
206
+ when "*=" then actual.include?(expected.to_s)
207
+ when "~=" then actual.split.include?(expected.to_s)
208
+ when "|=" then actual == expected || actual.start_with?("#{expected}-")
209
+ else false
210
+ end
211
+ end
212
+
213
+ def pseudo_match?(node, pseudo, value)
214
+ siblings = node.parent&.children&.select(&:element?) || []
215
+ index = siblings.index(node)
216
+ case pseudo
217
+ when "first-child" then index == 0
218
+ when "last-child" then index == siblings.length - 1
219
+ when "only-child" then siblings.length == 1
220
+ when "root" then node.name == "html"
221
+ when "empty" then node.children.none? { |child| child.element? || (child.text? && !child.data.empty?) }
222
+ when "nth-child" then nth?(index.to_i + 1, value)
223
+ when "not" then !simple_match?(node, parse_simple(value.to_s))
224
+ else false
225
+ end
226
+ end
227
+
228
+ def nth?(index, expression)
229
+ value = expression.to_s.strip.downcase
230
+ return index == value.to_i if value.match?(/\A\d+\z/)
231
+ return index.odd? if value == "odd"
232
+ return index.even? if value == "even"
233
+ if (match = value.match(/\A([+-]?\d*)n(?:\s*([+-])\s*(\d+))?\z/))
234
+ coefficient = match[1].empty? || match[1] == "+" ? 1 : match[1] == "-" ? -1 : match[1].to_i
235
+ offset = match[3].to_i * (match[2] == "-" ? -1 : 1)
236
+ return (index - offset) * coefficient >= 0 && (index - offset) % coefficient.zero?
237
+ end
238
+ false
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jabbah
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jabbah.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jabbah/version"
4
+ require_relative "jabbah/node"
5
+ require_relative "jabbah/document"
6
+ require_relative "jabbah/parser"
7
+ require_relative "jabbah/selector"
8
+ require_relative "jabbah/sanitize"
9
+ require_relative "jabbah/extract"
10
+
11
+ module Jabbah
12
+ class Error < StandardError; end
13
+
14
+ def self.parse(input, encoding: :auto)
15
+ Document.parse(input, encoding: encoding)
16
+ end
17
+
18
+ def self.fragment(input, context: "div")
19
+ Document.fragment(input, context: context)
20
+ end
21
+ end
data/sig/jabbah.rbs ADDED
@@ -0,0 +1,62 @@
1
+ module Jabbah
2
+ VERSION: String
3
+ def self.parse: (String input, ?encoding: Symbol | String) -> Document
4
+ def self.fragment: (String input, ?context: String) -> Document
5
+
6
+ class Error < StandardError
7
+ end
8
+
9
+ class Node
10
+ attr_accessor parent: Node?
11
+ attr_reader type: Symbol
12
+ attr_reader name: String?
13
+ attr_reader attributes: Hash[String, String?]
14
+ attr_reader children: Array[Node]
15
+ attr_reader data: String?
16
+ def initialize: (Symbol type, ?name: String?, ?attributes: Hash[String, String?], ?data: String?) -> void
17
+ def element?: () -> bool
18
+ def text?: () -> bool
19
+ def comment?: () -> bool
20
+ def document?: () -> bool
21
+ def []: (String | Symbol key) -> untyped
22
+ def []=: (String | Symbol key, String? value) -> untyped
23
+ def add_child: (Node child) -> Node
24
+ def remove: () -> Node
25
+ def each: () { (Node) -> void } -> Array[Node]
26
+ def ancestors: () -> Array[Node]
27
+ def descendants: () -> Enumerator[Node, void]
28
+ def at: (String selector) -> Node?
29
+ def search: (String selector) -> Array[Node]
30
+ def text: () -> String
31
+ def clone: () -> Node
32
+ def to_html: (?indent: String?) -> String
33
+ end
34
+
35
+ class Document
36
+ attr_reader encoding: Encoding
37
+ attr_reader blocked_count: Integer
38
+ def self.parse: (String input, ?encoding: Symbol | String) -> Document
39
+ def self.fragment: (String input, ?context: String) -> Document
40
+ def root: () -> Node
41
+ def head: () -> Node?
42
+ def body: () -> Node?
43
+ def title: () -> String?
44
+ def doctype: () -> String?
45
+ def text: () -> String
46
+ def at: (String selector) -> Node?
47
+ def search: (String selector) -> Array[Node]
48
+ def each: () { (Node) -> void } -> Array[Node]
49
+ def to_html: (?indent: String?) -> String
50
+ def clone: () -> Document
51
+ end
52
+
53
+ module Sanitize
54
+ DEFAULT: Hash[Symbol, bool]
55
+ PROFILES: Hash[Symbol, Hash[Symbol, bool]]
56
+ def self.clean: (Document | String document, ?profile: Symbol | Hash[Symbol, bool], ?allow: Hash[Symbol, bool], ?base_url: String?, ?on_blocked: Proc) -> Document
57
+ end
58
+
59
+ module Extract
60
+ def self.article: (Document | String document) -> Hash[Symbol, untyped]?
61
+ end
62
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jabbah
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Jabbah parses malformed HTML into a traversable tree, supports selectors
13
+ and serialization, and provides sanitization and article extraction.
14
+ email:
15
+ - t.yudai92@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - docs/adr/001-tree-and-serialization.md
24
+ - lib/jabbah.rb
25
+ - lib/jabbah/document.rb
26
+ - lib/jabbah/extract.rb
27
+ - lib/jabbah/node.rb
28
+ - lib/jabbah/parser.rb
29
+ - lib/jabbah/sanitize.rb
30
+ - lib/jabbah/selector.rb
31
+ - lib/jabbah/version.rb
32
+ - sig/jabbah.rbs
33
+ homepage: https://github.com/noxdea/jabbah
34
+ licenses:
35
+ - MIT
36
+ metadata:
37
+ allowed_push_host: https://rubygems.org
38
+ homepage_uri: https://github.com/noxdea/jabbah
39
+ source_code_uri: https://github.com/noxdea/jabbah/tree/main
40
+ changelog_uri: https://github.com/noxdea/jabbah/blob/main/CHANGELOG.md
41
+ rubygems_mfa_required: 'true'
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 3.2.0
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 4.0.16
57
+ specification_version: 4
58
+ summary: A tolerant, secure, dependency-free Ruby HTML parser
59
+ test_files: []