html2rss 0.24.0 → 0.26.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ module ArticleRules
6
+ ##
7
+ # DOM-agnostic image URL selection (srcset sizing, CSS background urls).
8
+ #
9
+ # Uses string scans instead of Regexp: CodeQL rb/polynomial-redos flags the
10
+ # prior patterns on uncontrolled HTML attributes, and length guards alone
11
+ # do not clear the query.
12
+ module Image
13
+ # Skip absurdly large attributes before scanning.
14
+ MAX_ATTR_CHARS = 4_096
15
+ # Optional quoting around a CSS `url(...)` value.
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
21
+
22
+ class << self
23
+ ##
24
+ # @param srcset_strings [Array<String>] raw srcset attribute values
25
+ # @return [String, nil] URL of the largest width/height candidate
26
+ def largest_from_srcsets(srcset_strings)
27
+ by_size = {}
28
+ Array(srcset_strings).each { |srcset| merge_srcset!(by_size, srcset) }
29
+ by_size[by_size.keys.max]
30
+ end
31
+
32
+ ##
33
+ # @param style_strings [Array<String>] CSS style attribute values
34
+ # @return [String, nil] longest non-data background url
35
+ def best_from_styles(style_strings)
36
+ Array(style_strings)
37
+ .filter_map { |style| style_url(style) }
38
+ .reject { |src| src.start_with?('data:') }
39
+ .max_by(&:size)
40
+ end
41
+
42
+ private
43
+
44
+ def merge_srcset!(by_size, srcset)
45
+ s = srcset.to_s
46
+ return if s.length > MAX_ATTR_CHARS
47
+
48
+ each_srcset_pair(s) do |url, size|
49
+ next if url.start_with?('data:')
50
+
51
+ by_size[size] = url
52
+ end
53
+ end
54
+
55
+ # URL token + `Nw`/`Nh` pairs. Commas may appear inside URLs; a candidate
56
+ # boundary is whitespace before a width/height descriptor.
57
+ def each_srcset_pair(string) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
58
+ i = 0
59
+ len = string.length
60
+ while i < len
61
+ i += 1 while i < len && sep?(string[i])
62
+ break if i >= len
63
+
64
+ start = i
65
+ i += 1 while i < len && !ws?(string[i])
66
+ url = string[start, i - start]
67
+
68
+ i += 1 while i < len && ws?(string[i])
69
+ 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])
72
+
73
+ yield url, string[size_start, i - size_start].to_i
74
+ i += 1
75
+ end
76
+ end
77
+
78
+ def style_url(style)
79
+ s = style.to_s
80
+ return if s.length > MAX_ATTR_CHARS
81
+
82
+ pos = s.index('url(')
83
+ return unless pos
84
+
85
+ extract_url_body(s, pos + 4)
86
+ end
87
+
88
+ def extract_url_body(string, index)
89
+ quote = string[index]
90
+ if QUOTES.include?(quote)
91
+ slice_until(string, index + 1, quote)
92
+ else
93
+ slice_until(string, index, ')')
94
+ end
95
+ end
96
+
97
+ def slice_until(string, start, closer)
98
+ close = string.index(closer, start)
99
+ string[start, close - start] if close
100
+ end
101
+
102
+ def sep?(char) = ws?(char) || char == ','
103
+
104
+ def ws?(char) = WS.include?(char)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Html
5
+ ##
6
+ # DOM-agnostic article field rules shared by Nokogiri and SST adapters.
7
+ module ArticleRules
8
+ end
9
+ end
10
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <audio> tag from a URL and type.
9
7
  class AudioRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] media URL for the audio source
11
11
  # @param type [String] MIME type for the audio source
12
12
  def initialize(url:, type:)
@@ -22,16 +22,6 @@ module Html2rss
22
22
  '</audio>'
23
23
  ].join
24
24
  end
25
-
26
- private
27
-
28
- def escaped_url
29
- CGI.escapeHTML(@url.to_s)
30
- end
31
-
32
- def escaped_type
33
- CGI.escapeHTML(@type.to_s)
34
- end
35
25
  end
36
26
  end
37
27
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Html2rss
6
+ module Html
7
+ module Rendering
8
+ ##
9
+ # Shared HTML attribute escaping for media renderers.
10
+ module EscapedAttributes
11
+ private
12
+
13
+ def escaped_url
14
+ CGI.escapeHTML(@url.to_s)
15
+ end
16
+
17
+ def escaped_type
18
+ CGI.escapeHTML(@type.to_s)
19
+ end
20
+
21
+ def escaped_title
22
+ CGI.escapeHTML(@title.to_s)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <img> tag from a URL and title.
9
7
  class ImageRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] image URL for the src attribute
11
11
  # @param title [String, nil] title/alt text for the image
12
12
  def initialize(url:, title:)
@@ -27,16 +27,6 @@ module Html2rss
27
27
  ]
28
28
  "<img #{attributes.join(' ')}>"
29
29
  end
30
-
31
- private
32
-
33
- def escaped_url
34
- CGI.escapeHTML(@url.to_s)
35
- end
36
-
37
- def escaped_title
38
- CGI.escapeHTML(@title.to_s)
39
- end
40
30
  end
41
31
  end
42
32
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <iframe> for PDF documents.
9
7
  class PdfRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] PDF URL rendered in the iframe
11
11
  def initialize(url:)
12
12
  @url = url
@@ -24,12 +24,6 @@ module Html2rss
24
24
  ]
25
25
  "<iframe #{attributes.join(' ')}></iframe>"
26
26
  end
27
-
28
- private
29
-
30
- def escaped_url
31
- CGI.escapeHTML(@url.to_s)
32
- end
33
27
  end
34
28
  end
35
29
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Html2rss
6
4
  module Html
7
5
  module Rendering
8
6
  # Renders an HTML <video> tag from a URL and type.
9
7
  class VideoRenderer
8
+ include EscapedAttributes
9
+
10
10
  # @param url [String, Html2rss::Url] media URL for the video source
11
11
  # @param type [String] MIME type for the video source
12
12
  def initialize(url:, type:)
@@ -22,16 +22,6 @@ module Html2rss
22
22
  '</video>'
23
23
  ].join
24
24
  end
25
-
26
- private
27
-
28
- def escaped_url
29
- CGI.escapeHTML(@url.to_s)
30
- end
31
-
32
- def escaped_type
33
- CGI.escapeHTML(@type.to_s)
34
- end
35
25
  end
36
26
  end
37
27
  end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Html2rss
6
+ module Html
7
+ ##
8
+ # Builds an {Html2rss::Article} from an SST segment / ranked segment.
9
+ # Port of Html::ArticleExtractor field logic onto SST::Node.
10
+ # rubocop:disable Metrics/ClassLength -- SST field extractors colocated for parity with ArticleExtractor
11
+ class SstArticleExtractor
12
+ # CSS class tokens that mark kicker / eyebrow text (excluded from titles).
13
+ KICKER_CLASS_PATTERN = /kicker|eyebrow|pre-title|pretitle|overline/i
14
+ # Inline emphasis tags used as title fallbacks when no heading exists.
15
+ FALLBACK_HEADING_NAMES = %i[strong b].freeze
16
+
17
+ class << self
18
+ ##
19
+ # @param ranked_or_segment [Scoring::RankedSegment, AutoSource::Segment]
20
+ # @param base_url [String, Html2rss::Url]
21
+ # @param scraper [Class, nil]
22
+ # @param fallback_anchorless [Boolean]
23
+ # @return [Html2rss::Article, nil]
24
+ def call(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false)
25
+ new(ranked_or_segment, base_url:, scraper:, fallback_anchorless:).call
26
+ end
27
+ end
28
+
29
+ # @param ranked_or_segment [Scoring::RankedSegment, AutoSource::Segment]
30
+ # @param base_url [String, Html2rss::Url]
31
+ # @param scraper [Class, nil]
32
+ # @param fallback_anchorless [Boolean]
33
+ def initialize(ranked_or_segment, base_url:, scraper: nil, fallback_anchorless: false)
34
+ segment = ranked_or_segment.is_a?(Scoring::RankedSegment) ? ranked_or_segment.segment : ranked_or_segment
35
+ raise ArgumentError, 'segment is required' unless segment.is_a?(AutoSource::Segment)
36
+
37
+ @segment = segment
38
+ @root = segment.root_node
39
+ @selected_anchor = segment.primary_link
40
+ @base_url = base_url
41
+ @scraper = scraper
42
+ @fallback_anchorless = fallback_anchorless
43
+ end
44
+
45
+ ##
46
+ # @return [Html2rss::Article, nil]
47
+ def call # rubocop:disable Metrics/MethodLength
48
+ attrs = {
49
+ title: extract_title,
50
+ url: extract_url,
51
+ image: extract_image,
52
+ description: extract_description,
53
+ id: generate_id,
54
+ published_at: extract_published_at,
55
+ enclosures: extract_enclosures,
56
+ categories: extract_categories,
57
+ scraper: @scraper
58
+ }
59
+ article = Article.new(**attrs)
60
+ article.valid? ? article : nil
61
+ end
62
+
63
+ private
64
+
65
+ def extract_url
66
+ @extract_url ||= begin
67
+ href = @selected_anchor&.attrs&.href.to_s
68
+ if href.empty?
69
+ anchorless_url_fallback
70
+ else
71
+ Url.from_relative(href.split('#').first.strip, @base_url)
72
+ end
73
+ end
74
+ end
75
+
76
+ def anchorless_url_fallback
77
+ return unless @fallback_anchorless
78
+
79
+ id = generate_id
80
+ Url.from_relative("##{id}", @base_url) if id
81
+ end
82
+
83
+ def extract_title
84
+ title_text = title_from_in_card_sources
85
+ return unless title_text
86
+
87
+ kicker = kicker_node&.visible_text.to_s.strip
88
+ kicker && !kicker.empty? && !title_text.include?(kicker) ? "#{kicker}: #{title_text}" : title_text
89
+ end
90
+
91
+ # Prefer heading over credit-shaped visible text; never emit agency-only titles.
92
+ #
93
+ # @return [String, nil]
94
+ def title_from_in_card_sources
95
+ title_candidates.find { |text| !credit_shaped_title?(text) }
96
+ end
97
+
98
+ # @return [Array<String>] ordered in-card title candidates (heading → anchor → fallback)
99
+ def title_candidates
100
+ [].tap do |candidates|
101
+ add_title_candidate!(candidates, heading&.visible_text)
102
+ add_title_candidate!(candidates, @selected_anchor&.visible_text)
103
+ next unless candidates.empty? && @fallback_anchorless && @selected_anchor.nil?
104
+
105
+ add_title_candidate!(candidates, first_nonempty_own_text)
106
+ end
107
+ end
108
+
109
+ # @param candidates [Array<String>]
110
+ # @param text [String, nil]
111
+ # @return [void]
112
+ def add_title_candidate!(candidates, text)
113
+ stripped = text.to_s.strip
114
+ return if stripped.empty? || candidates.include?(stripped)
115
+
116
+ candidates << stripped
117
+ end
118
+
119
+ # @param text [String]
120
+ # @return [Boolean]
121
+ def credit_shaped_title?(text)
122
+ AutoSource::Cleanup::CREDIT_TITLE.match?(text)
123
+ end
124
+
125
+ def heading
126
+ @heading ||= begin
127
+ tags = @root.find_all(&:heading?)
128
+ if tags.any?
129
+ select_best_heading(tags)
130
+ elsif @fallback_anchorless && @selected_anchor.nil?
131
+ fallback_heading
132
+ end
133
+ end
134
+ end
135
+
136
+ def select_best_heading(tags)
137
+ min_name = tags.map { |t| t.name.to_s }.min
138
+ best = nil
139
+ max_size = -1
140
+ tags.each do |tag|
141
+ next unless tag.name.to_s == min_name
142
+
143
+ size = tag.visible_text.to_s.size
144
+ (best = tag) && (max_size = size) if size > max_size
145
+ end
146
+ best
147
+ end
148
+
149
+ def fallback_heading
150
+ @root.find do |n|
151
+ next true if FALLBACK_HEADING_NAMES.include?(n.name)
152
+
153
+ n.attrs.class_names.any? { |c| c.match?(/title|font-bold|font-semibold/) } &&
154
+ !n.visible_text.to_s.strip.empty?
155
+ end
156
+ end
157
+
158
+ def kicker_node # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
159
+ @kicker_node ||= begin
160
+ node = @root.find do |n|
161
+ n.attrs.raw.key?('data-tb-kicker') ||
162
+ n.attrs.class_names.any? { |c| c.match?(KICKER_CLASS_PATTERN) }
163
+ end
164
+ node && heading && (node.equal?(heading) || descendant_of?(node, heading)) ? nil : node
165
+ end
166
+ end
167
+
168
+ def descendant_of?(child, ancestor)
169
+ # Walk via root children since Extractor may not hold Index; use tag walk on tree.
170
+ return true if child.equal?(ancestor)
171
+
172
+ ancestor.descendants.any? { |d| d.equal?(child) }
173
+ end
174
+
175
+ def extract_description
176
+ exclude = [heading, @selected_anchor, kicker_node].compact
177
+ description = @root.visible_text(exclude:)
178
+ return if description.nil?
179
+
180
+ desc = description.strip
181
+ desc.empty? ? nil : desc
182
+ end
183
+
184
+ def generate_id # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
185
+ @generate_id ||= begin
186
+ id_from_dom = parse_id_from_dom
187
+ if id_from_dom
188
+ id_from_dom
189
+ else
190
+ heading_text = heading&.visible_text
191
+ heading_text = first_nonempty_own_text if heading_text.to_s.strip.empty? && @fallback_anchorless
192
+ if heading_text && !heading_text.strip.empty?
193
+ generate_slug(heading_text)
194
+ elsif @fallback_anchorless
195
+ text = @root.visible_text.to_s.strip
196
+ Zlib.crc32(text).to_s(36) unless text.empty?
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ def parse_id_from_dom
203
+ candidates = [@root.attrs.id]
204
+ nested = @root.find { |n| n.attrs.id }
205
+ candidates << nested.attrs.id if nested
206
+ if @selected_anchor
207
+ url = extract_url
208
+ candidates += [url&.path, url&.query]
209
+ end
210
+ candidates.compact.reject(&:empty?).first
211
+ end
212
+
213
+ def generate_slug(text)
214
+ slug = text.downcase.gsub(/[^a-z0-9]+/, '-')
215
+ slug = slug[1..] if slug.start_with?('-')
216
+ slug = slug[0..-2] if slug.end_with?('-')
217
+ slug unless slug.empty?
218
+ end
219
+
220
+ def first_nonempty_own_text
221
+ @root.find { |n| !n.own_text.to_s.strip.empty? }&.own_text&.strip
222
+ end
223
+
224
+ def extract_image
225
+ img_src = image_from_srcset || image_from_img || image_from_style
226
+ Url.from_relative(img_src, @base_url) if img_src
227
+ end
228
+
229
+ def image_from_img
230
+ @root.find { |n| n.image? && n.attrs.src && !n.attrs.src.start_with?('data:') }&.attrs&.src
231
+ end
232
+
233
+ def image_from_srcset
234
+ srcsets = @root.find_all { |n| n.attrs.srcset }.map { |n| n.attrs.srcset }
235
+ ArticleRules::Image.largest_from_srcsets(srcsets)
236
+ end
237
+
238
+ def image_from_style
239
+ styles = @root.find_all { |n| n.attrs.style&.include?('url') }.map { |n| n.attrs.style }
240
+ ArticleRules::Image.best_from_styles(styles)
241
+ end
242
+
243
+ def extract_published_at
244
+ datetimes = @root.find_all { |n| n.attrs.datetime }.map { |n| n.attrs.datetime }
245
+ ArticleRules::Date.earliest(datetimes)
246
+ end
247
+
248
+ def extract_enclosures
249
+ @root.find_all { |n| enclosure_node?(n) }.filter_map { |element| enclosure_from(element) }
250
+ end
251
+
252
+ def enclosure_node?(node)
253
+ case node.name
254
+ when :img then node.attrs.src && !node.attrs.src.start_with?('data:')
255
+ when :source, :audio, :video, :iframe then node.attrs.src
256
+ when :a then ArticleRules::Enclosure.archive_href?(node.attrs.href)
257
+ else false
258
+ end
259
+ end
260
+
261
+ def enclosure_from(element)
262
+ case element.name
263
+ when :img
264
+ ArticleRules::Enclosure.from_image(element.attrs.src, @base_url)
265
+ when :video, :audio, :source
266
+ ArticleRules::Enclosure.from_media(element.attrs.src, element.attrs.type, @base_url)
267
+ when :iframe
268
+ ArticleRules::Enclosure.from_iframe(element.attrs.src, @base_url)
269
+ when :a
270
+ ArticleRules::Enclosure.from_anchor(element.attrs.href, @base_url)
271
+ end
272
+ end
273
+
274
+ def extract_categories
275
+ Set.new.tap do |categories|
276
+ @root.find_all { |n| category_candidate?(n) }.each do |element|
277
+ add_category_text!(categories, element) if ArticleRules::Category.class_match?(element.attrs.class_attr)
278
+ element.attrs.raw.each do |name, value|
279
+ next unless ArticleRules::Category.attr_name_match?(name)
280
+
281
+ ArticleRules::Category.add_text!(categories, value)
282
+ end
283
+ end
284
+ end.to_a
285
+ end
286
+
287
+ def category_candidate?(node)
288
+ ArticleRules::Category.class_match?(node.attrs.class_attr) ||
289
+ node.attrs.raw.keys.any? { |k| ArticleRules::Category.attr_name_match?(k) }
290
+ end
291
+
292
+ def add_category_text!(categories, element)
293
+ if element.link?
294
+ ArticleRules::Category.add_text!(categories, element.visible_text)
295
+ return
296
+ end
297
+
298
+ anchors = element.find_all(&:link?)
299
+ if anchors.any?
300
+ anchors.each { |a| ArticleRules::Category.add_text!(categories, a.visible_text) }
301
+ else
302
+ ArticleRules::Category.add_split_text!(categories, element.visible_text)
303
+ end
304
+ end
305
+ end
306
+ # rubocop:enable Metrics/ClassLength
307
+ end
308
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module LinkDestination
5
+ # Normalized URL plus reusable route-classification facts for one link.
6
+ DestinationFacts = Data.define(
7
+ :url,
8
+ :destination,
9
+ :segments,
10
+ :content_path,
11
+ :utility_path,
12
+ :taxonomy_path,
13
+ :vanity_path,
14
+ :shallow,
15
+ :strong_post_suffix,
16
+ :high_confidence_junk_path,
17
+ :high_confidence_utility_destination
18
+ ) do
19
+ # @param url [Html2rss::Url] normalized destination URL
20
+ # @return [DestinationFacts] route facts for downstream link scoring
21
+ def self.build(url) # rubocop:disable Metrics/MethodLength
22
+ classifier = PathClassifier.new(url.path_segments)
23
+
24
+ new(
25
+ url:,
26
+ destination: url.to_s,
27
+ segments: classifier.segments,
28
+ strong_post_suffix: classifier.strong_post_suffix?,
29
+ content_path: classifier.content_path?,
30
+ utility_path: classifier.utility_path?,
31
+ taxonomy_path: classifier.taxonomy_path?,
32
+ vanity_path: classifier.vanity_path?,
33
+ shallow: classifier.shallow?,
34
+ high_confidence_junk_path: classifier.junk_path?,
35
+ high_confidence_utility_destination: classifier.utility_destination?
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module LinkDestination
5
+ ##
6
+ # Noise / junk chrome eligibility for SST anchors (port of LinkHeuristics#noise_anchor?).
7
+ class NoisePolicy
8
+ # Child tag names that mark icon-only anchors as noise.
9
+ ICON_NAMES = %i[img svg].freeze
10
+
11
+ # @param link_resolver [LinkResolver]
12
+ def initialize(link_resolver:)
13
+ @link_resolver = link_resolver
14
+ end
15
+
16
+ ##
17
+ # @param text [String]
18
+ # @param destination_facts [LinkDestination::DestinationFacts, nil]
19
+ # @param anchor [SST::Node, nil]
20
+ # @param container [SST::Node, nil]
21
+ # @param heading_anchor [Boolean]
22
+ # @param utility_landmark_ancestor [Boolean] pre-computed by Segmenter tree walk
23
+ # @return [Boolean]
24
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
25
+ def noise_anchor?(text:, destination_facts:, anchor: nil, container: nil,
26
+ heading_anchor: false, utility_landmark_ancestor: false)
27
+ return true unless destination_facts
28
+
29
+ destination_facts.taxonomy_path ||
30
+ short_utility_label?(text, destination_facts) ||
31
+ recommended_chrome?(text, destination_facts, heading_anchor:) ||
32
+ (@link_resolver.utility_prefix_text?(text) && destination_facts.high_confidence_utility_destination) ||
33
+ (@link_resolver.utility_text?(text) && destination_facts.vanity_path) ||
34
+ utility_text_chrome?(text, destination_facts, heading_anchor:) ||
35
+ icon_only_anchor?(anchor, text) ||
36
+ utility_landmark_ancestor
37
+ end
38
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/PerceivedComplexity, Lint/UnusedMethodArgument
39
+
40
+ private
41
+
42
+ def short_utility_label?(text, destination_facts)
43
+ destination_facts.utility_path &&
44
+ !destination_facts.content_path &&
45
+ !destination_facts.strong_post_suffix &&
46
+ text.to_s.scan(/\p{Alnum}+/).size <= 3
47
+ end
48
+
49
+ def recommended_chrome?(text, destination_facts, heading_anchor:)
50
+ !heading_anchor && @link_resolver.recommended_text?(text) && destination_facts.shallow
51
+ end
52
+
53
+ def utility_text_chrome?(text, destination_facts, heading_anchor:)
54
+ return false if destination_facts.content_path
55
+ return false unless @link_resolver.utility_text?(text)
56
+
57
+ !heading_anchor && !destination_facts.strong_post_suffix
58
+ end
59
+
60
+ def icon_only_anchor?(anchor, text)
61
+ return false unless anchor
62
+ return false if text.to_s.match?(/\p{Alnum}/)
63
+
64
+ !!anchor.find { |n| ICON_NAMES.include?(n.name) }
65
+ end
66
+ end
67
+ end
68
+ end