html2rss 0.33.0 → 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 (177) 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 -21
  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 +126 -50
  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 -39
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
  19. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
  20. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
  21. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
  22. data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
  23. data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
  25. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
  27. data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
  28. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  29. data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
  30. data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
  31. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
  32. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
  33. data/lib/html2rss/auto_source/scraper.rb +10 -2
  34. data/lib/html2rss/auto_source/segment.rb +19 -5
  35. data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
  36. data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
  37. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  38. data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
  39. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  40. data/lib/html2rss/auto_source.rb +81 -26
  41. data/lib/html2rss/batch.rb +2 -0
  42. data/lib/html2rss/capture/README.md +5 -2
  43. data/lib/html2rss/capture.rb +44 -32
  44. data/lib/html2rss/channel.rb +30 -12
  45. data/lib/html2rss/cli.rb +22 -11
  46. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  47. data/lib/html2rss/config/issue_mapper.rb +4 -1
  48. data/lib/html2rss/config/request_controls.rb +6 -3
  49. data/lib/html2rss/config/schema.rb +15 -5
  50. data/lib/html2rss/config/selectors_validator.rb +2 -2
  51. data/lib/html2rss/config/validation_issue.rb +4 -1
  52. data/lib/html2rss/config/validator.rb +11 -0
  53. data/lib/html2rss/config.rb +53 -27
  54. data/lib/html2rss/contract_error.rb +9 -0
  55. data/lib/html2rss/defaults.rb +21 -23
  56. data/lib/html2rss/doctor/botasaurus.rb +7 -3
  57. data/lib/html2rss/error.rb +10 -0
  58. data/lib/html2rss/error_kind.rb +39 -0
  59. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  60. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  61. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  62. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  63. data/lib/html2rss/feed_builder/rss.rb +49 -6
  64. data/lib/html2rss/feed_builder.rb +2 -1
  65. data/lib/html2rss/feed_pipeline/README.md +1 -1
  66. data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
  67. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  68. data/lib/html2rss/feed_pipeline.rb +40 -27
  69. data/lib/html2rss/feed_resolution/README.md +1 -1
  70. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  71. data/lib/html2rss/feed_resolution/policy.rb +1 -1
  72. data/lib/html2rss/feed_resolution/probe.rb +3 -1
  73. data/lib/html2rss/feed_resolution.rb +11 -12
  74. data/lib/html2rss/feed_result.rb +9 -5
  75. data/lib/html2rss/hash_util.rb +3 -3
  76. data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
  77. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
  78. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  79. data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
  80. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  81. data/lib/html2rss/html/article_extractor.rb +19 -47
  82. data/lib/html2rss/html/article_rules/category.rb +3 -3
  83. data/lib/html2rss/html/article_rules/description.rb +6 -15
  84. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  85. data/lib/html2rss/html/article_rules/image.rb +30 -16
  86. data/lib/html2rss/html/feed_link.rb +7 -9
  87. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  88. data/lib/html2rss/html/navigator.rb +39 -9
  89. data/lib/html2rss/html/probe.rb +4 -0
  90. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  91. data/lib/html2rss/html/rendering.rb +2 -9
  92. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  93. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  94. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  95. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  96. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  97. data/lib/html2rss/html/title_pieces.rb +169 -0
  98. data/lib/html2rss/html/visibility.rb +56 -0
  99. data/lib/html2rss/html.rb +2 -0
  100. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  101. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  102. data/lib/html2rss/link_destination/path_classifier.rb +100 -9
  103. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  104. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  105. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  106. data/lib/html2rss/mcp/README.md +13 -1
  107. data/lib/html2rss/mcp/config_argument.rb +5 -2
  108. data/lib/html2rss/mcp/contract.rb +13 -4
  109. data/lib/html2rss/mcp/outcome.rb +14 -12
  110. data/lib/html2rss/mcp/runtime.rb +39 -2
  111. data/lib/html2rss/mcp/server.rb +13 -4
  112. data/lib/html2rss/page_recon/README.md +1 -1
  113. data/lib/html2rss/page_recon/diagnostics.rb +2 -0
  114. data/lib/html2rss/recon.rb +10 -12
  115. data/lib/html2rss/request_service/blocked_surface.rb +2 -1
  116. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  117. data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
  118. data/lib/html2rss/request_service/context.rb +15 -14
  119. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  120. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  121. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  122. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  123. data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
  124. data/lib/html2rss/request_service/policy.rb +48 -11
  125. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  126. data/lib/html2rss/request_service/response.rb +30 -13
  127. data/lib/html2rss/request_service/response_cache.rb +109 -0
  128. data/lib/html2rss/request_service/response_caching.rb +95 -0
  129. data/lib/html2rss/request_service/strategy.rb +5 -11
  130. data/lib/html2rss/request_service.rb +71 -12
  131. data/lib/html2rss/request_session/pager/base.rb +0 -7
  132. data/lib/html2rss/request_session/pager.rb +1 -1
  133. data/lib/html2rss/request_session.rb +6 -2
  134. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  135. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  136. data/lib/html2rss/scoring/engine.rb +6 -6
  137. data/lib/html2rss/scoring/observation.rb +2 -2
  138. data/lib/html2rss/selector_candidates.rb +19 -8
  139. data/lib/html2rss/selectors/README.md +1 -1
  140. data/lib/html2rss/selectors/option_spec.rb +4 -1
  141. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  142. data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
  143. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  144. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  145. data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
  146. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  147. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  148. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  149. data/lib/html2rss/selectors.rb +53 -12
  150. data/lib/html2rss/sst/attrs.rb +2 -2
  151. data/lib/html2rss/sst/document.rb +3 -3
  152. data/lib/html2rss/sst/index.rb +30 -11
  153. data/lib/html2rss/sst/node.rb +33 -26
  154. data/lib/html2rss/sst/normalizer.rb +31 -19
  155. data/lib/html2rss/sst/tags.rb +113 -2
  156. data/lib/html2rss/sst/text.rb +69 -29
  157. data/lib/html2rss/sst.rb +2 -0
  158. data/lib/html2rss/status.rb +6 -3
  159. data/lib/html2rss/surface_category.rb +1 -1
  160. data/lib/html2rss/syndication/README.md +12 -6
  161. data/lib/html2rss/syndication/alternate_links.rb +1 -1
  162. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  163. data/lib/html2rss/syndication/discovery.rb +82 -28
  164. data/lib/html2rss/syndication/feed.rb +20 -0
  165. data/lib/html2rss/syndication/item_media.rb +103 -0
  166. data/lib/html2rss/syndication/parser.rb +75 -51
  167. data/lib/html2rss/test/enhance_audit.rb +2 -2
  168. data/lib/html2rss/test/item_sample.rb +43 -0
  169. data/lib/html2rss/test.rb +42 -14
  170. data/lib/html2rss/url.rb +115 -14
  171. data/lib/html2rss/version.rb +1 -1
  172. data/lib/html2rss/word_count.rb +53 -0
  173. data/lib/html2rss.rb +17 -8
  174. data/schema/html2rss-config.schema.json +56 -41
  175. metadata +31 -4
  176. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  177. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
@@ -4,44 +4,25 @@ module Html2rss
4
4
  module Html
5
5
  class ArticleExtractor
6
6
  ##
7
- # Extracts enclosures from HTML tags using various strategies.
7
+ # Adapts Nokogiri elements to {ArticleRules::Enclosure}, which owns which elements become enclosures.
8
8
  class EnclosureExtractor
9
- # CSS union query covering images, media, PDFs, iframes, and archives.
10
- SELECTOR = [
11
- 'img[src]:not([src^="data"])',
12
- 'video source[src]',
13
- 'audio source[src]',
14
- 'audio[src]',
15
- 'a[href$=".pdf"]',
16
- 'iframe[src]',
17
- 'a[href$=".zip"]',
18
- 'a[href$=".tar.gz"]',
19
- 'a[href$=".tgz"]'
20
- ].join(',').freeze
9
+ # CSS union of the element names {ArticleRules::Enclosure} may turn into an enclosure.
10
+ SELECTOR = ArticleRules::Enclosure::ELEMENT_NAMES.join(',').freeze
11
+ # CSS union of the players a +source+ element must sit inside.
12
+ PLAYERS = ArticleRules::Enclosure::MEDIA_ELEMENTS.join(',').freeze
21
13
 
22
14
  # @param article_tag [Nokogiri::XML::Element] article container node
23
15
  # @param base_url [String, Html2rss::Url] base URL for relative enclosure links
24
- # @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes
16
+ # @return [Array<Hash{Symbol => Object}>] normalized enclosure hashes in document order, one per URL
25
17
  def self.call(article_tag, base_url)
26
- article_tag.css(SELECTOR).filter_map do |element|
27
- extract_from_element(element, base_url)
18
+ found = article_tag.css(SELECTOR).filter_map do |element|
19
+ attrs = { src: element['src'], href: element['href'], type: element['type'] }
20
+ ArticleRules::Enclosure.from_element(element.name, base_url, **attrs) do
21
+ element.ancestors(PLAYERS).first&.name
22
+ end
28
23
  end
24
+ ArticleRules::Enclosure.unique(found)
29
25
  end
30
-
31
- def self.extract_from_element(element, base_url)
32
- case element.name
33
- when 'img'
34
- ArticleRules::Enclosure.from_image(element['src'], base_url)
35
- when 'audio', 'source'
36
- ArticleRules::Enclosure.from_media(element['src'], element['type'], base_url)
37
- when 'iframe'
38
- ArticleRules::Enclosure.from_iframe(element['src'], base_url)
39
- when 'a'
40
- ArticleRules::Enclosure.from_anchor(element['href'], base_url)
41
- end
42
- end
43
-
44
- private_class_method :extract_from_element
45
26
  end
46
27
  end
47
28
  end
@@ -12,10 +12,9 @@ module Html2rss
12
12
  class << self
13
13
  ##
14
14
  # @param article_tag [Nokogiri::XML::Element] container node
15
- # @param fallback_anchorless [Boolean] whether to use fallback search
16
15
  # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
17
16
  # @return [Nokogiri::XML::Node, nil] the heading node, if found
18
- def call(article_tag, fallback_anchorless:, selected_anchor:)
17
+ def call(article_tag, selected_anchor:)
19
18
  tags = article_tag.css(HEADING_TAGS.join(','))
20
19
  if tags.any?
21
20
  select_best_heading(tags)
@@ -23,7 +22,7 @@ module Html2rss
23
22
  labeled = heading_from_aria_or_title(article_tag)
24
23
  return labeled if labeled
25
24
 
26
- fallback_heading(article_tag) if fallback_anchorless && selected_anchor.nil?
25
+ fallback_heading(article_tag) if selected_anchor.nil?
27
26
  end
28
27
  end
29
28
 
@@ -14,16 +14,15 @@ module Html2rss
14
14
  # @param heading [Nokogiri::XML::Node, nil] heading node
15
15
  # @param url [Html2rss::Url, nil] absolute article URL
16
16
  # @param selected_anchor [Nokogiri::XML::Node, nil] anchor element
17
- # @param fallback_anchorless [Boolean] whether to use fallback hashing
18
17
  # @return [String, nil] the generated ID, if any
19
- def call(article_tag, heading:, url:, selected_anchor:, fallback_anchorless:)
18
+ def call(article_tag, heading:, url:, selected_anchor:)
20
19
  id_from_dom = parse_id_from_dom(article_tag, url, selected_anchor)
21
20
  return id_from_dom if id_from_dom
22
21
 
23
- heading_text = resolve_heading_text(article_tag, heading, fallback_anchorless)
22
+ heading_text = resolve_heading_text(article_tag, heading)
24
23
  if heading_text && !heading_text.strip.empty?
25
24
  generate_slug(heading_text)
26
- elsif fallback_anchorless
25
+ else
27
26
  generate_content_hash(article_tag)
28
27
  end
29
28
  end
@@ -36,18 +35,16 @@ module Html2rss
36
35
  candidates.compact.reject(&:empty?).first
37
36
  end
38
37
 
39
- def resolve_heading_text(article_tag, heading, fallback_anchorless)
38
+ def resolve_heading_text(article_tag, heading)
40
39
  text = heading ? Navigator::TextExtractor.call(heading) : nil
41
40
  if text.nil? || text.strip.empty?
42
- fallback_text_node_content(article_tag, fallback_anchorless)
41
+ fallback_text_node_content(article_tag)
43
42
  else
44
43
  text
45
44
  end
46
45
  end
47
46
 
48
- def fallback_text_node_content(article_tag, fallback_anchorless)
49
- return unless fallback_anchorless
50
-
47
+ def fallback_text_node_content(article_tag)
51
48
  article_tag.xpath('.//text()').find { |t| !t.text.strip.empty? }&.text&.strip
52
49
  end
53
50
 
@@ -12,7 +12,8 @@ module Html2rss
12
12
  def self.call(article_tag, base_url:)
13
13
  img_src = from_source(article_tag) ||
14
14
  from_img(article_tag) ||
15
- from_style(article_tag)
15
+ from_style(article_tag) ||
16
+ from_poster(article_tag)
16
17
 
17
18
  Url.from_relative(img_src, base_url) if img_src
18
19
  end
@@ -43,6 +44,13 @@ module Html2rss
43
44
  styles = article_tag.css('[style*="url"]').map { |tag| tag['style'] }
44
45
  ArticleRules::Image.best_from_styles(styles)
45
46
  end
47
+
48
+ # @param article_tag [Nokogiri::XML::Element] article container node
49
+ # @return [String, nil] first usable video poster frame: the last resort, when the card has no other image
50
+ def self.from_poster(article_tag)
51
+ posters = article_tag.css('video[poster]').map { |video| video['poster'] }
52
+ posters.find { |src| ArticleRules::Image.remote_src?(src) }
53
+ end
46
54
  end
47
55
  end
48
56
  end
@@ -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