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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +62 -0
- data/docs/adr/001-tree-and-serialization.md +25 -0
- data/lib/jabbah/document.rb +90 -0
- data/lib/jabbah/extract.rb +46 -0
- data/lib/jabbah/node.rb +146 -0
- data/lib/jabbah/parser.rb +230 -0
- data/lib/jabbah/sanitize.rb +112 -0
- data/lib/jabbah/selector.rb +241 -0
- data/lib/jabbah/version.rb +5 -0
- data/lib/jabbah.rb +21 -0
- data/sig/jabbah.rbs +62 -0
- metadata +59 -0
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
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
|
data/lib/jabbah/node.rb
ADDED
|
@@ -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
|
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: []
|