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.
Files changed (202) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -25
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +127 -53
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
@@ -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
- module_function
9
-
10
- ##
11
- # @param node [Node]
12
- # @param separator [String]
13
- # @param exclude [Array<Node>, nil]
14
- # @return [String, nil]
15
- def extract(node, separator: ' ', exclude: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
16
- raise ArgumentError, 'node is required' unless node
17
-
18
- excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
19
- if node.children.empty?
20
- text = node.own_text.to_s.gsub(/\s+/, ' ').strip
21
- return text.empty? ? nil : text
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
- parts = collect_parts(node, separator, excluded)
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
- parts.join.squeeze(' ').strip
30
- end
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
- def collect_parts(node, separator, excluded)
33
- last_block = false
34
- node.children.each_with_object([]) do |child, parts|
35
- next if excluded&.[](child)
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
- text, block = child_text_and_block(child, separator, excluded)
39
- next if text.empty?
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
- append_separator!(parts, separator, block, last_block)
42
- parts << text
43
- last_block = block
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
- def append_separator!(parts, separator, is_block, last_was_block)
62
- return if parts.empty?
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
- parts << if is_block || last_was_block
65
- (separator == ' ' ? "\n" : separator)
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
- end
70
- module_function :append_separator!
71
- private_class_method :append_separator!
107
+ child.name == :li && !text.empty? ? "- #{text}" : text
108
+ end
72
109
 
73
- def visible_child?(node)
74
- !Tags::INVISIBLE_NAMES.include?(node.name) &&
75
- !(node.name == :a && node.attrs.href&.start_with?('#'))
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
@@ -4,5 +4,7 @@ module Html2rss
4
4
  ##
5
5
  # Simplified Semantic Tree namespace for the heuristic auto-source pipeline.
6
6
  module SST
7
+ # Raised when a tree node, attrs, text or document is built from invalid parts.
8
+ class InvalidError < Html2rss::ContractError; end
7
9
  end
8
10
  end
@@ -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 = Integer(dedup_dropped)
75
- attempts = Integer(attempt_count)
76
- validate_counters!(dedup:, attempts:, selected_strategy:)
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: admission_drops || {}, entry_url:, scrape_url:,
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.transform_keys(&:to_s).transform_values { |count| Integer(count) }.freeze
144
+ tallies.to_h { |name, count| [name.to_s, count] }.freeze
142
145
  end
143
146
 
144
147
  def freeze_attempts(attempts)
145
- Array(attempts).map { |attempt| attempt.to_h.freeze }.freeze
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 ArgumentError, "entry_resolution must be a Hash or FeedResolution::Diag, got #{value.class}"
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 validate_counters!(dedup:, attempts:, selected_strategy:)
165
- raise ArgumentError, 'dedup_dropped must be >= 0' if dedup.negative?
166
- raise ArgumentError, 'attempt_count must be >= 0' if attempts.negative?
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 ArgumentError, "unknown surface category: #{name.inspect}" }
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
- | RSS 2.0 / Atom → article hashes | `Syndication::Parser` |
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 same-origin feedish URL (lazy probe). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
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