html2rss 0.32.4 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -25
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +127 -53
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +50 -7
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +55 -51
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +111 -42
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
data/lib/html2rss/sst/text.rb
CHANGED
|
@@ -4,78 +4,114 @@ module Html2rss
|
|
|
4
4
|
module SST
|
|
5
5
|
##
|
|
6
6
|
# Visible-text extraction for SST nodes (port of Navigator::TextExtractor).
|
|
7
|
+
#
|
|
8
|
+
# Block children separate by a newline (or the given separator); other neighbours by one space,
|
|
9
|
+
# except where an inline element embedded in direct text touches it with no whitespace between them
|
|
10
|
+
# in the source: +Straßenbah<span>n</span> für+ stays one word. Two adjacent elements, and an element
|
|
11
|
+
# opening or closing its parent's text (+<span>Live</span>Headline+), always separate, since sites
|
|
12
|
+
# lay out such spans as blocks with CSS.
|
|
7
13
|
module Text
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
# One piece of a node's text: direct text or one child's text, whether it is a block, and whether it
|
|
15
|
+
# glues to the piece before it (no whitespace between them in the source).
|
|
16
|
+
Fragment = Data.define(:text, :block, :glue)
|
|
17
|
+
private_constant :Fragment
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
##
|
|
21
|
+
# @param node [Node]
|
|
22
|
+
# @param separator [String]
|
|
23
|
+
# @param exclude [Array<Node>, nil]
|
|
24
|
+
# @return [String, nil]
|
|
25
|
+
def extract(node, separator: ' ', exclude: nil)
|
|
26
|
+
raise InvalidError, 'node is required' unless node
|
|
27
|
+
|
|
28
|
+
excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
|
|
29
|
+
join_fragments(fragments(node, separator, excluded), separator)
|
|
22
30
|
end
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
own = node.own_text.to_s.gsub(/\s+/, ' ').strip
|
|
26
|
-
parts = [own, *parts] unless own.empty?
|
|
27
|
-
return if parts.empty?
|
|
32
|
+
private
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
# Ordered fragments: lead text, then each shown child followed by the text after it.
|
|
35
|
+
def fragments(node, separator, excluded)
|
|
36
|
+
raws = [node.lead_text, *node.tails]
|
|
37
|
+
pieces = [direct_fragment(raws.first, false)]
|
|
38
|
+
node.children.each_with_index do |child, index|
|
|
39
|
+
next pieces << direct_fragment(raws[index + 1], false) unless shown?(child, excluded)
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
next unless visible_child?(child)
|
|
41
|
+
pieces.push(*shown_fragments(child, raws, index, separator, excluded))
|
|
42
|
+
end
|
|
43
|
+
pieces
|
|
44
|
+
end
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
# A shown child and the direct text after it. Only a child embedded in direct text (text on both
|
|
47
|
+
# sides) glues to it, where they meet with no whitespace between them: a leading or trailing span
|
|
48
|
+
# beside text (+<span>Live</span>Headline+) is a styled label more often than part of a word.
|
|
49
|
+
def shown_fragments(child, raws, index, separator, excluded)
|
|
50
|
+
before = raws[index].to_s
|
|
51
|
+
after = raws[index + 1].to_s
|
|
52
|
+
embedded = !before.empty? && !after.empty?
|
|
53
|
+
[child_fragment(child, separator, excluded, embedded && before.match?(/\S\z/) && opens_word?(child)),
|
|
54
|
+
direct_fragment(after, embedded && closes_word?(child) && after.match?(/\A\S/))]
|
|
55
|
+
end
|
|
40
56
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
# Whether the raw text of +node+ starts with a non-space, descending into its first child while it
|
|
58
|
+
# has no lead text.
|
|
59
|
+
def opens_word?(node)
|
|
60
|
+
first = node.children.first
|
|
61
|
+
node.lead_text.empty? && first ? opens_word?(first) : node.lead_text.match?(/\A\S/)
|
|
44
62
|
end
|
|
45
|
-
end
|
|
46
|
-
module_function :collect_parts
|
|
47
|
-
private_class_method :collect_parts
|
|
48
|
-
|
|
49
|
-
def child_text_and_block(child, separator, excluded)
|
|
50
|
-
text = if child.children.empty?
|
|
51
|
-
child.own_text.gsub(/\s+/, ' ').strip
|
|
52
|
-
else
|
|
53
|
-
extract(child, separator:, exclude: excluded&.keys).to_s.strip
|
|
54
|
-
end
|
|
55
|
-
text = "- #{text}" if child.name == :li && !text.empty?
|
|
56
|
-
[text, Tags::BLOCK_NAMES.include?(child.name)]
|
|
57
|
-
end
|
|
58
|
-
module_function :child_text_and_block
|
|
59
|
-
private_class_method :child_text_and_block
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
# Whether the raw text of +node+ ends with a non-space, descending into its last child while no
|
|
65
|
+
# text follows it.
|
|
66
|
+
def closes_word?(node)
|
|
67
|
+
raw = node.tails.last || node.own_text
|
|
68
|
+
last = node.children.last
|
|
69
|
+
raw.empty? && last ? closes_word?(last) : raw.match?(/\S\z/)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def join_fragments(pieces, separator)
|
|
73
|
+
last_block = nil
|
|
74
|
+
parts = pieces.each_with_object([]) do |piece, out|
|
|
75
|
+
next if piece.text.empty?
|
|
76
|
+
|
|
77
|
+
out << gap(separator, piece, last_block) unless last_block.nil?
|
|
78
|
+
out << piece.text
|
|
79
|
+
last_block = piece.block
|
|
80
|
+
end
|
|
81
|
+
parts.join unless parts.empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def gap(separator, piece, last_block)
|
|
85
|
+
return (separator == ' ' ? "\n" : separator) if piece.block || last_block
|
|
86
|
+
|
|
87
|
+
piece.glue ? '' : ' '
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def shown?(child, excluded) = !excluded&.[](child) && visible_child?(child)
|
|
91
|
+
|
|
92
|
+
def squish(text) = text.to_s.gsub(/\s+/, ' ').strip
|
|
93
|
+
|
|
94
|
+
def direct_fragment(raw, glue) = Fragment.new(squish(raw), false, glue)
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
def child_fragment(child, separator, excluded, glue)
|
|
97
|
+
Fragment.new(child_text(child, separator, excluded), Tags::BLOCK_NAMES.include?(child.name), glue)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A leaf's text is read directly (no fragments to join), the hot path of every text read.
|
|
101
|
+
def child_text(child, separator, excluded)
|
|
102
|
+
text = if child.children.empty?
|
|
103
|
+
squish(child.own_text)
|
|
66
104
|
else
|
|
67
|
-
|
|
105
|
+
extract(child, separator:, exclude: excluded&.keys).to_s
|
|
68
106
|
end
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
private_class_method :append_separator!
|
|
107
|
+
child.name == :li && !text.empty? ? "- #{text}" : text
|
|
108
|
+
end
|
|
72
109
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
110
|
+
def visible_child?(node)
|
|
111
|
+
!Tags::INVISIBLE_NAMES.include?(node.name) &&
|
|
112
|
+
!(node.name == :a && node.attrs.href&.start_with?('#'))
|
|
113
|
+
end
|
|
76
114
|
end
|
|
77
|
-
module_function :visible_child?
|
|
78
|
-
private_class_method :visible_child?
|
|
79
115
|
end
|
|
80
116
|
end
|
|
81
117
|
end
|
data/lib/html2rss/sst.rb
CHANGED
data/lib/html2rss/status.rb
CHANGED
|
@@ -13,6 +13,13 @@ module Html2rss
|
|
|
13
13
|
# Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
|
|
14
14
|
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
15
15
|
class Status
|
|
16
|
+
# Raised when telemetry fields fail validation at construction.
|
|
17
|
+
class InvalidError < Html2rss::ContractError; end
|
|
18
|
+
|
|
19
|
+
# Keys kept in {#to_h} even when zero or empty.
|
|
20
|
+
ALWAYS_REPORTED = %i[version dedup_dropped].freeze
|
|
21
|
+
private_constant :ALWAYS_REPORTED
|
|
22
|
+
|
|
16
23
|
class << self
|
|
17
24
|
##
|
|
18
25
|
# Builds status from extracted articles and scrape telemetry.
|
|
@@ -71,9 +78,9 @@ module Html2rss
|
|
|
71
78
|
version:, scraper_tallies:, dedup_dropped:, selected_strategy: nil, attempt_count: 0,
|
|
72
79
|
strategy_attempts: [], admission_drops: {}, entry_url: nil, scrape_url: nil, entry_resolution: nil
|
|
73
80
|
)
|
|
74
|
-
dedup =
|
|
75
|
-
attempts =
|
|
76
|
-
|
|
81
|
+
dedup = Option.integer(:dedup_dropped, dedup_dropped, min: 0)
|
|
82
|
+
attempts = Option.integer(:attempt_count, attempt_count, min: 0)
|
|
83
|
+
validate_selected_strategy!(selected_strategy, attempts)
|
|
77
84
|
|
|
78
85
|
super(
|
|
79
86
|
version: version.to_s.dup.freeze,
|
|
@@ -93,20 +100,8 @@ module Html2rss
|
|
|
93
100
|
# Observability hash for web (+scraper_status+). Omits empty/absent optional keys.
|
|
94
101
|
#
|
|
95
102
|
# @return [Hash{Symbol => Object}]
|
|
96
|
-
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- omit-empty optional keys stay explicit
|
|
97
103
|
def to_h
|
|
98
|
-
{
|
|
99
|
-
version:,
|
|
100
|
-
dedup_dropped:,
|
|
101
|
-
**(scraper_tallies.any? ? { scraper_tallies: } : {}),
|
|
102
|
-
**(selected_strategy.nil? ? {} : { selected_strategy: }),
|
|
103
|
-
**(attempt_count.positive? ? { attempt_count: } : {}),
|
|
104
|
-
**(strategy_attempts.any? ? { strategy_attempts: } : {}),
|
|
105
|
-
**(admission_drops.any? ? { admission_drops: } : {}),
|
|
106
|
-
**(entry_url ? { entry_url: } : {}),
|
|
107
|
-
**(scrape_url ? { scrape_url: } : {}),
|
|
108
|
-
**(entry_resolution ? { entry_resolution: } : {})
|
|
109
|
-
}
|
|
104
|
+
super.reject { |key, value| !ALWAYS_REPORTED.include?(key) && omit?(value) }
|
|
110
105
|
end
|
|
111
106
|
|
|
112
107
|
##
|
|
@@ -124,6 +119,14 @@ module Html2rss
|
|
|
124
119
|
|
|
125
120
|
private
|
|
126
121
|
|
|
122
|
+
def omit?(value)
|
|
123
|
+
case value
|
|
124
|
+
when nil then true
|
|
125
|
+
when Integer then value.zero?
|
|
126
|
+
else value.empty?
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
127
130
|
def marshal_dump
|
|
128
131
|
[version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count, strategy_attempts,
|
|
129
132
|
admission_drops, entry_url, scrape_url, entry_resolution]
|
|
@@ -133,16 +136,16 @@ module Html2rss
|
|
|
133
136
|
version, scraper_tallies, dedup_dropped, selected_strategy, attempt_count,
|
|
134
137
|
strategy_attempts, admission_drops, entry_url, scrape_url, entry_resolution = payload
|
|
135
138
|
initialize(version:, scraper_tallies:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
136
|
-
strategy_attempts:, admission_drops
|
|
139
|
+
strategy_attempts:, admission_drops:, entry_url:, scrape_url:,
|
|
137
140
|
entry_resolution:)
|
|
138
141
|
end
|
|
139
142
|
|
|
140
143
|
def freeze_tallies(tallies)
|
|
141
|
-
tallies.to_h
|
|
144
|
+
tallies.to_h { |name, count| [name.to_s, count] }.freeze
|
|
142
145
|
end
|
|
143
146
|
|
|
144
147
|
def freeze_attempts(attempts)
|
|
145
|
-
|
|
148
|
+
attempts.map { |attempt| attempt.dup.freeze }.freeze
|
|
146
149
|
end
|
|
147
150
|
|
|
148
151
|
def freeze_optional_string(value)
|
|
@@ -157,21 +160,14 @@ module Html2rss
|
|
|
157
160
|
case value
|
|
158
161
|
when Hash, FeedResolution::Diag then value.to_h
|
|
159
162
|
else
|
|
160
|
-
raise
|
|
163
|
+
raise InvalidError, "entry_resolution must be a Hash or FeedResolution::Diag, got #{value.class}"
|
|
161
164
|
end.transform_keys(&:to_sym).freeze
|
|
162
165
|
end
|
|
163
166
|
|
|
164
|
-
def
|
|
165
|
-
|
|
166
|
-
raise
|
|
167
|
-
|
|
168
|
-
unless selected_strategy.nil? || selected_strategy.is_a?(Symbol)
|
|
169
|
-
raise ArgumentError, 'selected_strategy must be a Symbol or nil'
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
return unless selected_strategy && attempts < 1
|
|
173
|
-
|
|
174
|
-
raise ArgumentError, 'attempt_count must be >= 1 when selected_strategy is set'
|
|
167
|
+
def validate_selected_strategy!(selected_strategy, attempts)
|
|
168
|
+
return if selected_strategy.nil?
|
|
169
|
+
raise InvalidError, 'selected_strategy must be a Symbol or nil' unless selected_strategy.is_a?(Symbol)
|
|
170
|
+
raise InvalidError, 'attempt_count must be >= 1 when selected_strategy is set' if attempts < 1
|
|
175
171
|
end
|
|
176
172
|
end
|
|
177
173
|
end
|
|
@@ -20,7 +20,7 @@ module Html2rss
|
|
|
20
20
|
##
|
|
21
21
|
# @param name [Symbol, String]
|
|
22
22
|
# @return [SurfaceCategory]
|
|
23
|
-
def self.[](name) = ALL.fetch(name.to_sym) { raise
|
|
23
|
+
def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown surface category: #{name.inspect}" }
|
|
24
24
|
|
|
25
25
|
##
|
|
26
26
|
# @return [Boolean]
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# Syndication
|
|
2
2
|
|
|
3
|
-
Native RSS/Atom discovery and parse for auto-source promotion and direct feed URLs.
|
|
3
|
+
Native RSS/Atom/RDF discovery and parse for auto-source promotion and direct feed URLs.
|
|
4
4
|
|
|
5
5
|
## Ownership
|
|
6
6
|
|
|
7
7
|
| Concern | Owner |
|
|
8
8
|
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
9
|
-
| Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery`
|
|
10
|
-
|
|
|
9
|
+
| Head `rel=alternate` (audio-hinted first on a podcast page, after the path guesses elsewhere) + common path probes + status-gated feedish | `Syndication::Discovery` |
|
|
10
|
+
| Native feed of a fetched page for Recon, Capture and Test | `Syndication::Discovery.native_feed_url` (one entry point: the page response they already hold, at the URL a redirect landed on) |
|
|
11
|
+
| Cross-origin permission for a page's advertised feed | `RequestService::Policy` (proof: `RequestService::FeedAdvertisement`) |
|
|
12
|
+
| RSS 2.0 / RSS 1.0 (RDF) / Atom → `Syndication::Feed` (article hashes + channel facts); item URL: an http(s) link, else an http(s) guid, else the first audio/video file | `Syndication::Parser` |
|
|
13
|
+
| Item media files and image (enclosure, Media RSS, iTunes) → item | `Syndication::ItemMedia` (called by `Parser`; http(s) only via `Url.resolve_web`; sizes, durations and player pages via `Article::Enclosure`) |
|
|
14
|
+
| Channel facts (author, `itunes:category`, `itunes:explicit`) → `Feed#channel` | `Syndication::ChannelFacts` (called by `Parser` on the same parse; precedence over page meta is `Channel`'s) |
|
|
11
15
|
| Strict head-only link parse (no path guessing) | `Html::FeedLink` |
|
|
12
16
|
| Response Content-Type / body feed sniff | `RequestService::Response#feed_response?` (sole sniff owner; Discovery only adds HTTP status gate) |
|
|
13
17
|
|
|
@@ -15,7 +19,9 @@ Native RSS/Atom discovery and parse for auto-source promotion and direct feed UR
|
|
|
15
19
|
|
|
16
20
|
- Entry-URL tournament / listing resolution (`FeedResolution`)
|
|
17
21
|
- Page recon / surface classification (`PageRecon`)
|
|
18
|
-
- Feed channel metadata for output (`Channel` / `FeedBuilder`)
|
|
19
|
-
- Ranking or quality scoring of competing feeds beyond first feedish hit
|
|
22
|
+
- Feed channel metadata for output and its precedence (`Channel` / `FeedBuilder`); Syndication only reads what a feed states
|
|
23
|
+
- Ranking or quality scoring of competing feeds beyond the audio-hint order and first feedish hit
|
|
20
24
|
|
|
21
|
-
Discovery stops at the first
|
|
25
|
+
Discovery stops at the first feedish URL (lazy probe). Head alternates are fetched as `:advertised_feed` with the page's `FeedAdvertisement`, so a feed on another host (a podcast platform) is followed. An advertised `http:` feed off the page origin is fetched as `https:` or skipped. Path guesses stay same-origin. Alternates keep document order, except on a podcast page: `podcast`, `episode` or `audio` (word-ish, plural allowed) in its URL host, path or `<title>`, an `og:type` of `music.*` or `video.*`, or a JSON-LD `@type` `PodcastSeries`, `PodcastEpisode` or `AudioObject` anywhere (regex on the script text, no JSON parse). There, an alternate whose title or URL hints audio (the same words plus `mp3`, `m4a`, `aac`, `opus`) goes first. On any other page those rank after the same-origin path guesses, and other alternates still precede them. A news front page that advertises only its podcast ships its page items or a same-origin feed, not the episodes; the podcast feed is its last candidate.
|
|
26
|
+
|
|
27
|
+
An item without a link takes its guid (Atom `id`, RDF `rdf:about`) when that is an absolute http(s) URL, whatever `isPermaLink` says, else its first audio or video file. Relative, `urn:` and `tag:` ids name no page. An item with none of these is skipped. Links, files and images resolve against the feed URL through `Url.resolve_web`, so a `javascript:`, `data:` or `ftp:` one counts as absent. A followed feed's items may link to the feed's own domain as well as the page's (AutoSource `Cleanup`, which owns that rule). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Syndication
|
|
5
|
+
##
|
|
6
|
+
# Scans raw HTML for +<link rel="alternate">+ hints that point at RSS/Atom feeds.
|
|
7
|
+
module AlternateLinks
|
|
8
|
+
LINK_TAG_RE = /<link\b[^>]*>/i
|
|
9
|
+
HREF_RE = /\bhref\s*=\s*["']([^"']+)["']/i
|
|
10
|
+
TYPE_RE = /\btype\s*=\s*["']([^"']+)["']/i
|
|
11
|
+
REL_RE = /\brel\s*=\s*["']([^"']+)["']/i
|
|
12
|
+
private_constant :LINK_TAG_RE, :HREF_RE, :TYPE_RE, :REL_RE
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
##
|
|
16
|
+
# @param html [String] raw HTML
|
|
17
|
+
# @return [Array<String>] hrefs of alternate links that look like feeds
|
|
18
|
+
def hrefs(html)
|
|
19
|
+
html.scan(LINK_TAG_RE).filter_map { |tag| feed_href(tag) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def feed_href(tag)
|
|
25
|
+
return unless tag[REL_RE, 1].to_s.downcase.split(/\s+/).include?('alternate')
|
|
26
|
+
|
|
27
|
+
href = tag[HREF_RE, 1]
|
|
28
|
+
return if href.nil? || href.empty?
|
|
29
|
+
|
|
30
|
+
href.strip if syndication_type?(tag[TYPE_RE, 1]) || href.match?(/rss|atom|feed|\.xml/i)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def syndication_type?(type)
|
|
34
|
+
type.to_s.downcase.match?(/rss|atom|rdf/)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rss'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Syndication
|
|
7
|
+
ChannelFacts = Data.define(:author, :categories, :explicit)
|
|
8
|
+
|
|
9
|
+
##
|
|
10
|
+
# What a native feed states about its show or publication, for {Html2rss::Channel}: the author
|
|
11
|
+
# name, the iTunes categories and the explicit flag. A fact the feed does not state stays unknown
|
|
12
|
+
# (+nil+, no categories); nothing is guessed.
|
|
13
|
+
#
|
|
14
|
+
# RSS 2.0: the author is +itunes:author+, else the name part of +managingEditor+
|
|
15
|
+
# (+editor@example.com (Name)+ gives +Name+, a plain name is kept, a bare email address is no name);
|
|
16
|
+
# each +itunes:category+ is its +text+ followed by its nested subcategory texts; +itunes:explicit+
|
|
17
|
+
# +true+, +yes+ or +explicit+ is true, +false+, +no+ or +clean+ false, any other value unstated.
|
|
18
|
+
# Atom: the author is the feed +author+ name. RSS 1.0 (RDF) states none of these.
|
|
19
|
+
#
|
|
20
|
+
# @!attribute [r] author
|
|
21
|
+
# @return [String, nil] the channel author name
|
|
22
|
+
# @!attribute [r] categories
|
|
23
|
+
# @return [Array<Array<String>>] each category followed by its subcategories
|
|
24
|
+
# @!attribute [r] explicit
|
|
25
|
+
# @return [Boolean, nil] whether the feed declares explicit content; nil when unstated
|
|
26
|
+
class ChannelFacts
|
|
27
|
+
# +itunes:explicit+ values, lowercased, to the flag they state.
|
|
28
|
+
EXPLICIT = {
|
|
29
|
+
'true' => true, 'yes' => true, 'explicit' => true,
|
|
30
|
+
'false' => false, 'no' => false, 'clean' => false
|
|
31
|
+
}.freeze
|
|
32
|
+
# A +managingEditor+ written as an email address followed by the name in parentheses.
|
|
33
|
+
NAMED_EMAIL = /\A\s*[^\s@]+@\S+\s+\((.+)\)\s*\z/
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
##
|
|
37
|
+
# @param feed [RSS::Rss, RSS::RDF, RSS::Atom::Feed, nil] a document the +rss+ library parsed
|
|
38
|
+
# @return [ChannelFacts, nil] the facts an RSS 2.0 or Atom feed states; nil for any other document
|
|
39
|
+
def from(feed)
|
|
40
|
+
case feed
|
|
41
|
+
when RSS::Rss then from_rss(feed.channel)
|
|
42
|
+
when RSS::Atom::Feed then new(author: feed.author&.name&.content)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def from_rss(channel)
|
|
49
|
+
new(author: rss_author(channel),
|
|
50
|
+
categories: channel.itunes_categories.map { [_1.text, *_1.itunes_categories.map(&:text)] },
|
|
51
|
+
explicit: EXPLICIT[channel.itunes_explicit.to_s.strip.downcase])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Blank and padded values are normalized by {#initialize}.
|
|
55
|
+
def rss_author(channel)
|
|
56
|
+
author = channel.itunes_author.to_s
|
|
57
|
+
return author if author.match?(/\S/)
|
|
58
|
+
|
|
59
|
+
editor = channel.managingEditor.to_s
|
|
60
|
+
editor[NAMED_EMAIL, 1] || (editor unless editor.include?('@'))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# @param author [String, nil] blank becomes nil
|
|
66
|
+
# @param categories [Array<Array<String, nil>>] a category without a name is dropped, blank
|
|
67
|
+
# subcategories are skipped
|
|
68
|
+
# @param explicit [Boolean, nil]
|
|
69
|
+
def initialize(author: nil, categories: [], explicit: nil)
|
|
70
|
+
super(author: present(author), categories: categories.filter_map { path(_1) }.freeze, explicit:)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def path((name, *subcategories))
|
|
76
|
+
return unless (name = present(name))
|
|
77
|
+
|
|
78
|
+
[name, *subcategories.filter_map { present(_1) }].freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def present(value)
|
|
82
|
+
text = value.to_s.strip
|
|
83
|
+
text.freeze unless text.empty?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|