nokodown 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.
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ # Readability's paragraph scoring, run without rewriting the DOM: paragraphs
5
+ # push score up to their ancestors, the best ancestor wins, and siblings that
6
+ # look like part of the same article are merged back in.
7
+ class Scorer
8
+ Result = Struct.new(:top, :nodes, :score)
9
+
10
+ SCORED = Set.new(%w[p td pre section h2 h3 h4 h5 h6]).freeze
11
+ PARAGRAPH_MIN = 25
12
+ TOP_CANDIDATES = 5
13
+ COMMAS = /[,\u060C\uFE50\uFE10\uFE11\u2E41\u2E34\u2E32\uFF0C]/.freeze
14
+
15
+ def self.class_weight(node)
16
+ [node["class"], node["id"]].sum { |value| words_weight(value.to_s.downcase) }
17
+ end
18
+
19
+ # Conditional cleaning deletes whatever weighs negative, so it ignores CSS
20
+ # utility tokens. Candidate scoring keeps Readability's weight, which some
21
+ # pages need to pick the right candidate.
22
+ def self.cleaning_weight(node)
23
+ [node["class"], node["id"]].sum do |value|
24
+ tokens = value.to_s.downcase.split.reject { |token| utility_class?(token) }
25
+ words_weight(tokens.join(" "))
26
+ end
27
+ end
28
+
29
+ # "lg:overflow-hidden" is judged without its variant prefix.
30
+ def self.utility_class?(token)
31
+ token.rpartition(":").last.match?(Patterns::UTILITY_CLASS)
32
+ end
33
+
34
+ def self.words_weight(value)
35
+ return 0 if value.empty?
36
+
37
+ (value.match?(Patterns::POSITIVE) ? 25 : 0) - (value.match?(Patterns::NEGATIVE) ? 25 : 0)
38
+ end
39
+ private_class_method :utility_class?, :words_weight
40
+
41
+ def initialize(root, stats)
42
+ @root = root
43
+ @root_key = Dom.key(root)
44
+ @stats = stats
45
+ @scores = {}
46
+ @nodes = {}
47
+ end
48
+
49
+ def call
50
+ @paragraph_keys = Dom.paragraph_keys(@root)
51
+ score_paragraphs
52
+ return if @scores.empty?
53
+
54
+ @scores.each_key { |key| @scores[key] *= 1 - @stats.link_density(@nodes[key]) }
55
+ ranked = @scores.keys.max_by(TOP_CANDIDATES) { |key| @scores[key] }.map { |key| @nodes[key] }
56
+ top = climb(shared_ancestor(ranked) || ranked.first)
57
+ Result.new(top, merge_siblings(top), score(top) || 0.0)
58
+ end
59
+
60
+ private
61
+
62
+ def paragraph?(node)
63
+ @paragraph_keys.include?(Dom.key(node))
64
+ end
65
+
66
+ def score(node)
67
+ @scores[Dom.key(node)]
68
+ end
69
+
70
+ def score_paragraphs
71
+ ignored = secondary_keys
72
+ @root.traverse do |node|
73
+ next unless node.element?
74
+ next if ignored.include?(Dom.key(node))
75
+
76
+ if SCORED.include?(node.name) || paragraph?(node)
77
+ add_score(Dom.text(node), scoring_parent(node))
78
+ elsif node.name == "div"
79
+ phrasing_runs(node).each { |text| add_score(text, node) }
80
+ end
81
+ end
82
+ end
83
+
84
+ # Comment threads are prose too, and on news sites they often outscore the
85
+ # article. A thread holding most of the page (a forum) still counts.
86
+ def secondary_keys
87
+ limit = @stats.text_chars(@root) * 0.5
88
+ regions = @root.css("*").select do |node|
89
+ Dom.signature(node).match?(Patterns::SECONDARY) && @stats.text_chars(node) < limit
90
+ end
91
+ regions.flat_map { |region| [region] + region.css("*").to_a }.map { |node| Dom.key(node) }.to_set
92
+ end
93
+
94
+ # A div wrapping a single paragraph is that paragraph, as far as scoring goes.
95
+ def scoring_parent(node)
96
+ parent = node.parent
97
+ return parent unless node.name == "p" && parent&.name == "div" && parent.element_children.size == 1
98
+ return parent unless parent.children.all? { |child| child.element? || child.content.strip.empty? }
99
+
100
+ parent.parent
101
+ end
102
+
103
+ # Text sitting directly in a div between block children counts as paragraphs.
104
+ def phrasing_runs(div)
105
+ runs = [+""]
106
+ div.children.each do |child|
107
+ if child.element? && (Dom::PARAGRAPH_BREAKERS.include?(child.name) || Dom.block?(child))
108
+ runs << +""
109
+ else
110
+ runs.last << child.content
111
+ end
112
+ end
113
+ runs.map { |run| Dom.squish(run) }.reject(&:empty?)
114
+ end
115
+
116
+ def add_score(text, parent)
117
+ return if text.length < PARAGRAPH_MIN || parent.nil? || !within_root?(parent)
118
+
119
+ points = 1 + text.split(COMMAS, -1).size + [text.length / 100, 3].min
120
+ ancestors_from(parent).each_with_index do |ancestor, level|
121
+ key = Dom.key(ancestor)
122
+ @nodes[key] ||= ancestor
123
+ @scores[key] ||= initial_score(ancestor)
124
+ @scores[key] += points.to_f / divider(level)
125
+ end
126
+ end
127
+
128
+ def divider(level)
129
+ case level
130
+ when 0 then 1
131
+ when 1 then 2
132
+ else level * 3
133
+ end
134
+ end
135
+
136
+ def ancestors_from(parent)
137
+ list = []
138
+ current = parent
139
+ while current&.element? && list.size < 5
140
+ list << current
141
+ break if Dom.key(current) == @root_key
142
+
143
+ current = current.parent
144
+ end
145
+ list
146
+ end
147
+
148
+ def within_root?(node)
149
+ Dom.key(node) == @root_key || node.ancestors.any? { |ancestor| ancestor.element? && Dom.key(ancestor) == @root_key }
150
+ end
151
+
152
+ def initial_score(node)
153
+ self.class.class_weight(node) +
154
+ case node.name
155
+ when "div" then 5
156
+ when "pre", "td", "blockquote" then 3
157
+ when "address", "ol", "ul", "dl", "dd", "dt", "li", "form" then -3
158
+ when "h1", "h2", "h3", "h4", "h5", "h6", "th" then -5
159
+ else 0
160
+ end
161
+ end
162
+
163
+ # When several near-equal candidates share an ancestor, the content is
164
+ # split across them: a list of posts, or an article broken up by ads.
165
+ def shared_ancestor(ranked)
166
+ top_score = score(ranked.first)
167
+ return if top_score <= 0
168
+
169
+ peers = ranked.drop(1).select { |node| score(node) / top_score >= 0.75 }
170
+ return if peers.size < 3
171
+
172
+ peer_ancestor_keys = peers.map { |node| node.ancestors.map { |ancestor| Dom.key(ancestor) } }
173
+ climbable_ancestors(ranked.first).find do |ancestor|
174
+ key = Dom.key(ancestor)
175
+ peer_ancestor_keys.count { |keys| keys.include?(key) } >= 3
176
+ end
177
+ end
178
+
179
+ def climbable_ancestors(node)
180
+ node.ancestors.take_while { |ancestor| ancestor.element? && ancestor.name != "body" && within_root?(ancestor) }
181
+ end
182
+
183
+ # Readability walks up while the parent scores higher than its child: the
184
+ # content probably continues in the parent's other children.
185
+ def climb(top)
186
+ last = score(top) || 0.0
187
+ threshold = last / 3
188
+ climbable_ancestors(top).each do |parent|
189
+ parent_score = score(parent)
190
+ next unless parent_score
191
+ break if parent_score < threshold
192
+
193
+ return single_child_ancestor(parent) if parent_score > last
194
+
195
+ last = parent_score
196
+ end
197
+ single_child_ancestor(top)
198
+ end
199
+
200
+ def single_child_ancestor(node)
201
+ climbable_ancestors(node).each do |parent|
202
+ break unless parent.element_children.size == 1
203
+
204
+ node = parent
205
+ end
206
+ node
207
+ end
208
+
209
+ def merge_siblings(top)
210
+ return [top] if Dom.key(top) == @root_key || top.parent.nil?
211
+
212
+ top_score = score(top) || 0.0
213
+ threshold = [10, top_score * 0.2].max
214
+ top.parent.element_children.select do |sibling|
215
+ sibling == top || related_sibling?(sibling, top, top_score, threshold)
216
+ end
217
+ end
218
+
219
+ def related_sibling?(sibling, top, top_score, threshold)
220
+ same_class = !top["class"].to_s.empty? && sibling["class"] == top["class"]
221
+ sibling_score = score(sibling)
222
+ return true if sibling_score && sibling_score + (same_class ? top_score * 0.2 : 0) >= threshold
223
+ return false unless paragraph?(sibling)
224
+
225
+ text = Dom.text(sibling)
226
+ density = @stats.link_density(sibling)
227
+ (text.length > 80 && density < 0.25) || (text.length.between?(1, 80) && density.zero? && text.match?(/\.( |\z)/))
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ # Character and link counts for every element under a root, computed in one
5
+ # pass. Counts are non-whitespace characters, so indentation and line breaks
6
+ # in the source do not skew them.
7
+ class Stats
8
+ FRAGMENT_LINK_WEIGHT = 0.3
9
+
10
+ def initialize(root)
11
+ @chars = {}
12
+ @link_chars = {}
13
+ measure(root, 0.0)
14
+ end
15
+
16
+ def chars(node)
17
+ lookup(@chars, node)
18
+ end
19
+
20
+ def link_chars(node)
21
+ lookup(@link_chars, node)
22
+ end
23
+
24
+ def text_chars(node)
25
+ chars(node) - link_chars(node)
26
+ end
27
+
28
+ def link_density(node)
29
+ total = chars(node)
30
+ total.zero? ? 0.0 : link_chars(node) / total
31
+ end
32
+
33
+ private
34
+
35
+ def lookup(table, node)
36
+ key = Dom.key(node)
37
+ measure(node, inherited_link_weight(node)) unless table.key?(key)
38
+ table[key]
39
+ end
40
+
41
+ def measure(node, link_weight)
42
+ chars = 0.0
43
+ link_chars = 0.0
44
+ node.children.each do |child|
45
+ if child.text? || child.cdata?
46
+ count = Dom.char_count(child.content)
47
+ chars += count
48
+ link_chars += count * link_weight
49
+ elsif child.element?
50
+ child_chars, child_link_chars = measure(child, link_weight_for(child, link_weight))
51
+ chars += child_chars
52
+ link_chars += child_link_chars
53
+ end
54
+ end
55
+ store(node, chars, link_chars)
56
+ end
57
+
58
+ def store(node, chars, link_chars)
59
+ key = Dom.key(node)
60
+ if key
61
+ @chars[key] = chars
62
+ @link_chars[key] = link_chars
63
+ end
64
+ [chars, link_chars]
65
+ end
66
+
67
+ def link_weight_for(node, inherited)
68
+ href = node.name == "a" && node["href"]
69
+ return inherited unless href
70
+
71
+ href.start_with?("#") ? FRAGMENT_LINK_WEIGHT : 1.0
72
+ end
73
+
74
+ def inherited_link_weight(node)
75
+ anchor = node.ancestors.find { |ancestor| ancestor.element? && ancestor.name == "a" && ancestor["href"] }
76
+ anchor ? link_weight_for(anchor, 0.0) : 0.0
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Tables
5
+ module_function
6
+
7
+ # Data vs. layout tables, following the checks Firefox uses for
8
+ # accessibility (and Readability copies).
9
+ def data_table?(table)
10
+ return false if table["role"] == "presentation" || table["datatable"] == "0"
11
+ return true if table["summary"].to_s.strip != ""
12
+
13
+ caption = table.at_css("caption")
14
+ return true if caption && !caption.content.strip.empty?
15
+ return true if table.at_css("col, colgroup, tfoot, thead, th")
16
+ return false if table.at_css("table")
17
+
18
+ rows, columns = size(table)
19
+ return false if rows == 1 || columns == 1 || sparse?(table)
20
+ return true if rows >= 10 || columns > 4
21
+
22
+ rows * columns > 10
23
+ end
24
+
25
+ # Headerless grids with many blank cells (spacers, indentation) are layout,
26
+ # however many rows they have. Hacker News is the canonical example.
27
+ def sparse?(table)
28
+ cells = table.css("td")
29
+ blank = cells.count { |cell| cell.content.strip.empty? && cell.at_css("img").nil? }
30
+ blank > cells.size * 0.3
31
+ end
32
+
33
+ def size(table)
34
+ rows = 0
35
+ columns = 0
36
+ table.css("tr").each do |tr|
37
+ rowspan = tr["rowspan"].to_i
38
+ rows += rowspan.positive? ? rowspan : 1
39
+ cells = tr.css("td").sum do |td|
40
+ colspan = td["colspan"].to_i
41
+ colspan.positive? ? colspan : 1
42
+ end
43
+ columns = cells if cells > columns
44
+ end
45
+ [rows, columns]
46
+ end
47
+
48
+ def rows(table)
49
+ table.element_children.flat_map do |child|
50
+ case child.name
51
+ when "tr" then [child]
52
+ when "thead", "tbody", "tfoot" then child.element_children.select { |row| row.name == "tr" }
53
+ else []
54
+ end
55
+ end
56
+ end
57
+
58
+ def cells(row)
59
+ row.element_children.select { |cell| cell.name == "td" || cell.name == "th" }
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Nokodown
6
+ # Resolves hrefs against the page URL (and <base>), tolerating the malformed
7
+ # URLs real pages contain, and shortens them to the address they point at:
8
+ # tracking parameters, click redirectors and image resize options cost
9
+ # tokens and tell a reader nothing.
10
+ class UrlResolver
11
+ # From Ruby 3.4 URI::DEFAULT_PARSER is the RFC 3986 parser, whose unescape is obsolete.
12
+ URI_PARSER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
13
+ TRACKING_PARAM = /\A(?:utm_[a-z_]+|fbclid|gclid|gclsrc|dclid|gbraid|wbraid|msclkid|yclid|igshid|mc_cid|mc_eid|_hsenc|_hsmi|mkt_tok|__s|
14
+ _gl|_ga|srsltid|trk|icid|cmpid|hsCtaTracking|ref_|pd_rd_[a-z]+|pf_rd_[a-z]+)\z/ix.freeze
15
+ # Amazon session, search-rank and affiliate parameters; on other sites
16
+ # these names can matter (Microsoft fwlinks need linkid).
17
+ AMAZON_PARAM = /\A(?:ref|qid|sr|th|smid|psc|crid|dib|dib_tag|spLa|sprefix|linkCode|linkId|ascsubtag|camp|creative)\z/.freeze
18
+ AMAZON = /\A(?:www\.|smile\.)?amazon\.(?:com?\.)?[a-z]{2,3}\z/i.freeze
19
+ AMAZON_PRODUCT = %r{\A.*?/(?:dp|gp/product|gp/aw/d)/[A-Z0-9]{10}(?![A-Za-z0-9])}.freeze
20
+ # Bestseller links put the session id after the ref segment.
21
+ AMAZON_REF = %r{/ref=[^/]*(?:/\d{3}-\d{7}-\d{7})?\z}.freeze
22
+ REDIRECTOR = %r{
23
+ \A(?:www\.)?google\.(?:com?\.)?[a-z]{2,3}/url\z |
24
+ \A(?:www\.)?youtube\.com/redirect\z |
25
+ \Alm?\.facebook\.com/l\.php\z |
26
+ \.safelinks\.protection\.outlook\.com/?\z |
27
+ \Ago\.(?:skimresources|redirectingat)\.com/?\z |
28
+ \Aredirect\.viglink\.com/?\z |
29
+ \Aclick\.linksynergy\.com/ |
30
+ \A(?:www\.)?avantlink\.com/click\.php\z |
31
+ \A[\w-]+\.(?:pxf|sjv)\.io/c/ |
32
+ \A(?:www\.)?(?:anrdoezrs\.net|dpbolvw\.net|jdoqocy\.com|kqzyfj\.com|tkqlhce\.com)/click- |
33
+ \Asteamcommunity\.com/linkfilter/?\z
34
+ }xi.freeze
35
+ DESTINATION_PARAM = /\A(?:url|u|murl|q)\z/.freeze
36
+ MAX_REDIRECTS = 3
37
+ # Image CDN size, crop and format options; the default rendition shows the
38
+ # same picture.
39
+ PRESENTATION_PARAM = /\A(?:fit|crop|auto|dpr|fm|fmt|resize|resMode|op_usm|ixlib|impolicy)\z/.freeze
40
+ # Only a number is a size: Google and Bing thumbnails name the image in q.
41
+ SIZE_PARAM = /\A(?:w|h|width|height|q|quality|wid|hei|qlt|imwidth|imheight)\z/.freeze
42
+ NUMBER = /\A\d+(?:[.,]\d+)?\z/.freeze
43
+ EXTENSION = /\.[A-Za-z][A-Za-z0-9]{1,4}\z/.freeze
44
+ # Signed URLs stop working once any parameter changes.
45
+ SIGNATURE_PARAM = /\A(?:s|sig|sign|signature|token|__token__|hdnts|expires|x-amz-[a-z-]+|x-goog-[a-z-]+|key-pair-id|
46
+ policy|hmac|hash|jwt|itok)\z/ix.freeze
47
+ NEXT_IMAGE = %r{/_(?:next|vercel)/image/?\z}.freeze
48
+ # Cloudinary (on any domain), Forem, Cloudflare and ShortPixel append the
49
+ # original to their path. Screenshot services and web archives do too, but
50
+ # they do not serve the image at that URL.
51
+ FETCH_PROXY = %r{
52
+ \A[^/]*(?:/[\w-]+)?/image/fetch/ |
53
+ \A[^/]*/(?:dynamic|cdn-cgi)/image/ |
54
+ \Asp-ao\.shortpixel\.ai/client/
55
+ }ix.freeze
56
+ EMBEDDED_URL = %r{/(https?(?::|%3A)(?:/|%2F){2}.+)\z}i.freeze
57
+ CDN_CGI_IMAGE = %r{\A/cdn-cgi/image/[^/]+(/.+)\z}.freeze
58
+ PRIVATE_BUCKET = /\Abucketeer-/i.freeze
59
+ WEB_URL = %r{\Ahttps?://}i.freeze
60
+ UNSAFE = %r{[^A-Za-z0-9\-._~:/?#@!$&'()*+,;=%]}.freeze
61
+
62
+ def initialize(url, base_href = nil)
63
+ @base = parse(url)
64
+ href = base_href.to_s.strip
65
+ @base = join(@base, href) || @base if !href.empty? && !href.start_with?("javascript:")
66
+ end
67
+
68
+ def resolve(href)
69
+ absolute(href) { |uri| clean_link(uri) }
70
+ end
71
+
72
+ def resolve_image(src)
73
+ absolute(src) { |uri| clean_image(uri) }
74
+ end
75
+
76
+ def same_page?(url)
77
+ return false unless @base
78
+
79
+ uri = parse(url)
80
+ return false unless uri
81
+
82
+ @page ||= strip_fragment(clean_link(@base))
83
+ uri.fragment && strip_fragment(uri) == @page
84
+ end
85
+
86
+ private
87
+
88
+ def absolute(href)
89
+ href = href.to_s.strip.delete("\t\n\r")
90
+ return if href.empty?
91
+ return href if href.match?(/\A(?:mailto|tel|data):/i)
92
+
93
+ uri = @base ? join(@base, href) : parse(href)
94
+ uri ? yield(uri).to_s : href
95
+ end
96
+
97
+ def parse(url)
98
+ return if url.nil? || url.to_s.strip.empty?
99
+
100
+ URI.parse(encode(url.to_s.strip))
101
+ rescue URI::Error, ArgumentError
102
+ nil
103
+ end
104
+
105
+ def join(base, href)
106
+ return parse(href) unless base
107
+
108
+ base.merge(encode(href))
109
+ rescue URI::Error, ArgumentError
110
+ nil
111
+ end
112
+
113
+ def encode(string)
114
+ string.gsub(UNSAFE) { |char| char.bytes.map { |byte| format("%%%02X", byte) }.join }
115
+ end
116
+
117
+ def clean_link(uri, redirects = 0)
118
+ target = redirect_target(uri) if redirects < MAX_REDIRECTS
119
+ return clean_link(target, redirects + 1) if target
120
+ return clean_amazon(uri) if uri.host.to_s.match?(AMAZON)
121
+
122
+ strip_tracking(uri)
123
+ end
124
+
125
+ def strip_tracking(uri)
126
+ reject_params(uri) { |key, _| key.match?(TRACKING_PARAM) }
127
+ end
128
+
129
+ def redirect_target(uri)
130
+ return unless "#{uri.host}#{uri.path}".match?(REDIRECTOR)
131
+
132
+ param_values(uri, DESTINATION_PARAM).map { |value| parse(value) }.find { |target| web?(target) }
133
+ end
134
+
135
+ def web?(uri)
136
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
137
+ end
138
+
139
+ def clean_amazon(uri)
140
+ product = uri.path[AMAZON_PRODUCT]
141
+ uri = uri.dup
142
+ uri.path = product || uri.path.sub(AMAZON_REF, "")
143
+ return reject_params(uri) { |key, _| key != "tag" } if product
144
+
145
+ reject_params(uri) { |key, _| key.match?(TRACKING_PARAM) || key.match?(AMAZON_PARAM) }
146
+ end
147
+
148
+ def clean_image(uri)
149
+ return uri if signed_image?(uri)
150
+
151
+ embedded = embedded_image(uri)
152
+ return original_image(uri, embedded) if embedded
153
+
154
+ proxy?(uri) ? strip_tracking(uri) : strip_presentation(uri)
155
+ end
156
+
157
+ def proxy?(uri)
158
+ uri.query.to_s.split("&").any? { |pair| decode(param(pair).last).to_s.match?(WEB_URL) }
159
+ end
160
+
161
+ # A proxy needs its options, so it stays whole when its original is unusable
162
+ # or is the proxy itself. The original is not unwrapped in turn, because
163
+ # proxies can point at each other.
164
+ def original_image(proxy, embedded)
165
+ original = join(proxy, embedded) unless embedded.empty?
166
+ return strip_tracking(proxy) unless web?(original) && !same_resource?(original, proxy) && !original.host.match?(PRIVATE_BUCKET)
167
+
168
+ signed_image?(original) ? original : strip_presentation(original)
169
+ end
170
+
171
+ def same_resource?(uri, other)
172
+ uri.host == other.host && uri.path == other.path
173
+ end
174
+
175
+ def strip_presentation(uri)
176
+ extension = uri.path.to_s.match?(EXTENSION)
177
+ reject_params(uri) { |key, value| key.match?(TRACKING_PARAM) || presentation?(key, value, extension) }
178
+ end
179
+
180
+ # Twitter media has no file extension and is a 404 without its format.
181
+ def presentation?(key, value, extension)
182
+ return value.match?(NUMBER) if key.match?(SIZE_PARAM)
183
+ return extension if key == "format"
184
+
185
+ key.match?(PRESENTATION_PARAM) || (key == "v" && value.match?(/\A\d+\z/))
186
+ end
187
+
188
+ def signed_image?(uri)
189
+ uri.query.to_s.split("&").any? { |pair| param(pair).first.match?(SIGNATURE_PARAM) }
190
+ end
191
+
192
+ def embedded_image(uri)
193
+ path = uri.path.to_s
194
+ return param_values(uri, /\Aurl\z/).first.to_s if path.match?(NEXT_IMAGE)
195
+ return fetched_image(uri) if "#{uri.host}#{path}".match?(FETCH_PROXY) && path.match?(EMBEDDED_URL)
196
+
197
+ original = path[CDN_CGI_IMAGE, 1]
198
+ original && uri.query ? "#{original}?#{uri.query}" : original
199
+ end
200
+
201
+ # An encoded original ends where the proxy's query starts. A plain one was
202
+ # split in two by the parser, so that query is the original's own.
203
+ def fetched_image(uri)
204
+ embedded = uri.path[EMBEDDED_URL, 1]
205
+ return unescape(embedded).to_s if embedded.match?(/\Ahttps?%3A/i)
206
+
207
+ uri.query ? "#{embedded}?#{uri.query}" : embedded
208
+ end
209
+
210
+ def param_values(uri, key)
211
+ params = uri.query.to_s.split("&").map { |pair| param(pair) }
212
+ params.select { |name, _| name.match?(key) }.map { |_, value| decode(value) }.compact
213
+ end
214
+
215
+ def param(pair)
216
+ key, value = pair.split("=", 2)
217
+ [key.to_s, value.to_s]
218
+ end
219
+
220
+ def decode(value)
221
+ decoded = URI.decode_www_form_component(value)
222
+ decoded if decoded.valid_encoding?
223
+ rescue ArgumentError
224
+ nil
225
+ end
226
+
227
+ def unescape(value)
228
+ decoded = URI_PARSER.unescape(value)
229
+ decoded if decoded.valid_encoding?
230
+ end
231
+
232
+ def reject_params(uri)
233
+ return uri unless uri.query
234
+
235
+ pairs = uri.query.split("&")
236
+ kept = pairs.reject { |pair| yield(*param(pair)) }
237
+ return uri if kept.size == pairs.size
238
+
239
+ kept -= [""]
240
+ uri = uri.dup
241
+ uri.query = kept.empty? ? nil : kept.join("&")
242
+ uri
243
+ end
244
+
245
+ def strip_fragment(uri)
246
+ uri.to_s.sub(/#.*\z/, "")
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ VERSION = "0.1.0"
5
+ end
data/lib/nokodown.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokolexbor"
4
+
5
+ require_relative "nokodown/version"
6
+ require_relative "nokodown/charset"
7
+ require_relative "nokodown/dom"
8
+ require_relative "nokodown/stats"
9
+ require_relative "nokodown/patterns"
10
+ require_relative "nokodown/tables"
11
+ require_relative "nokodown/url_resolver"
12
+ require_relative "nokodown/metadata"
13
+ require_relative "nokodown/preprocessor"
14
+ require_relative "nokodown/boilerplate"
15
+ require_relative "nokodown/product_reviews"
16
+ require_relative "nokodown/scorer"
17
+ require_relative "nokodown/content_locator"
18
+ require_relative "nokodown/container_check"
19
+ require_relative "nokodown/pruner"
20
+ require_relative "nokodown/markdown/text"
21
+ require_relative "nokodown/markdown/image"
22
+ require_relative "nokodown/markdown/code_block"
23
+ require_relative "nokodown/markdown/inline"
24
+ require_relative "nokodown/markdown/redundant_images"
25
+ require_relative "nokodown/markdown/table"
26
+ require_relative "nokodown/markdown/renderer"
27
+ require_relative "nokodown/markdown/cleanup"
28
+ require_relative "nokodown/markdown/image_cleanup"
29
+ require_relative "nokodown/result"
30
+ require_relative "nokodown/extractor"
31
+
32
+ module Nokodown
33
+ # Extracts the content of an HTML page as Markdown, plus page metadata.
34
+ #
35
+ # result = Nokodown.extract(html, url: "https://example.com/post")
36
+ # result.markdown
37
+ # result.title
38
+ #
39
+ # Options:
40
+ # url: page URL, used to resolve relative links and images
41
+ # main_content: true (default) keeps only the main content; false converts
42
+ # the whole page, removing only invisible elements and scripts
43
+ # title: prepend the page title as an H1 when the content has none;
44
+ # a page with no content gives empty Markdown, not the title
45
+ # images: include images
46
+ # links: keep links; false renders link text only
47
+ def self.extract(html, **options)
48
+ Extractor.new(html, **options).call
49
+ end
50
+
51
+ def self.to_markdown(html, **options)
52
+ extract(html, **options).markdown
53
+ end
54
+ end