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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ class Extractor
5
+ TITLE_SIMILARITY = 0.75
6
+ MIN_ARTICLE_LENGTH = 500
7
+ CONDITIONAL_SHARE = 0.5
8
+ REMOVED_LINK_SHARE = 0.5
9
+
10
+ def initialize(html, url: nil, main_content: true, title: true, images: true, links: true)
11
+ @html = html
12
+ @url = url
13
+ @main_content = main_content
14
+ @title = title
15
+ @images = images
16
+ @links = links
17
+ end
18
+
19
+ def call
20
+ doc = Nokolexbor::HTML(Charset.utf8(@html.to_s))
21
+ metadata = Metadata.new(doc).to_h
22
+ urls = UrlResolver.new(@url, doc.at_css("base[href]")&.[]("href"))
23
+ body = doc.at_css("body")
24
+ return result([], metadata, :none) if body.nil?
25
+
26
+ product_page = @main_content && ProductReviews.product_page?(doc, metadata[:page_type])
27
+ Preprocessor.new(body).call
28
+ nodes, mode = select_content(body, metadata[:page_type], product_page)
29
+ blocks = render(nodes, mode, urls)
30
+ blocks = with_title(blocks, metadata[:title]) if @title
31
+ result(blocks, metadata, mode)
32
+ end
33
+
34
+ private
35
+
36
+ def render(nodes, mode, urls)
37
+ redundant = redundant_images(nodes, mode, urls)
38
+ renderer = Markdown::Renderer.new(urls, images: @images, links: @links, redundant_images: redundant)
39
+ Markdown::Cleanup.call(renderer.render(nodes), renderer.rendered_images)
40
+ end
41
+
42
+ # Converting the whole page keeps every visible image.
43
+ def redundant_images(nodes, mode, urls)
44
+ return {} if mode == :full || !@images
45
+
46
+ Markdown::RedundantImages.new(nodes, mode, urls).call
47
+ end
48
+
49
+ def select_content(body, page_type, product_page)
50
+ return [[body], :full] unless @main_content
51
+
52
+ Boilerplate.new(body).call
53
+ ProductReviews.new(body).call if product_page
54
+ selection = ContentLocator.new(body, page_type: page_type).call
55
+ return [cleaned_article(selection.nodes, page_type), :article] if selection.mode == :article
56
+
57
+ Pruner.new(selection.nodes, :page).call
58
+ [selection.nodes, :page]
59
+ end
60
+
61
+ # Like Readability, fall back to lighter cleaning when conditional cleaning
62
+ # throws away most of what it sees; markup made of many small linky blocks
63
+ # (React apps, card grids) trips it, and a short intro would otherwise pass
64
+ # for the article. When almost nothing is left at all, the content is
65
+ # pruned like a page. Cleaning runs on a copy so it can be undone.
66
+ def cleaned_article(nodes, page_type)
67
+ copies = nodes.map(&:dup)
68
+ pruner = Pruner.new(copies, :article).call
69
+ if almost_nothing_kept?(copies, nodes)
70
+ Pruner.new(nodes, :page).call
71
+ return nodes
72
+ end
73
+ return copies if keep_conditional_cleaning?(pruner, page_type)
74
+
75
+ Pruner.new(nodes, :article, conditional: false).call
76
+ nodes
77
+ end
78
+
79
+ # When what conditional cleaning removed from a declared article is mostly
80
+ # links, it was a grid of teasers for other stories, not the article. On
81
+ # listings and hubs the same teasers are the content.
82
+ def keep_conditional_cleaning?(pruner, page_type)
83
+ return true if pruner.conditional_share >= CONDITIONAL_SHARE
84
+
85
+ page_type == :article && pruner.removed_link_share > REMOVED_LINK_SHARE
86
+ end
87
+
88
+ def almost_nothing_kept?(copies, nodes)
89
+ kept = copies.sum { |node| Dom.text(node).length }
90
+ kept < MIN_ARTICLE_LENGTH && kept < nodes.sum { |node| Dom.text(node).length } * 0.25
91
+ end
92
+
93
+ # One H1 at the top: the page's own if the content has one, otherwise the
94
+ # metadata title, replacing a near-duplicate heading right below it.
95
+ # Nothing found stays empty, so a script-rendered shell or a bot wall does
96
+ # not pass for a page; Result#title still has the title.
97
+ def with_title(blocks, title)
98
+ return blocks if blocks.empty?
99
+
100
+ first_h1 = blocks.find { |block| block.kind == :heading && block.level == 1 }
101
+ return demote_h1s(blocks, first_h1) if first_h1
102
+ return blocks if title.nil?
103
+
104
+ duplicate = blocks.first(3).find do |block|
105
+ block.kind == :heading && Dom.similarity(title, Markdown::Cleanup.heading_text(block)) > TITLE_SIMILARITY
106
+ end
107
+ [Markdown::Block.new(:heading, "# #{Markdown::Text.escape(title)}", 1)] + blocks.reject { |block| block.equal?(duplicate) }
108
+ end
109
+
110
+ def demote_h1s(blocks, keep)
111
+ blocks.each do |block|
112
+ next unless block.kind == :heading && block.level == 1 && !block.equal?(keep)
113
+
114
+ block.text = "##{block.text}"
115
+ block.level = 2
116
+ end
117
+ end
118
+
119
+ def result(blocks, metadata, mode)
120
+ Result.new(
121
+ markdown: blocks.map(&:text).join("\n\n"),
122
+ title: metadata[:title],
123
+ byline: metadata[:byline],
124
+ excerpt: metadata[:excerpt] || first_paragraph(blocks),
125
+ site_name: metadata[:site_name],
126
+ published_time: metadata[:published_time],
127
+ lang: metadata[:lang],
128
+ mode: mode
129
+ )
130
+ end
131
+
132
+ def first_paragraph(blocks)
133
+ paragraphs = blocks.select { |block| block.kind == :paragraph }.map { |block| Markdown::Text.plain(block.text) }
134
+ paragraphs.find { |text| text.length >= 80 } || paragraphs.find { |text| !text.empty? }
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ # Block-level tidying after rendering: repeated blocks, stray rules,
6
+ # punctuation-only lines, section headings left without a section, and
7
+ # redundant or repeated images.
8
+ module Cleanup
9
+ DEDUPE_WINDOW = 4
10
+ IMAGE_ONLY = /\A!\[([^\]]*)\]\([^)]*\)\z/.freeze
11
+ LINKED_HEADING = /\A#+ \[.*\]\(.*\)\z/.freeze
12
+ TITLE_LIST_MIN = 3
13
+
14
+ module_function
15
+
16
+ # images: the Renderer's rendered_images.
17
+ def call(blocks, images = {})
18
+ blocks = blocks.reject { |block| noise?(block) }
19
+ blocks = dedupe(blocks)
20
+ blocks = trim_rules(blocks)
21
+ blocks = drop_empty_sections(blocks)
22
+ images.empty? ? blocks : ImageCleanup.new(images).call(blocks)
23
+ end
24
+
25
+ def noise?(block)
26
+ return false unless block.kind == :paragraph
27
+
28
+ text = block.text
29
+ return true unless text.match?(/[\p{L}\p{N}]/) || text.include?("](")
30
+
31
+ plain = text.gsub(/[*_`~]/, "").strip
32
+ plain.length <= 80 && plain.match?(Patterns::BOILERPLATE_TEXT)
33
+ end
34
+
35
+ # Responsive and "enlarge" variants repeat an image (and its caption) a
36
+ # block or two later, within the same section. Dropping the image makes
37
+ # the captions adjacent.
38
+ def dedupe(blocks)
39
+ blocks.each_with_object([]) do |block, kept|
40
+ window = block.text.match?(IMAGE_ONLY) ? same_section(kept.last(DEDUPE_WINDOW)) : kept.last(1)
41
+ kept << block unless window.any? { |previous| identity(previous) == identity(block) }
42
+ end
43
+ end
44
+
45
+ def same_section(blocks)
46
+ heading = blocks.rindex { |block| block.kind == :heading }
47
+ heading ? blocks.drop(heading + 1) : blocks
48
+ end
49
+
50
+ # Variants of one image differ in URL (size, crop) but share alt text.
51
+ def identity(block)
52
+ alt = block.text[IMAGE_ONLY, 1]
53
+ alt && alt.length >= 10 ? "image:#{alt}" : block.text
54
+ end
55
+
56
+ def trim_rules(blocks)
57
+ blocks = blocks.each_with_object([]) do |block, kept|
58
+ next if block.kind == :rule && (kept.empty? || kept.last.kind == :rule || kept.last.kind == :heading)
59
+
60
+ kept << block
61
+ end
62
+ blocks.pop while blocks.last&.kind == :rule
63
+ blocks
64
+ end
65
+
66
+ # A heading followed by nothing, or by a heading at the same or a higher
67
+ # level, lost its section during extraction. Title lists are content in
68
+ # their own right, and a page that is only its headline keeps it.
69
+ def drop_empty_sections(blocks)
70
+ titles = title_list_indexes(blocks)
71
+ kept = blocks.each_index.reject { |index| !titles.include?(index) && empty_section?(blocks, index) }
72
+ kept.pop while kept.any? && blocks[kept.last].kind == :heading && !titles.include?(kept.last)
73
+ return blocks.values_at(*kept) if kept.any?
74
+
75
+ [blocks.find { |block| block.kind == :heading && block.level == 1 }].compact
76
+ end
77
+
78
+ def empty_section?(blocks, index)
79
+ block = blocks[index]
80
+ return false unless block.kind == :heading
81
+
82
+ following = blocks[(index + 1)..].find { |b| b.kind != :rule }
83
+ return true if following.nil?
84
+ return false unless following.kind == :heading && following.level <= block.level
85
+
86
+ heading_text(block).match?(Patterns::NOISE_HEADING)
87
+ end
88
+
89
+ # Same-level headings in a row, mostly links, are the titles of cards
90
+ # whose details were removed (products, related guides), not sections.
91
+ # A "Trending" or "Related" heading among them is still a section label.
92
+ def title_list_indexes(blocks)
93
+ runs = blocks.each_index.reject { |index| blocks[index].kind == :rule }.slice_when do |a, b|
94
+ blocks[a].kind != :heading || blocks[b].kind != :heading || blocks[a].level != blocks[b].level
95
+ end
96
+ indexes = runs.select { |run| title_list?(blocks.values_at(*run)) }.flatten
97
+ indexes.reject { |index| heading_text(blocks[index]).match?(Patterns::NOISE_HEADING) }.to_set
98
+ end
99
+
100
+ def title_list?(headings)
101
+ return false if headings.size < TITLE_LIST_MIN
102
+
103
+ headings.count { |heading| heading.text.match?(LINKED_HEADING) } * 2 > headings.size
104
+ end
105
+
106
+ def heading_text(block)
107
+ block.text.sub(/\A#+\s*/, "").gsub(/\[([^\]]*)\]\([^)]*\)/, '\1').gsub(/[*_`]/, "")
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ module CodeBlock
6
+ LANGUAGE_CLASS = /\A(?:language|lang|highlight-source|highlight)-([\w+#.-]+)\z/i.freeze
7
+ NO_LANGUAGE = %w[none plain plaintext text nohighlight].freeze
8
+
9
+ module_function
10
+
11
+ def render(node, raw)
12
+ text = raw.tr(Dom::NBSP, " ").sub(/\A\n/, "").split("\n").map(&:rstrip).join("\n")
13
+ text = text.sub(/\A(?:[ \t]*\n)+/, "").rstrip
14
+ return [] if text.strip.empty?
15
+
16
+ fence = Text.code_fence(text)
17
+ [Block.new(:code, "#{fence}#{language(node)}\n#{text}\n#{fence}")]
18
+ end
19
+
20
+ # <code> used as a block: <div class="code"><code>multi-line</code></div>.
21
+ def standalone?(node)
22
+ parent = node.parent
23
+ return false if parent.nil? || parent.name == "pre"
24
+ return false unless node.content.strip.include?("\n")
25
+
26
+ parent.children.all? { |child| child == node || (child.text? && child.content.strip.empty?) }
27
+ end
28
+
29
+ # Highlighters often put each line in its own block element instead of
30
+ # separating lines with "\n".
31
+ def preformatted(node)
32
+ node.children.each_with_object(+"") do |child, out|
33
+ if child.text? || child.cdata?
34
+ out << child.content
35
+ elsif child.name == "br"
36
+ out << "\n"
37
+ elsif Dom.block?(child)
38
+ out << "\n" unless out.empty? || out.end_with?("\n")
39
+ out << preformatted(child)
40
+ out << "\n" unless out.end_with?("\n")
41
+ elsif child.element?
42
+ out << preformatted(child)
43
+ end
44
+ end
45
+ end
46
+
47
+ # The language sits on the <pre>, its <code>, or a wrapper up to two
48
+ # levels out (GitHub, Sphinx).
49
+ def language(node)
50
+ [node, node.at_css("code"), node.parent, node.parent&.parent].each do |element|
51
+ next unless element&.element?
52
+
53
+ name = explicit_language(element)
54
+ return clean(name) if name
55
+ end
56
+ ""
57
+ end
58
+
59
+ def explicit_language(element)
60
+ classes = element["class"].to_s
61
+ element["data-lang"] || element["data-language"] || classes[/brush:\s*([\w+#.-]+)/, 1] ||
62
+ classes.split.map { |name| name[LANGUAGE_CLASS, 1] }.compact.first
63
+ end
64
+
65
+ def clean(name)
66
+ name = name.downcase.sub(/\Asource-/, "")
67
+ name.match?(/\A[\w+#.-]{1,24}\z/) && !NO_LANGUAGE.include?(name) ? name : ""
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ module Image
6
+ TRACKING = %r{(?:\A|/)(?:pixel|spacer|blank|transparent|clear|tracking|beacon|1x1|[\w-]*placeholder)\.(?:gif|png|svg|jpe?g|webp)(?:\?|\z)|/(?:pixel|beacon)(?:/|\?)}i.freeze
7
+ # README status badges: build, version and coverage shields.
8
+ BADGE = %r{//(?:img\.shields\.io|badge\.fury\.io|badgen\.net)/|badge\.svg(?:[?#]|\z)|/badges?/[^?#]*\.svg}i.freeze
9
+ # Emoji, including skin-tone modifiers, variation selectors and joiners.
10
+ EMOJI = /\A[\p{So}\p{Sk}\p{Mn}\p{Cf}]+\z/.freeze
11
+
12
+ module_function
13
+
14
+ def source(node, urls)
15
+ src = node["src"].to_s.strip
16
+ src = largest_srcset(node["srcset"].to_s) if src.empty? || src.start_with?("data:")
17
+ return if src.nil? || src.empty? || src.start_with?("data:")
18
+
19
+ urls.resolve_image(src)
20
+ end
21
+
22
+ def largest_srcset(srcset)
23
+ candidates = srcset.split(/,\s+/).map do |candidate|
24
+ url, descriptor = candidate.strip.split(/\s+/, 2)
25
+ [url, descriptor.to_f]
26
+ end
27
+ candidates.reject { |url, _| url.nil? || url.empty? }.max_by { |_, size| size }&.first
28
+ end
29
+
30
+ # Icons, spacers, tracking pixels, placeholders and badges.
31
+ def decorative?(node, src, alt)
32
+ return true if alt.empty? && src.match?(/\.svg(?:[?#]|\z)/i)
33
+
34
+ width = pixels(node["width"])
35
+ height = pixels(node["height"])
36
+ return true if (width && width <= 2) || (height && height <= 2)
37
+ return true if width && height && width <= 32 && height <= 32
38
+
39
+ src.match?(TRACKING) || src.match?(BADGE)
40
+ end
41
+
42
+ def pixels(value)
43
+ value.to_s.strip.match?(/\A\d+(?:px)?\z/) ? value.to_i : nil
44
+ end
45
+
46
+ # WordPress and Twitter render emoji as images; the alt is the emoji.
47
+ def emoji?(node, alt)
48
+ node["class"].to_s.match?(/emoji|wp-smiley/) || (alt.length.between?(1, 8) && alt.match?(EMOJI))
49
+ end
50
+
51
+ # Wikipedia's math fallback images carry the TeX source as alt text.
52
+ def tex(alt)
53
+ tex = alt.sub(/\A\{\\displaystyle\s*(.*)\}\z/m, '\1').strip
54
+ tex.empty? ? "" : "$#{tex}$"
55
+ end
56
+
57
+ def video_page(src)
58
+ id = src[%r{youtube(?:-nocookie)?\.com/embed/([\w-]{6,})}, 1]
59
+ id ? "https://www.youtube.com/watch?v=#{id}" : src
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nokodown
4
+ module Markdown
5
+ # The last step of Cleanup: removes redundant images and repeats of an
6
+ # image anywhere on the page. Run earlier, it would leave a heading whose
7
+ # section held only an image for drop_empty_sections to take.
8
+ class ImageCleanup
9
+ # Markup as Inline writes it: "](" in the alt is escaped, and the
10
+ # destination's parentheses are balanced or percent-encoded. One pattern
11
+ # for every image keeps the cost linear on pages with thousands; the
12
+ # rare URL with nested parentheses is not matched, so its image stays.
13
+ IMAGE = /!\[(?:\\.|[^\\\[\]\n]|\[(?:\\.|[^\\\[\]\n])*\])*\]\((?:[^()\s]|\([^()\s]*\))*\)/.freeze
14
+ DESTINATION = /\]\(((?:[^()\s]|\([^()\s]*\))*)\)/.freeze
15
+ # A line with nothing but list or quote markers, nested lists included.
16
+ BARE_LINE = /\A(?:\s|>|[-*+]|\d+\.)*\z/.freeze
17
+ LIST_MARKER = /\A *(?:[-*+]|\d+\.) /.freeze
18
+ LIST_MARKERS = /\A *(?:(?:[-*+]|\d+\.) )+/.freeze
19
+ # The indentation and markers before a line's text.
20
+ LINE_PREFIX = /\A(?: |>|(?:[-*+]|\d+\.) )*/.freeze
21
+ # Text never starts a line with ``` (Text.escape_line_starts), so this
22
+ # is the fence of a code block nested in a list or a quote.
23
+ FENCE = /#{LINE_PREFIX.source}(`{3,})/.freeze
24
+
25
+ def initialize(images)
26
+ @images = images
27
+ # Image URL => @text_lines when its last copy was written.
28
+ @seen = {}
29
+ @text_lines = 0
30
+ end
31
+
32
+ def call(blocks)
33
+ @linked = linked_pages(blocks)
34
+ blocks.select do |block|
35
+ if block.kind == :code || !block.text.include?("![")
36
+ @text_lines += 1
37
+ next true
38
+ end
39
+
40
+ text = without_images(block.text)
41
+ next true if text == block.text
42
+
43
+ block.text = text
44
+ text.match?(/[\p{L}\p{N}]/) && !Cleanup.noise?(block)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Pages the final Markdown still links to.
51
+ def linked_pages(blocks)
52
+ destinations = blocks.reject { |block| block.kind == :code }.flat_map { |block| block.text.scan(DESTINATION).flatten }
53
+ Set.new(destinations.map { |destination| RedundantImages.page(destination) })
54
+ end
55
+
56
+ def without_images(text)
57
+ lines = with_code_flags(text.split("\n")).map do |line, code|
58
+ next [line, line_without_images(line), false] unless code
59
+
60
+ @text_lines += 1
61
+ [line, line, true]
62
+ end
63
+ squeeze(drop_bare_lines(lines)).strip
64
+ end
65
+
66
+ # Each line, and whether it belongs to a nested code block, whose lines
67
+ # stay as they are.
68
+ def with_code_flags(lines)
69
+ fence = nil
70
+ lines.map do |line|
71
+ run = line[FENCE, 1]
72
+ if fence
73
+ fence = nil if run && run.length >= fence.length
74
+ next [line, true]
75
+ end
76
+ fence = run
77
+ [line, !run.nil?]
78
+ end
79
+ end
80
+
81
+ # Text that followed a removed image can now start the line, where "#"
82
+ # or ">" would turn it into a heading or a quote.
83
+ def line_without_images(line)
84
+ removed = removed_images(line)
85
+ return line if removed.empty?
86
+
87
+ kept = line.gsub(IMAGE) { |markup| removed.include?(markup) ? " " : markup }
88
+ prefix = line[LINE_PREFIX]
89
+ prefix + Text.escape_line_starts(kept[prefix.length..].strip.squeeze(" "))
90
+ end
91
+
92
+ # Redundant images, and later copies of an image standing alone on its
93
+ # line. Beside text, as in a table of tick marks, each copy counts.
94
+ def removed_images(line)
95
+ markups = line.scan(IMAGE).select { |markup| @images.key?(markup) }
96
+ content = markups.reject { |markup| redundant?(@images[markup]) }
97
+ alone = line.gsub(IMAGE) { |markup| @images.key?(markup) ? "" : markup }.match?(BARE_LINE)
98
+ removed = Set.new(markups - content)
99
+ removed.merge(repeats(content)) if alone
100
+ content.each { |markup| @seen[@images[markup].url] = @text_lines }
101
+ @text_lines += 1 unless alone
102
+ removed
103
+ end
104
+
105
+ # A line with several copies of one image is a count, such as a row of
106
+ # rating stars, and keeps them all. Otherwise a copy without alt text
107
+ # adds nothing. One with alt text can mark its own item (an award, a
108
+ # verified tick), so it goes only when nothing but images came since
109
+ # the last copy, as in a logo strip shown twice.
110
+ def repeats(content)
111
+ urls = content.map { |markup| @images[markup].url }
112
+ return [] if urls.uniq.size < urls.size
113
+
114
+ content.select do |markup|
115
+ seen = @seen[@images[markup].url]
116
+ seen && (markup.start_with?("![]") || seen == @text_lines)
117
+ end
118
+ end
119
+
120
+ # A thumbnail goes only while the Markdown still links to its page; a
121
+ # card whose title link was dropped keeps its image.
122
+ def redundant?(image)
123
+ image.redundant && image.targets.all? { |target| @linked.include?(target) }
124
+ end
125
+
126
+ # Lines left with only markers go. A list item whose first line held
127
+ # only images starts at its next line instead.
128
+ def drop_bare_lines(lines)
129
+ marker = nil
130
+ lines.filter_map do |line, kept, code|
131
+ if kept != line && kept.match?(BARE_LINE)
132
+ marker = pending_marker(line, marker)
133
+ next
134
+ end
135
+ next [kept, code] if kept.empty?
136
+
137
+ kept = with_marker(kept, marker) if marker
138
+ marker = nil
139
+ [kept, code]
140
+ end
141
+ end
142
+
143
+ # One blank line where dropped lines left several, except in code.
144
+ def squeeze(lines)
145
+ lines.each_with_object([]) do |(line, code), kept|
146
+ kept << line unless !code && line.empty? && kept.last == ""
147
+ end.join("\n")
148
+ end
149
+
150
+ # The outermost item still waiting for its text: a bare line nested in
151
+ # it, such as a list of thumbnails, does not take its place.
152
+ def pending_marker(line, marker)
153
+ return marker if marker && line.start_with?(" " * marker[LIST_MARKER].length)
154
+
155
+ line[LIST_MARKERS] || marker
156
+ end
157
+
158
+ # The markers of the items the line is indented into, not deeper.
159
+ def with_marker(line, marker)
160
+ prefixes = marker.scan(/ *(?:[-*+]|\d+\.) /).each_with_object([]) { |piece, all| all << "#{all.last}#{piece}" }
161
+ prefix = prefixes.reverse.find { |candidate| line.start_with?(" " * candidate.length) && !line[candidate.length..].match?(/\A\s/) }
162
+ prefix ? prefix + line[prefix.length..] : line
163
+ end
164
+ end
165
+ end
166
+ end