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
@@ -14,11 +14,10 @@ module Html2rss
14
14
  # @param article_tag [Nokogiri::XML::Node] article-like container to extract from
15
15
  # @param base_url [String, Html2rss::Url] base url used to resolve relative links
16
16
  # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
17
- # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
18
17
  # @param time_zone [String] channel time zone for naive leftover dates
19
18
  # @return [Hash{Symbol => Object}] extracted article attributes
20
- def call(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false, time_zone: 'UTC')
21
- new(article_tag, base_url:, selected_anchor:, fallback_anchorless:, time_zone:).call
19
+ def call(article_tag, base_url:, selected_anchor: nil, time_zone: 'UTC')
20
+ new(article_tag, base_url:, selected_anchor:, time_zone:).call
22
21
  end
23
22
  end
24
23
 
@@ -26,26 +25,26 @@ module Html2rss
26
25
  # @param article_tag [Nokogiri::XML::Node] article-like container to extract from
27
26
  # @param base_url [String, Html2rss::Url] base url used to resolve relative links
28
27
  # @param selected_anchor [Nokogiri::XML::Node, nil] explicit primary anchor for the container
29
- # @param fallback_anchorless [Boolean] whether to fall back to anchorless extraction
30
28
  # @param time_zone [String] channel time zone for naive leftover dates
31
- def initialize(article_tag, base_url:, selected_anchor: nil, fallback_anchorless: false, time_zone: 'UTC')
32
- raise ArgumentError, 'article_tag is required' unless article_tag
29
+ def initialize(article_tag, base_url:, selected_anchor: nil, time_zone: 'UTC')
30
+ raise InvalidError, 'article_tag is required' unless article_tag
33
31
 
34
32
  @article_tag = article_tag
35
33
  @base_url = base_url
36
34
  @selected_anchor = selected_anchor
37
- @fallback_anchorless = fallback_anchorless
38
35
  @time_zone = time_zone
39
36
  end
40
37
 
41
38
  # @return [Hash{Symbol => Object}] extracted article attributes
42
39
  def call # rubocop:disable Metrics/MethodLength
43
- title = extract_title
40
+ title = extract_headline
44
41
  lines = leftover_lines
45
42
  published_at = extract_published_at(lines)
46
43
  source, lines, published_at = parent_card_fields(title, lines, published_at)
47
44
  {
48
- title:,
45
+ # Composed here, not passed as a +kicker:+ key: Selectors' enhance merges keys one by one,
46
+ # so a separate kicker would prefix an explicitly selected title.
47
+ title: Article::Title.compose(title, kicker: title_pieces.kicker_text),
49
48
  url: extract_url,
50
49
  image: extract_image,
51
50
  description: ArticleRules::Description.from_lines(lines, title:),
@@ -63,55 +62,29 @@ module Html2rss
63
62
  def extract_url
64
63
  @extract_url ||= begin
65
64
  href = selected_anchor&.[]('href').to_s
66
-
67
- if href.empty?
68
- anchorless_url_fallback
69
- else
70
- Url.from_relative(href.split('#').first.strip, base_url)
71
- end
65
+ Url.from_relative(href.split('#').first, base_url) unless href.empty?
72
66
  end
73
67
  end
74
68
 
75
- def anchorless_url_fallback
76
- return unless @fallback_anchorless
69
+ def extract_headline
70
+ return first_text_title unless headline_source
77
71
 
78
- id = generate_id
79
- Url.from_relative("##{id}", base_url) if id
72
+ title_pieces.headline_text(headline_source)
80
73
  end
81
74
 
82
- # rubocop:disable-next Metrics/CyclomaticComplexity
83
- def extract_title
84
- source = heading || selected_anchor
85
- title_text = source ? Navigator.extract_visible_text(source) : fallback_anchorless_title
86
- return unless title_text
87
-
88
- kicker = kicker_node ? Navigator.extract_visible_text(kicker_node).to_s.strip : nil
89
- kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
90
- end
91
-
92
- def fallback_anchorless_title
93
- return unless @fallback_anchorless && selected_anchor.nil?
75
+ # Kicker and headline text ({TitlePieces}) of this card.
76
+ def title_pieces = @title_pieces ||= TitlePieces.new(article_tag, headline_source, anchor: selected_anchor)
94
77
 
78
+ def first_text_title
95
79
  text_node = article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }
96
80
  text_node&.text&.strip
97
81
  end
98
82
 
99
83
  def heading
100
- @heading ||= HeadingExtractor.call(
101
- article_tag,
102
- fallback_anchorless: @fallback_anchorless,
103
- selected_anchor:
104
- )
84
+ @heading ||= HeadingExtractor.call(article_tag, selected_anchor:)
105
85
  end
106
86
 
107
- def kicker_node
108
- @kicker_node ||= begin
109
- selector = '[data-tb-kicker], [class*="kicker"], [class*="eyebrow"], ' \
110
- '[class*="pre-title"], [class*="pretitle"], [class*="overline"]'
111
- node = article_tag.at_css(selector)
112
- node && heading && (node == heading || Navigator.descendant_of?(node, heading)) ? nil : node
113
- end
114
- end
87
+ def headline_source = heading || selected_anchor
115
88
 
116
89
  def leftover_lines
117
90
  leftover_lines_from(article_tag)
@@ -124,7 +97,7 @@ module Html2rss
124
97
  end
125
98
 
126
99
  def leftover_exclude_nodes_for(node)
127
- [heading, selected_anchor, kicker_node, *node.css('time')].compact
100
+ [heading, selected_anchor, title_pieces.kicker, *node.css('time')].compact
128
101
  end
129
102
 
130
103
  def heading_or_anchor_item?
@@ -186,8 +159,7 @@ module Html2rss
186
159
  article_tag,
187
160
  heading:,
188
161
  url: (selected_anchor ? extract_url : nil),
189
- selected_anchor:,
190
- fallback_anchorless: @fallback_anchorless
162
+ selected_anchor:
191
163
  )
192
164
  end
193
165
 
@@ -27,10 +27,10 @@ module Html2rss
27
27
  end
28
28
 
29
29
  ##
30
- # @param class_attr [String, nil]
30
+ # @param class_names [Array<String>] individual class tokens
31
31
  # @return [Boolean]
32
- def class_match?(class_attr)
33
- class_attr.to_s.split(/\s+/).any? { |token| class_token_match?(token) }
32
+ def class_match?(class_names)
33
+ class_names.any? { |token| class_token_match?(token) }
34
34
  end
35
35
 
36
36
  ##
@@ -7,24 +7,15 @@ module Html2rss
7
7
  # Leftover visible-text keep/drop for article descriptions.
8
8
  # Split once on block newlines; Date and Category consume the same keepers.
9
9
  module Description
10
- # CTA lines dropped by whole-line equality (case-insensitive).
11
- CTA = Set['read more', 'learn more'].freeze
12
- # Type-chip lines dropped when the whole leftover line is one of these.
13
- TYPE_CHIPS = Set['news article', 'press release', 'news'].freeze
10
+ # Owner of the read-more vocabulary; a whole line that is one is chrome, not a dek.
11
+ TEXT_CLASSIFIER = LinkDestination::TextClassifier.new
12
+ private_constant :TEXT_CLASSIFIER
14
13
  # Listing section names that are chrome, not a dek.
15
14
  SECTION_NAMES = Set['press releases'].freeze
16
15
  # Skip date detection / DateTime.parse above this length.
17
16
  MAX_DATE_CHARS = 128
18
17
  # Optional type-chip / clock separators (ASCII/en/em dash, pipe, bullet).
19
18
  CHIP_SEPARATORS = [' - ', ' – ', ' — ', ' | ', ' • '].freeze
20
- # Trailing clock plus optional AM/PM and one timezone/offset token (IANA, GMT/UTC offset, or abbrev).
21
- CLOCK_ZONE = %r{
22
- \A
23
- \d{1,2}:\d{2}(?::\d{2})?
24
- (?:\s*(?:AM|PM))?
25
- (?:\s+(?:[A-Za-z]+(?:/[A-Za-z_]+)+|(?:GMT|UTC)[+-]\d{1,2}(?::\d{2})?|[A-Za-z]{2,5}))?
26
- \z
27
- }ix
28
19
  # Whole-line date shapes after normalize (ISO, numeric, day-month, month-day).
29
20
  DATE_SHAPES = [
30
21
  /\A\d{4}-\d{1,2}-\d{1,2}(?:[T ]\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/i,
@@ -100,13 +91,13 @@ module Html2rss
100
91
  nil
101
92
  end
102
93
 
103
- def type_chip?(text) = TYPE_CHIPS.include?(text.downcase)
94
+ def type_chip?(text) = Article::Title::TYPE_MARKERS.include?(text.downcase)
104
95
 
105
- def clock_zone?(text) = text.match?(CLOCK_ZONE)
96
+ def clock_zone?(text) = Article::Title.clock_piece?(text)
106
97
 
107
98
  def chrome?(normalized, title:, type_chips:)
108
99
  key = normalized.downcase
109
- CTA.include?(key) || (type_chips && TYPE_CHIPS.include?(key)) ||
100
+ TEXT_CLASSIFIER.read_more?(normalized) || (type_chips && Article::Title::TYPE_MARKERS.include?(key)) ||
110
101
  SECTION_NAMES.include?(key) || normalized.end_with?(':') ||
111
102
  date_shaped?(normalized) || title_echo?(key, title)
112
103
  end
@@ -4,66 +4,94 @@ module Html2rss
4
4
  module Html
5
5
  module ArticleRules
6
6
  ##
7
- # DOM-agnostic enclosure URL/type normalization.
7
+ # The one rule for which HTML elements become enclosures, and their URL/type normalization.
8
+ #
9
+ # DOM-agnostic: {ArticleExtractor::EnclosureExtractor} (Nokogiri) and {SstArticleExtractor} (SST)
10
+ # only visit {ELEMENT_NAMES}, adapt their node API to {.from_element} and pass the result through
11
+ # {.unique}. Images (+img[src]+), players (+audio[src]+, +video[src]+), a player's +source[src]+ with
12
+ # its +type+, and file links ({FILE_LINK}) qualify; an +iframe+ (embed player page) never does,
13
+ # and only an http(s) URL is one (no +data:+, +blob:+, +javascript:+ or +mailto:+).
8
14
  module Enclosure
9
- # Href path suffixes treated as download/archive enclosure links.
10
- ARCHIVE_HREF_SUFFIXES = %w[.pdf .zip .tar.gz .tgz].freeze
15
+ # A link to a file: its path (before any query or fragment) ends, in any case, in a downloadable
16
+ # document or archive extension (+.pdf+, +.zip+, +.tar.gz+, +.tgz+) or an audio or video one
17
+ # ({Article::Enclosure::MEDIA_TYPES}, which also types it), such as an episode's download link.
18
+ FILE_LINK = /
19
+ \A[^?\#]*\.
20
+ (?:pdf|zip|tar\.gz|tgz|#{Regexp.union(Article::Enclosure::MEDIA_TYPES.keys).source})
21
+ (?:[?\#]|\z)
22
+ /ix
23
+ # Element names that may carry an enclosure.
24
+ ELEMENT_NAMES = %w[img audio video source a].freeze
25
+ # Players whose own +src+ and inner +source+ elements name a media file.
26
+ MEDIA_ELEMENTS = %w[audio video].freeze
11
27
 
12
28
  class << self
13
29
  ##
14
- # @param href [String]
15
- # @return [Boolean]
16
- def archive_href?(href)
17
- ARCHIVE_HREF_SUFFIXES.any? { |suffix| href.to_s.end_with?(suffix) }
18
- end
19
-
20
- ##
21
- # @param src [String]
30
+ # The enclosure an HTML element stands for, if any. A player's file carries the player as its
31
+ # kind, so an untyped file without a media extension is still audio or video
32
+ # ({Article::Enclosure::KIND_TYPES}).
33
+ #
34
+ # @param name [String] element name (lower case)
22
35
  # @param base_url [String, Html2rss::Url]
36
+ # @param src [String, nil] the element's +src+ attribute
37
+ # @param href [String, nil] the element's +href+ attribute
38
+ # @param type [String, nil] the element's +type+ attribute
39
+ # @yieldreturn [String, nil] the name of the nearest {MEDIA_ELEMENTS} player around the element (an
40
+ # ancestor, not only the parent: the HTML4 parser nests sibling +source+ elements), or nil; asked
41
+ # only for a +source+
23
42
  # @return [Hash{Symbol => Object}, nil]
24
- def from_image(src, base_url)
25
- return if src.to_s.empty?
26
-
27
- abs = Url.from_relative(src, base_url)
28
- { url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'image/jpeg') }
43
+ def from_element(name, base_url, src:, href:, type:)
44
+ case name
45
+ when 'img' then from_image(src, base_url)
46
+ when 'audio', 'video' then from_media(src, type, base_url, kind: name.to_sym)
47
+ when 'source' then from_source(src, type, base_url, yield)
48
+ when 'a' then from_anchor(href, base_url)
49
+ end
29
50
  end
30
51
 
31
52
  ##
32
- # @param src [String]
33
- # @param type [String, nil]
34
- # @param base_url [String, Html2rss::Url]
35
- # @return [Hash{Symbol => Object}, nil]
36
- def from_media(src, type, base_url)
37
- return if src.to_s.empty?
53
+ # @param href [String, nil] an anchor's +href+
54
+ # @return [Boolean] whether it names a file {.from_element} turns into an enclosure ({FILE_LINK})
55
+ def file_link?(href) = href.to_s.match?(FILE_LINK)
38
56
 
39
- { url: Url.from_relative(src, base_url), type: }
57
+ ##
58
+ # @param src [String, nil]
59
+ # @param type [String, nil] declared MIME type; nil lets {Article::Enclosure#type} guess it
60
+ # @param base_url [String, Html2rss::Url, nil]
61
+ # @param kind [Symbol, nil] +:audio+ or +:video+ when the source proves the file's kind
62
+ # @return [Hash{Symbol => Object}, nil] nil unless +src+ resolves to an http(s) URL ({Url.resolve_web})
63
+ def from_media(src, type, base_url, kind: nil)
64
+ return unless (url = Url.resolve_web(src, base_url))
65
+
66
+ { url:, type:, kind: }.compact
40
67
  end
41
68
 
42
69
  ##
43
- # @param src [String]
44
- # @param base_url [String, Html2rss::Url]
45
- # @return [Hash{Symbol => Object}, nil]
46
- def from_iframe(src, base_url)
47
- return if src.to_s.empty?
70
+ # One enclosure per URL, at its first position in document order; a duplicate declaring a type
71
+ # wins over an untyped one (+<video src=x><source src=x type=video/webm>+).
72
+ #
73
+ # @param enclosures [Array<Hash{Symbol => Object}>] {.from_element} results in document order
74
+ # @return [Array<Hash{Symbol => Object}>]
75
+ def unique(enclosures)
76
+ enclosures.group_by { _1[:url] }.map { |_url, same| same.find { _1[:type] } || same.first }
77
+ end
78
+
79
+ private
80
+
81
+ def from_image(src, base_url)
82
+ return unless (url = Url.resolve_web(src, base_url))
48
83
 
49
- abs = Url.from_relative(src, base_url)
50
- { url: abs, type: Article::Enclosure.guess_content_type_from_url(abs, default: 'text/html') }
84
+ { url:, type: Article::Enclosure.guess_content_type_from_url(url, default: 'image/jpeg') }
85
+ end
86
+
87
+ def from_source(src, type, base_url, player)
88
+ from_media(src, type, base_url, kind: player.to_sym) if player
51
89
  end
52
90
 
53
- ##
54
- # @param href [String]
55
- # @param base_url [String, Html2rss::Url]
56
- # @return [Hash{Symbol => Object}, nil]
57
91
  def from_anchor(href, base_url)
58
- return if href.to_s.empty?
92
+ return unless file_link?(href) && (url = Url.resolve_web(href, base_url))
59
93
 
60
- abs = Url.from_relative(href, base_url)
61
- type = if href.end_with?('.pdf')
62
- Article::Enclosure.guess_content_type_from_url(abs)
63
- else
64
- 'application/zip'
65
- end
66
- { url: abs, type: }
94
+ { url: }
67
95
  end
68
96
  end
69
97
  end
@@ -14,10 +14,16 @@ module Html2rss
14
14
  MAX_ATTR_CHARS = 4_096
15
15
  # Optional quoting around a CSS `url(...)` value.
16
16
  QUOTES = ['"', "'"].freeze
17
- # Srcset width/height descriptor suffixes.
18
- UNITS = %w[w h].freeze
19
- # HTML/CSS whitespace treated as srcset token separators.
20
- WS = [' ', "\t", "\n", "\r", "\f"].freeze
17
+ # Srcset width/height descriptor suffix bytes (`w`, `h`).
18
+ UNIT_BYTES = [119, 104].freeze
19
+ # HTML/CSS whitespace bytes (space, tab, LF, CR, FF) treated as srcset token separators.
20
+ WS_BYTES = [32, 9, 10, 13, 12].freeze
21
+ # ASCII `,`.
22
+ COMMA = 44
23
+ # ASCII `0`.
24
+ ZERO = 48
25
+ # ASCII `9`.
26
+ NINE = 57
21
27
 
22
28
  class << self
23
29
  ##
@@ -29,6 +35,11 @@ module Html2rss
29
35
  by_size[by_size.keys.max]
30
36
  end
31
37
 
38
+ ##
39
+ # @param src [String, nil] an image URL attribute, e.g. a video +poster+
40
+ # @return [Boolean] whether it names a fetchable image: not blank, not an inline +data:+ URI
41
+ def remote_src?(src) = src.to_s.match?(/\S/) && !src.start_with?('data:')
42
+
32
43
  ##
33
44
  # @param style_strings [Array<String>] CSS style attribute values
34
45
  # @return [String, nil] longest non-data background url
@@ -54,23 +65,24 @@ module Html2rss
54
65
 
55
66
  # URL token + `Nw`/`Nh` pairs. Commas may appear inside URLs; a candidate
56
67
  # boundary is whitespace before a width/height descriptor.
68
+ # Scans bytes: every separator, digit and unit is ASCII, and UTF-8
69
+ # continuation bytes (0x80+) never equal an ASCII byte, so byte offsets are safe.
70
+ # `getbyte` returns nil past the end, which every predicate treats as no match.
57
71
  def each_srcset_pair(string) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
58
72
  i = 0
59
- len = string.length
73
+ len = string.bytesize
60
74
  while i < len
61
- i += 1 while i < len && sep?(string[i])
62
- break if i >= len
63
-
75
+ i += 1 while sep?(string.getbyte(i))
64
76
  start = i
65
- i += 1 while i < len && !ws?(string[i])
66
- url = string[start, i - start]
77
+ i += 1 until ws?(string.getbyte(i)) || i >= len
78
+ url = string.byteslice(start, i - start)
67
79
 
68
- i += 1 while i < len && ws?(string[i])
80
+ i += 1 while ws?(string.getbyte(i))
69
81
  size_start = i
70
- i += 1 while i < len && string[i].between?('0', '9')
71
- next unless i > size_start && i < len && UNITS.include?(string[i])
82
+ i += 1 while digit?(string.getbyte(i))
83
+ next unless i > size_start && UNIT_BYTES.include?(string.getbyte(i))
72
84
 
73
- yield url, string[size_start, i - size_start].to_i
85
+ yield url, string.byteslice(size_start, i - size_start).to_i
74
86
  i += 1
75
87
  end
76
88
  end
@@ -99,9 +111,11 @@ module Html2rss
99
111
  string[start, close - start] if close
100
112
  end
101
113
 
102
- def sep?(char) = ws?(char) || char == ','
114
+ def sep?(byte) = ws?(byte) || byte == COMMA
115
+
116
+ def ws?(byte) = WS_BYTES.include?(byte)
103
117
 
104
- def ws?(char) = WS.include?(char)
118
+ def digit?(byte) = byte&.between?(ZERO, NINE)
105
119
  end
106
120
  end
107
121
  end
@@ -2,15 +2,16 @@
2
2
 
3
3
  module Html2rss
4
4
  module Html
5
- FeedLink = Data.define(:href, :mime_type)
5
+ FeedLink = Data.define(:href, :mime_type, :title)
6
6
 
7
- # A native RSS/Atom hint from a document head +link[rel=alternate]+.
7
+ # A native RSS/Atom/RDF hint from a document head +link[rel=alternate]+.
8
8
  #
9
9
  # Does not guess +/feed+ or +/rss.xml+ paths — that stays in configs +probe_rss+.
10
+ # +title+ is the link's stripped +title+ attribute (empty when absent).
10
11
  class FeedLink
11
12
  class << self
12
13
  ##
13
- # Collects RSS/Atom +rel=alternate+ links from +doc+'s +head+.
14
+ # Collects RSS/Atom/RDF +rel=alternate+ links from +doc+'s +head+.
14
15
  #
15
16
  # @param doc [Nokogiri::XML::Node] parsed HTML document
16
17
  # @return [Array<FeedLink>] native feed hints, omitting blanks and non-feed types
@@ -21,16 +22,13 @@ module Html2rss
21
22
  private
22
23
 
23
24
  def from_node(node)
24
- href = node['href']&.strip
25
+ href = node['href'].strip # the head selector guarantees an href attribute
25
26
  mime_type = feed_mime_type(node['type'])
26
- new(href:, mime_type:) unless href.nil? || href.empty? || mime_type.nil?
27
+ new(href:, mime_type:, title: node['title'].to_s.strip) unless href.empty? || mime_type.nil?
27
28
  end
28
29
 
29
30
  def feed_mime_type(type)
30
- return Probe::APPLICATION_RSS_XML if Probe.mime_match?(type, Probe::APPLICATION_RSS_XML)
31
- return Probe::APPLICATION_ATOM_XML if Probe.mime_match?(type, Probe::APPLICATION_ATOM_XML)
32
-
33
- nil
31
+ Probe::FEED_MIME_TYPES.find { |mime| Probe.mime_match?(type, mime) }
34
32
  end
35
33
  end
36
34
  end
@@ -5,7 +5,9 @@ module Html2rss
5
5
  class Navigator
6
6
  ##
7
7
  # TextExtractor extracts visible text from DOM elements, preserving lists
8
- # and block spacing while sanitizing white spaces.
8
+ # and block spacing while sanitizing white spaces. Same joining rule as {SST::Text}: an inline element
9
+ # embedded in direct text and touching it stays in its word (+Straßenbah<span>n</span> für+), while two
10
+ # adjacent elements and an element opening or closing its parent's text always separate.
9
11
  class TextExtractor
10
12
  # HTML block elements that trigger line breaks or special formatting.
11
13
  BLOCK_TAGS = %w[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
@@ -29,19 +31,21 @@ module Html2rss
29
31
 
30
32
  private
31
33
 
34
+ # +last+ is [node, block?] of the previous piece with text.
32
35
  def iterate_children(tag, separator, exclude_nodes)
33
- last = false
34
- tag.children.each_with_object([]) do |c, p|
35
- next if exclude_nodes&.include?(c) || !visible_child?(c)
36
+ last = nil
37
+ tag.children.each_with_object([]) do |c, parts|
38
+ text, block = process_child_node(c, separator, exclude_nodes) if shown?(c, exclude_nodes)
39
+ next if text.to_s.empty?
36
40
 
37
- text, block = process_child_node(c, separator, exclude_nodes)
38
- next if text.empty?
39
-
40
- append_separator!(p, separator, block, last)
41
- (p << text) && (last = block)
41
+ parts << gap(separator, block, last, c) if last
42
+ parts << text
43
+ last = [c, block]
42
44
  end
43
45
  end
44
46
 
47
+ def shown?(child, exclude_nodes) = !exclude_nodes&.include?(child) && visible_child?(child)
48
+
45
49
  def process_child_node(child, separator, exclude_nodes)
46
50
  child_text = get_child_text(child, separator, exclude_nodes)
47
51
  return ['', false] if child_text.empty?
@@ -58,16 +62,22 @@ module Html2rss
58
62
  end
59
63
  end
60
64
 
61
- def append_separator!(parts, separator, is_block, last_was_block)
62
- return if parts.empty?
65
+ def gap(separator, is_block, last, child)
66
+ return (separator == ' ' ? "\n" : separator) if is_block || last.last
63
67
 
64
- parts << if is_block || last_was_block
65
- (separator == ' ' ? "\n" : separator)
66
- else
67
- ' '
68
- end
68
+ touching?(last.first, child) ? '' : ' '
69
69
  end
70
70
 
71
+ # Whether direct text and an inline element meet as adjacent siblings with no whitespace between
72
+ # them. Only an element embedded in direct text (a text sibling on both sides) touches it.
73
+ def touching?(before, after)
74
+ element = before.element? ? before : after
75
+ before.next_sibling == after && embedded?(element) &&
76
+ !before.text.match?(/\s\z/) && !after.text.match?(/\A\s/)
77
+ end
78
+
79
+ def embedded?(element) = element.previous_sibling&.text? && element.next_sibling&.text?
80
+
71
81
  def visible_child?(node)
72
82
  !INVISIBLE_CONTENT_TAGS.include?(node.name) &&
73
83
  !(node.name == 'a' && node['href']&.start_with?('#'))
@@ -5,17 +5,17 @@ module Html2rss
5
5
  ##
6
6
  # Navigator owns DOM chrome recognition and node traversal helpers.
7
7
  class Navigator
8
+ # String forms of {SST::Tags} (the vocabulary owner); no delta today.
8
9
  # Heading tags used to prioritize title extraction and container assessment.
9
- HEADING_TAGS = Set['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].freeze
10
+ HEADING_TAGS = SST::Tags::HEADING_NAMES.to_set(&:to_s).freeze
10
11
 
11
12
  # Element tags that indicate ignored DOM chrome when found in a container path.
12
- IGNORED_CONTAINER_TAGS = Set['nav', 'footer', 'header', 'svg', 'script', 'style'].freeze
13
+ IGNORED_CONTAINER_TAGS = SST::Tags::IGNORED_CONTAINER_NAMES.to_set(&:to_s).freeze
13
14
 
14
- # Ancestor tags that usually indicate navigation/utility regions inside a content container.
15
- UTILITY_LANDMARK_TAGS = Set['nav', 'aside', 'footer', 'menu'].freeze
16
-
17
- # Immediate parent walk stops here — not a usable article card.
18
- CARD_WALK_STOP_TAGS = (UTILITY_LANDMARK_TAGS | IGNORED_CONTAINER_TAGS | Set['html', 'body']).freeze
15
+ # Immediate parent walk stops here — not a usable article card. Utility landmarks
16
+ # (tags and roles, {SST::Tags.utility_landmark?}) stop too, and page-level header/footer
17
+ # (or banner/contentinfo roles), via {usable_card_parent?}.
18
+ CARD_WALK_STOP_TAGS = (IGNORED_CONTAINER_TAGS | Set['html', 'body']).freeze
19
19
 
20
20
  # Inner tags that mean a wrapping <a> is a card, not a span-styled list link.
21
21
  WRAPPING_ANCHOR_CHILD_TAGS = (HEADING_TAGS + %w[p]).freeze
@@ -23,7 +23,7 @@ module Html2rss
23
23
  # Anchor selector used to identify the canonical article link element.
24
24
  MAIN_ANCHOR_SELECTOR = begin
25
25
  buf = +'a[href]:not([href=""])'
26
- %w[# javascript: mailto: tel: file:// sms: data:].each do |prefix|
26
+ SST::Tags::SKIP_HREF_PREFIXES.each do |prefix|
27
27
  buf << %[:not([href^="#{prefix}"])]
28
28
  end
29
29
  buf.freeze
@@ -67,6 +67,7 @@ module Html2rss
67
67
 
68
68
  ##
69
69
  # Immediate parent is a usable article card (not html/body/landmark chrome).
70
+ # A header/footer inside article/section/aside/main belongs to its card.
70
71
  #
71
72
  # @param node [Nokogiri::XML::Node, SST::Node, nil]
72
73
  # @return [Boolean]
@@ -74,7 +75,11 @@ module Html2rss
74
75
  return false unless node
75
76
  return false if node.is_a?(Nokogiri::XML::Document)
76
77
 
77
- !CARD_WALK_STOP_TAGS.include?(Probe.tag(node))
78
+ tag = Probe.tag(node)
79
+ role = node.is_a?(SST::Node) ? node.attrs.raw['role'] : node['role']
80
+ return !page_chrome?(node) if SST::Tags.page_level_chrome?(tag, role)
81
+
82
+ !CARD_WALK_STOP_TAGS.include?(tag) && !SST::Tags.utility_landmark?(tag, role)
78
83
  end
79
84
 
80
85
  ##
@@ -123,6 +128,31 @@ module Html2rss
123
128
  end
124
129
  false
125
130
  end
131
+
132
+ private
133
+
134
+ # Whether a header/footer is page-level chrome. SST nodes defer to the
135
+ # Normalizer's chrome index (unindexed nodes stay chrome); Nokogiri nodes
136
+ # check for a sectioning ancestor.
137
+ #
138
+ # @param node [Nokogiri::XML::Node, SST::Node]
139
+ # @return [Boolean]
140
+ def page_chrome?(node)
141
+ if node.is_a?(SST::Node)
142
+ index = SST::Index.for_node(node)
143
+ return index.nil? || index.ignored_chrome?(node)
144
+ end
145
+
146
+ nokogiri_chrome_scope(node) == :chrome
147
+ end
148
+
149
+ # Folds {SST::Tags.chrome_scope} over a Nokogiri node's ancestors, root first.
150
+ #
151
+ # @param node [Nokogiri::XML::Node]
152
+ # @return [Symbol] +:page+, +:sectioned+, or +:chrome+
153
+ def nokogiri_chrome_scope(node)
154
+ (node.ancestors.reverse << node).reduce(:page) { |scope, el| el.element? ? SST::Tags.chrome_scope(el, scope) : scope }
155
+ end
126
156
  end
127
157
  end
128
158
  end