html2rss 0.22.2 → 0.23.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -108
  3. data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
  4. data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
  5. data/lib/html2rss/article.rb +206 -0
  6. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
  7. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
  8. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
  9. data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
  10. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
  11. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
  12. data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
  13. data/lib/html2rss/auto_source/discovery.rb +14 -0
  14. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
  15. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
  16. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
  17. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
  18. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
  19. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
  20. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
  21. data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
  22. data/lib/html2rss/auto_source/scraper/html.rb +22 -33
  23. data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
  24. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
  25. data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
  27. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  28. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  29. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  30. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  31. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  32. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  33. data/lib/html2rss/auto_source/scraper.rb +10 -3
  34. data/lib/html2rss/auto_source.rb +30 -35
  35. data/lib/html2rss/channel.rb +118 -0
  36. data/lib/html2rss/cli.rb +5 -6
  37. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  38. data/lib/html2rss/config/request_controls.rb +136 -0
  39. data/lib/html2rss/config/request_headers.rb +1 -1
  40. data/lib/html2rss/config/schema.rb +65 -114
  41. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  42. data/lib/html2rss/config/validator.rb +6 -6
  43. data/lib/html2rss/config.rb +176 -19
  44. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  45. data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
  46. data/lib/html2rss/feed_builder/json_feed.rb +61 -0
  47. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  48. data/lib/html2rss/feed_builder/rss.rb +116 -0
  49. data/lib/html2rss/feed_builder.rb +23 -0
  50. data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
  51. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  52. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  53. data/lib/html2rss/feed_pipeline.rb +52 -65
  54. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  55. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  56. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  57. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  58. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  59. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  60. data/lib/html2rss/html/article_extractor.rb +134 -0
  61. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  62. data/lib/html2rss/html/navigator.rb +146 -0
  63. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  64. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  65. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  66. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  67. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  68. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering.rb +25 -0
  70. data/lib/html2rss/html.rb +8 -0
  71. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
  74. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  75. data/lib/html2rss/request_service/budget.rb +90 -16
  76. data/lib/html2rss/request_service/context.rb +1 -3
  77. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  78. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  79. data/lib/html2rss/request_service/network_guard.rb +125 -0
  80. data/lib/html2rss/request_service/policy.rb +10 -82
  81. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  82. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  83. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  84. data/lib/html2rss/request_service/response_guard.rb +1 -1
  85. data/lib/html2rss/request_service/strategy.rb +102 -6
  86. data/lib/html2rss/request_service.rb +7 -2
  87. data/lib/html2rss/request_session/pager/base.rb +83 -0
  88. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  89. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  90. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  91. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  92. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  93. data/lib/html2rss/request_session/pager.rb +75 -0
  94. data/lib/html2rss/request_session.rb +25 -16
  95. data/lib/html2rss/selectors/item_scope.rb +30 -0
  96. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  97. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  98. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  99. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  100. data/lib/html2rss/selectors.rb +63 -65
  101. data/lib/html2rss/version.rb +1 -3
  102. data/lib/html2rss.rb +19 -19
  103. data/schema/html2rss-config.schema.json +109 -9
  104. metadata +68 -42
  105. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  106. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  107. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  108. data/lib/html2rss/blocked_surface.rb +0 -65
  109. data/lib/html2rss/category_extractor.rb +0 -116
  110. data/lib/html2rss/config/class_methods.rb +0 -183
  111. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  112. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  113. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  114. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  115. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  116. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  117. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  118. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  119. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  120. data/lib/html2rss/html_extractor.rb +0 -190
  121. data/lib/html2rss/html_navigator.rb +0 -71
  122. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  123. data/lib/html2rss/json_feed_builder.rb +0 -59
  124. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  125. data/lib/html2rss/rendering/description_builder.rb +0 -87
  126. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  127. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  128. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  129. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  130. data/lib/html2rss/rendering.rb +0 -23
  131. data/lib/html2rss/request_controls.rb +0 -133
  132. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  133. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  134. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  135. data/lib/html2rss/rss_builder/article.rb +0 -208
  136. data/lib/html2rss/rss_builder/channel.rb +0 -118
  137. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  138. data/lib/html2rss/rss_builder.rb +0 -101
@@ -1,401 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- module Scraper
6
- ##
7
- # Shared link-level heuristics used by scraper-local selection and
8
- # scoring. This keeps normalization and route/text classification
9
- # consistent without moving scraper policy into higher orchestration.
10
- class LinkHeuristics
11
- # Normalized URL plus reusable route-classification facts for one link.
12
- DestinationFacts = Data.define(
13
- :url,
14
- :destination,
15
- :segments,
16
- :content_path,
17
- :utility_path,
18
- :taxonomy_path,
19
- :vanity_path,
20
- :shallow,
21
- :strong_post_suffix,
22
- :high_confidence_junk_path,
23
- :high_confidence_utility_destination
24
- ) do
25
- # @param url [Html2rss::Url] normalized destination URL
26
- # @return [DestinationFacts] route facts for downstream link scoring
27
- def self.build(url) # rubocop:disable Metrics/MethodLength
28
- classifier = PathClassifier.new(url.path_segments)
29
-
30
- new(
31
- url:,
32
- destination: url.to_s,
33
- segments: classifier.segments,
34
- strong_post_suffix: classifier.strong_post_suffix?,
35
- content_path: classifier.content_path?,
36
- utility_path: classifier.utility_path?,
37
- taxonomy_path: classifier.taxonomy_path?,
38
- vanity_path: classifier.vanity_path?,
39
- shallow: classifier.shallow?,
40
- high_confidence_junk_path: classifier.junk_path?,
41
- high_confidence_utility_destination: classifier.utility_destination?
42
- )
43
- end
44
- end
45
-
46
- # Extracts a normalized href from a Nokogiri anchor or raw href value.
47
- class HrefExtractor
48
- # Regexp to capture everything before the first '#'
49
- HREF_BASE_PATTERN = /\A([^#]*)/
50
-
51
- # @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
52
- # @return [String, nil] href without fragment, or nil when blank
53
- def self.call(anchor_or_href) = new(anchor_or_href).call
54
-
55
- # @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
56
- def initialize(anchor_or_href)
57
- @anchor_or_href = anchor_or_href
58
- end
59
-
60
- # @return [String, nil] href without fragment, or nil when blank
61
- def call
62
- href = case @anchor_or_href
63
- when Nokogiri::XML::Node
64
- @anchor_or_href['href']
65
- else
66
- @anchor_or_href
67
- end
68
-
69
- return unless href
70
-
71
- # Extract base part before # and strip whitespace
72
- base = href.to_s[HREF_BASE_PATTERN, 1].strip
73
- base unless base.empty?
74
- end
75
- end
76
-
77
- # Classifies visible anchor text for utility and recommendation chrome.
78
- class TextClassifier
79
- # Prefix labels that usually identify navigation or subscription links.
80
- UTILITY_PREFIX_PATTERN = /
81
- \A\s*(
82
- # English
83
- view\s+all|see\s+all|all\s+news|subscribe|newsletter|comment\s+feed|comments\s+feed|join|premium|plus|
84
- # German
85
- alle\s+anzeigen|alle\s+news|abonnieren|newsletter|kommentar\s+feed|mitmachen|
86
- # Spanish
87
- ver\s+todos|ver\s+todo|todas\s+las\s+noticias|suscribirse|bolet(i|í)n|comentarios\s+feed|unirse|
88
- # French
89
- voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre
90
- )\b
91
- /ix
92
- # Short labels that usually identify non-article navigation links.
93
- UTILITY_PATTERN = /
94
- \A\s*(
95
- # English
96
- about|contact|comments?|join|log\s+in|login|member(ship)?|
97
- plus|premium|pricing|recommended(\s+for\s+you)?|
98
- see\s+all|share|sign\s+up|signup|subscribe|view\s+all|
99
- # German
100
- (ue|ü)ber(\s+uns)?|kontakt|kommentare?|mitmachen|anmelden|login|
101
- mitglied(schaft)?|empfohlen(\s+f(ue|ü)r\s+dich)?|alle\s+anzeigen|
102
- teilen|registrieren|abonnieren|newsletter|
103
- # Spanish
104
- sobre(\s+nosotros)?|contacto|comentarios?|unirse|iniciar\s+sesion|
105
- login|miembro|membres(i|í)a|recomendado(\s+para\s+ti)?|ver\s+todo|
106
- compartir|registrarse|suscribirse|bolet(i|í)n|
107
- # French
108
- (a|à)\s+propos|(a|à)propos|contact|commentaires?|rejoindre|
109
- se\s+connecter|login|membre|abonnement|recommand(e|é)(\s+pour\s+vous)?|
110
- voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter
111
- )\b
112
- /ix
113
- # Labels for recommendation chrome rather than source articles.
114
- RECOMMENDED_PATTERN = /
115
- \A\s*(
116
- recommended(\s+for\s+you)?|
117
- empfohlen(\s+f(ue|ü)r\s+dich)?|
118
- recomendado(\s+para\s+ti)?|
119
- recommand(e|é)(\s+pour\s+vous)?
120
- )\b
121
- /ix
122
-
123
- # @param text [String, #to_s] visible anchor text
124
- # @return [Boolean] true when text matches a utility label
125
- def utility?(text) = text.to_s.match?(UTILITY_PATTERN)
126
-
127
- # @param text [String, #to_s] visible anchor text
128
- # @return [Boolean] true when text begins with a utility label
129
- def utility_prefix?(text) = text.to_s.match?(UTILITY_PREFIX_PATTERN)
130
-
131
- # @param text [String, #to_s] visible anchor text
132
- # @return [Boolean] true when text identifies recommendation chrome
133
- def recommended?(text) = text.to_s.match?(RECOMMENDED_PATTERN)
134
- end
135
-
136
- # Classifies normalized destination path segments for scoring.
137
- class PathClassifier # rubocop:disable Metrics/ClassLength
138
- attr_reader :segments
139
-
140
- # Segment groups used to classify article, taxonomy, utility, and vanity routes.
141
- SEGMENT_SETS = {
142
- content: %w[
143
- article articles blog blogs changelog changelogs insight insights
144
- launch launches news post posts release releases story stories update updates
145
- artikel beitrag beitraege nachrichten neuigkeiten aktuelles
146
- articulo articulos noticia noticias entrada entradas publicacion publicaciones
147
- actualite actualites nouvelle nouvelles
148
- teaser teasers card cards
149
- ].to_set.freeze,
150
- utility: %w[
151
- about account archive archives author authors category categories comment comments
152
- contact feedback help login logout newsletter newsletters notification notifications
153
- preference preferences profile register search settings share signup subscribe
154
- tag tags topic topics
155
- feed feeds comment-feed comments-feed
156
- recommended
157
- for-you
158
- privacy terms cookie cookies
159
- join member members membership plus premium plans pricing user users
160
- kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
161
- ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
162
- konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
163
- categoria categorias etiqueta etiquetas tema temas autores archivos
164
- sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
165
- registrarse registro cuenta suscribirse boletin privacidad condiciones
166
- categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
167
- a-propos apropos recherche rechercher aide connexion s-inscrire
168
- sinscrire inscription compte s-abonner saboner lettre-information confidentialite mentions-legales cgu
169
- menu sidebar widget social modal popup banner promo ad ads
170
- related recommendation recommendations pagination pager
171
- ].to_set.freeze,
172
- high_confidence_junk: %w[
173
- about account archive archives author authors category categories comment comments
174
- contact cookie cookies feedback feed feeds help login logout notification notifications
175
- preference preferences privacy profile register search settings share signup subscribe
176
- tag tags terms topic topics comment-feed comments-feed user users
177
- kategorie kategorien schlagwort schlagworte thema themen autor autoren archiv
178
- ueber-uns ueber ueberuns profil kontakt impressum suche hilfe anmelden registrieren
179
- konto registrierung anmeldung abonnieren abo datenschutz nutzungsbedingungen agb
180
- categoria categorias etiqueta etiquetas tema temas autores archivos
181
- sobre-nosotros sobre quienes-somos buscar busqueda ayuda entrar ingresar
182
- registrarse registro cuenta suscribirse boletin privacidad condiciones
183
- categorie etiquette etiquettes sujet sujets theme themes auteur auteurs
184
- a-propos apropos recherche rechercher aide connexion s-inscrire
185
- sinscrire inscription compte s-abonner saboner lettre-information confidentialite mentions-legales cgu
186
- menu sidebar widget social modal popup banner promo ad ads
187
- related recommendation recommendations pagination pager
188
- ].to_set.freeze,
189
- taxonomy: %w[
190
- category categories tag tags topic topics
191
- kategorie kategorien schlagwort schlagworte thema themen
192
- categoria categorias etiqueta etiquetas tema temas
193
- categorie etiquette etiquettes sujet sujets theme themes
194
- ].to_set.freeze,
195
- vanity: %w[
196
- join membership plus premium pricing plans subscribe signup
197
- abonnieren abo
198
- suscribirse boletin
199
- s-abonner saboner
200
- ].to_set.freeze,
201
- deep_post_context: %w[
202
- press newsroom
203
- presse pressemitteilungen
204
- prensa
205
- ].to_set.freeze
206
- }.freeze
207
- # Path segment that begins with a year-like publishing marker.
208
- YEARISH_SEGMENT = /\A\d{4,}[\w-]*\z/
209
- # Hyphenated slug shape common to article permalinks.
210
- POST_SLUG_SEGMENT = /\A[a-z0-9]+(?:-[a-z0-9]+){2,}\z/i
211
-
212
- # @param segments [Array<String>] normalized URL path segments
213
- def initialize(segments)
214
- @segments = segments
215
- end
216
-
217
- # @return [Boolean] true when the route has article-like path evidence
218
- def content_path?
219
- @content_path ||= segments.any? { |s| SEGMENT_SETS[:content].include?(s) } ||
220
- yearish_content_context?
221
- end
222
-
223
- # @return [Boolean] true when the route includes utility/navigation evidence
224
- def utility_path?
225
- @utility_path ||= segments.any? { |s| SEGMENT_SETS[:utility].include?(s) }
226
- end
227
-
228
- # @return [Boolean] true when the route points at conversion or account chrome
229
- def vanity_path?
230
- @vanity_path ||= segments.any? { |s| SEGMENT_SETS[:vanity].include?(s) }
231
- end
232
-
233
- # @return [Boolean] true when the route points at taxonomy/listing chrome
234
- def taxonomy_path?
235
- @taxonomy_path ||= segments.any? { |s| SEGMENT_SETS[:taxonomy].include?(s) }
236
- end
237
-
238
- # @return [Boolean] true when the route is too shallow to strongly indicate an article
239
- def shallow?
240
- segment_count = segments.size
241
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
242
-
243
- segment_count <= 1 || (segment_count == 2 && junk_segments.include?(segments.last))
244
- end
245
-
246
- # @return [Boolean] true when the final path segment looks like a post slug
247
- def strong_post_suffix?
248
- @strong_post_suffix ||= segments.any? &&
249
- included_last_segment? &&
250
- trusted_post_context?(segments.size - 1)
251
- end
252
-
253
- # @return [Boolean] true when every path segment is utility chrome
254
- def utility_only_route?
255
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
256
-
257
- segments.all? { |segment| junk_segments.include?(segment) }
258
- end
259
-
260
- # @return [Boolean] true when the route is shallow and contains high-confidence noise
261
- def shallow_high_confidence_route?
262
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
263
- vanity_segments = SEGMENT_SETS.fetch(:vanity)
264
-
265
- shallow? && segments.any? do |segment|
266
- junk_segments.include?(segment) || vanity_segments.include?(segment)
267
- end
268
- end
269
-
270
- # @return [Boolean] true when the leading segments are all utility chrome
271
- def deep_utility_context_route?
272
- all_junk?(segments.size - 1)
273
- end
274
-
275
- # @return [Boolean] true when the route is shallow and contains high-confidence noise
276
- def junk_path?
277
- return false if excluded_content_route?
278
-
279
- taxonomy_path? ||
280
- utility_only_route? ||
281
- deep_utility_context_route? ||
282
- shallow_high_confidence_route?
283
- end
284
-
285
- # @return [Boolean] true when the route points at conversion or account chrome
286
- def utility_destination?
287
- return false if excluded_content_route?
288
-
289
- vanity_path? || utility_route?
290
- end
291
-
292
- private
293
-
294
- def yearish_content_context?
295
- segments.any? { |segment| segment.match?(YEARISH_SEGMENT) } &&
296
- (strong_post_suffix? || trusted_post_context?(segments.size - 1))
297
- end
298
-
299
- def excluded_content_route?
300
- segments.empty? || content_path? || strong_post_suffix?
301
- end
302
-
303
- def utility_route?
304
- taxonomy_path? ||
305
- utility_only_route? ||
306
- deep_utility_context_route? ||
307
- shallow_utility_route?
308
- end
309
-
310
- def shallow_utility_route?
311
- shallow? && utility_path?
312
- end
313
-
314
- def all_junk?(limit)
315
- return false if limit <= 0
316
-
317
- junk_segments = SEGMENT_SETS.fetch(:high_confidence_junk)
318
- (0...limit).all? { |i| junk_segments.include?(segments[i]) }
319
- end
320
-
321
- def trusted_post_context?(limit)
322
- return false if limit <= 0
323
-
324
- content_segments = SEGMENT_SETS.fetch(:content)
325
- context_segments = SEGMENT_SETS.fetch(:deep_post_context)
326
-
327
- (0...limit).any? do |i|
328
- segment = segments[i]
329
- content_segments.include?(segment) ||
330
- segment.match?(PathClassifier::YEARISH_SEGMENT) ||
331
- context_segments.include?(segment)
332
- end
333
- end
334
-
335
- def included_last_segment?
336
- !excluded_last_segment? && slug_last_segment?
337
- end
338
-
339
- def excluded_last_segment?
340
- last = segments.last
341
- [SEGMENT_SETS[:high_confidence_junk], SEGMENT_SETS[:vanity]].any? { |set| set.include?(last) }
342
- end
343
-
344
- def slug_last_segment?
345
- last = segments.last
346
- last.match?(YEARISH_SEGMENT) || last.match?(POST_SLUG_SEGMENT)
347
- end
348
- end
349
-
350
- # @param base_url [String, Html2rss::Url] page URL used to resolve relative hrefs
351
- def initialize(base_url)
352
- @base_url = base_url
353
- @text_classifier = TextClassifier.new
354
- end
355
-
356
- # Builds normalized destination facts for an anchor element or href string.
357
- #
358
- # @param anchor_or_href [Nokogiri::XML::Element, String, #to_s] anchor element or href-like value
359
- # @return [DestinationFacts, nil] normalized destination facts, or nil for blank/invalid URLs
360
- def destination_facts(anchor_or_href)
361
- return node_facts[anchor_or_href] if node_facts.key?(anchor_or_href)
362
-
363
- href = HrefExtractor.call(anchor_or_href)
364
- return unless href
365
-
366
- res = memoized_destination_facts(href)
367
-
368
- node_facts[anchor_or_href] = res if anchor_or_href.is_a?(Nokogiri::XML::Node)
369
- res
370
- rescue ArgumentError
371
- nil
372
- end
373
-
374
- # @param text [String, #to_s] visible anchor text
375
- # @return [Boolean] true when text matches a utility label
376
- def utility_text?(text) = @text_classifier.utility?(text)
377
-
378
- # @param text [String, #to_s] visible anchor text
379
- # @return [Boolean] true when text begins with a utility label
380
- def utility_prefix_text?(text) = @text_classifier.utility_prefix?(text)
381
-
382
- # @param text [String, #to_s] visible anchor text
383
- # @return [Boolean] true when text identifies recommendation chrome
384
- def recommended_text?(text) = @text_classifier.recommended?(text)
385
-
386
- private
387
-
388
- def node_facts
389
- @node_facts ||= {}.compare_by_identity
390
- end
391
-
392
- def memoized_destination_facts(href)
393
- (@destination_facts ||= {})[href] ||= begin
394
- url = Html2rss::Url.from_relative(href, @base_url)
395
- DestinationFacts.build(url)
396
- end
397
- end
398
- end
399
- end
400
- end
401
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- module Scraper
6
- class SemanticHtml
7
- ##
8
- # Selects the best content-like anchor from a semantic container.
9
- #
10
- # The selector turns raw DOM anchors into ranked facts so semantic
11
- # scraping can reason about link intent instead of DOM order. It favors
12
- # heading-aligned article links and suppresses utility links, duplicate
13
- # destinations, and weak textless affordances.
14
- class AnchorSelector
15
- # Comma-separated heading selector used for heading/anchor matching.
16
- HEADING_SELECTOR = HtmlExtractor::HEADING_TAGS.join(',').freeze
17
-
18
- # @param base_url [String, Html2rss::Url] page URL used to normalize href destinations
19
- def initialize(base_url)
20
- @link_heuristics = LinkHeuristics.new(base_url)
21
- end
22
-
23
- ##
24
- # Chooses the single anchor that best represents the story contained
25
- # in a semantic block.
26
- #
27
- # Ranking is scoped to one container at a time. That keeps the logic
28
- # local, makes duplicate links to the same destination collapse into
29
- # one candidate, and avoids page-wide heuristics leaking across cards.
30
- #
31
- # @param container [Nokogiri::XML::Element] semantic container being evaluated
32
- # @return [Nokogiri::XML::Element, nil] selected primary anchor or nil when none qualify
33
- def primary_anchor_for(container)
34
- facts_for(container).max_by(&:score)&.anchor
35
- end
36
-
37
- private
38
-
39
- def facts_for(container)
40
- HtmlExtractor::SemanticAnchorCandidates.new(
41
- container,
42
- link_heuristics: @link_heuristics
43
- ).to_a
44
- end
45
- end
46
- end
47
- end
48
- end
49
- end
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- ##
5
- # Shared anti-bot/interstitial signatures used by request and auto-source flows.
6
- #
7
- # This module centralizes signature matching so request-time guards and
8
- # auto-source surface classification stay consistent.
9
- module BlockedSurface
10
- # Known interstitial fingerprints used to detect blocked or anti-bot surfaces.
11
- INTERSTITIAL_SIGNATURES = [
12
- {
13
- key: :cloudflare_interstitial,
14
- min_matches: 2,
15
- patterns: [
16
- %r{<title>\s*just a moment\.\.\.\s*</title>}i,
17
- /checking your browser before accessing/i,
18
- /please (?:enable|turn on) javascript and cookies/i,
19
- %r{cdn-cgi/challenge-platform}i,
20
- /cloudflare ray id/i
21
- ],
22
- message: 'Blocked surface detected: Cloudflare anti-bot interstitial page. ' \
23
- 'Retry with --strategy browserless, try a more specific public listing URL, ' \
24
- 'or run from an environment that can complete anti-bot checks.'
25
- }
26
- ].freeze
27
-
28
- ##
29
- # Returns the first matching interstitial signature for the provided body.
30
- #
31
- # @param body [String, nil] response body candidate
32
- # @return [Hash, nil] signature hash when matched, otherwise nil
33
- def self.interstitial_signature_for(body)
34
- normalized_body = normalize_body(body)
35
- INTERSTITIAL_SIGNATURES.find { |signature| interstitial_signature_match?(normalized_body, signature) }
36
- end
37
-
38
- ##
39
- # @param body [String, nil] response body candidate
40
- # @return [Boolean] true when body matches a known interstitial signature
41
- def self.interstitial?(body)
42
- !interstitial_signature_for(body).nil?
43
- end
44
-
45
- def self.interstitial_signature_match?(body, signature)
46
- min_matches = signature.fetch(:min_matches, 1)
47
- matches = 0
48
-
49
- signature.fetch(:patterns).each do |pattern|
50
- matches += 1 if pattern.match?(body)
51
- return true if matches >= min_matches
52
- end
53
-
54
- false
55
- end
56
- private_class_method :interstitial_signature_match?
57
-
58
- def self.normalize_body(body)
59
- body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
60
- rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
61
- body.to_s.force_encoding(Encoding::UTF_8).scrub
62
- end
63
- private_class_method :normalize_body
64
- end
65
- end
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- ##
5
- # CategoryExtractor is responsible for extracting categories from HTML elements
6
- # by looking for CSS class names containing common category-related terms.
7
- class CategoryExtractor
8
- # Common category-related terms to look for in class names
9
- CATEGORY_TERMS = %w[category tag topic section label theme subject].freeze
10
-
11
- # CSS selectors to find elements with category-related class names or data attributes
12
- CATEGORY_SELECTORS = CATEGORY_TERMS.flat_map do |term|
13
- ["[class*=\"#{term}\"]", "[data-#{term}]", "[#{term}]"]
14
- end.freeze
15
-
16
- # Regex pattern for matching category-related attribute names
17
- CATEGORY_ATTR_PATTERN = /#{CATEGORY_TERMS.join('|')}/i
18
-
19
- ##
20
- # Extracts categories from the given article tag by looking for elements
21
- # with class names containing common category-related terms.
22
- #
23
- # @param article_tag [Nokogiri::XML::Element] The article element to extract categories from
24
- # @return [Array<String>] Array of category strings, empty if none found
25
- def self.call(article_tag)
26
- return [] unless article_tag
27
-
28
- # Single optimized traversal that extracts all category types
29
- extract_all_categories(article_tag)
30
- .map(&:strip)
31
- .reject(&:empty?)
32
- end
33
-
34
- ##
35
- # Optimized single DOM traversal that extracts all category types.
36
- #
37
- # @param article_tag [Nokogiri::XML::Element] The article element
38
- # @return [Set<String>] Set of category strings
39
- def self.extract_all_categories(article_tag)
40
- Set.new.tap do |categories|
41
- article_tag.css(CATEGORY_SELECTORS.join(',')).each do |element|
42
- # Extract text categories from elements with category-related class names
43
- extract_text_categories!(categories, element) if element['class']&.match?(CATEGORY_ATTR_PATTERN)
44
-
45
- # Extract data categories from all elements
46
- extract_element_data_categories!(categories, element)
47
- end
48
- end
49
- end
50
-
51
- ##
52
- # Extracts categories from data attributes of a single element.
53
- #
54
- # @param categories [Set<String>] Accumulator set
55
- # @param element [Nokogiri::XML::Element] metadata element that may contain category links
56
- # @return [void]
57
- def self.extract_element_data_categories!(categories, element)
58
- element.attributes.each_value do |attr|
59
- next unless attr.name.match?(CATEGORY_ATTR_PATTERN)
60
-
61
- value = attr.value&.strip
62
- categories.add(value) if value && !value.empty?
63
- end
64
- end
65
-
66
- ##
67
- # Extracts text-based categories from elements, splitting content into discrete values.
68
- #
69
- # @param categories [Set<String>] Accumulator set
70
- # @param element [Nokogiri::XML::Element] metadata element whose text may contain delimiters
71
- # @return [void]
72
- def self.extract_text_categories!(categories, element)
73
- if element.name == 'a'
74
- add_text_to_categories!(categories, element)
75
- return
76
- end
77
-
78
- anchors = element.css('a')
79
-
80
- if anchors.any?
81
- anchors.each { |node| add_text_to_categories!(categories, node) }
82
- else
83
- extract_split_text_categories!(categories, element)
84
- end
85
- end
86
-
87
- ##
88
- # Adds the visible text of the given element to the categories set.
89
- #
90
- # @param categories [Set<String>] Accumulator set
91
- # @param element [Nokogiri::XML::Element] The element to extract text from
92
- # @return [void]
93
- def self.add_text_to_categories!(categories, element)
94
- text = HtmlExtractor.extract_visible_text(element)
95
- categories.add(text) if text && !text.empty?
96
- end
97
-
98
- ##
99
- # Extracts categories from the element's text by splitting on newlines.
100
- #
101
- # @param categories [Set<String>] Accumulator set
102
- # @param element [Nokogiri::XML::Element] The element to extract text from
103
- # @return [void]
104
- def self.extract_split_text_categories!(categories, element)
105
- text = HtmlExtractor.extract_visible_text(element)
106
- return unless text
107
-
108
- text.split(/\n+/).each do |line|
109
- line = line.strip
110
- categories.add(line) unless line.empty?
111
- end
112
- end
113
-
114
- private_class_method :add_text_to_categories!, :extract_split_text_categories!
115
- end
116
- end