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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html2rss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gil Desmarais
@@ -113,6 +113,20 @@ dependencies:
113
113
  - - ">="
114
114
  - !ruby/object:Gem::Version
115
115
  version: '0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: mcp
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '1.0'
123
+ type: :runtime
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '1.0'
116
130
  - !ruby/object:Gem::Dependency
117
131
  name: mime-types
118
132
  requirement: !ruby/object:Gem::Requirement
@@ -148,19 +162,33 @@ dependencies:
148
162
  - !ruby/object:Gem::Version
149
163
  version: '2.0'
150
164
  - !ruby/object:Gem::Dependency
151
- name: puppeteer-ruby
165
+ name: rack
152
166
  requirement: !ruby/object:Gem::Requirement
153
167
  requirements:
154
- - - ">="
168
+ - - "~>"
155
169
  - !ruby/object:Gem::Version
156
- version: '0'
170
+ version: '3.0'
157
171
  type: :runtime
158
172
  prerelease: false
159
173
  version_requirements: !ruby/object:Gem::Requirement
160
174
  requirements:
161
- - - ">="
175
+ - - "~>"
162
176
  - !ruby/object:Gem::Version
163
- version: '0'
177
+ version: '3.0'
178
+ - !ruby/object:Gem::Dependency
179
+ name: rackup
180
+ requirement: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - "~>"
183
+ - !ruby/object:Gem::Version
184
+ version: '2.0'
185
+ type: :runtime
186
+ prerelease: false
187
+ version_requirements: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - "~>"
190
+ - !ruby/object:Gem::Version
191
+ version: '2.0'
164
192
  - !ruby/object:Gem::Dependency
165
193
  name: regexp_parser
166
194
  requirement: !ruby/object:Gem::Requirement
@@ -245,6 +273,20 @@ dependencies:
245
273
  - - ">="
246
274
  - !ruby/object:Gem::Version
247
275
  version: '0'
276
+ - !ruby/object:Gem::Dependency
277
+ name: webrick
278
+ requirement: !ruby/object:Gem::Requirement
279
+ requirements:
280
+ - - "~>"
281
+ - !ruby/object:Gem::Version
282
+ version: '1.9'
283
+ type: :runtime
284
+ prerelease: false
285
+ version_requirements: !ruby/object:Gem::Requirement
286
+ requirements:
287
+ - - "~>"
288
+ - !ruby/object:Gem::Version
289
+ version: '1.9'
248
290
  - !ruby/object:Gem::Dependency
249
291
  name: zeitwerk
250
292
  requirement: !ruby/object:Gem::Requirement
@@ -278,25 +320,13 @@ files:
278
320
  - lib/html2rss/article/enclosure.rb
279
321
  - lib/html2rss/auto_source.rb
280
322
  - lib/html2rss/auto_source/cleanup.rb
281
- - lib/html2rss/auto_source/discovery.rb
282
- - lib/html2rss/auto_source/discovery/dom_clustering.rb
283
- - lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb
284
- - lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb
285
- - lib/html2rss/auto_source/discovery/list_candidates.rb
286
- - lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb
287
- - lib/html2rss/auto_source/discovery/semantic_containers.rb
288
- - lib/html2rss/auto_source/discovery/sitemap.rb
289
- - lib/html2rss/auto_source/link_heuristics.rb
290
- - lib/html2rss/auto_source/link_heuristics/anchor_signals.rb
291
- - lib/html2rss/auto_source/link_heuristics/container_assessor.rb
292
- - lib/html2rss/auto_source/link_heuristics/container_signals.rb
293
- - lib/html2rss/auto_source/link_heuristics/destination_facts.rb
294
- - lib/html2rss/auto_source/link_heuristics/href_extractor.rb
295
- - lib/html2rss/auto_source/link_heuristics/path_classifier.rb
296
- - lib/html2rss/auto_source/link_heuristics/text_classifier.rb
297
323
  - lib/html2rss/auto_source/scraper.rb
298
324
  - lib/html2rss/auto_source/scraper/html.rb
299
325
  - lib/html2rss/auto_source/scraper/json_state.rb
326
+ - lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb
327
+ - lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb
328
+ - lib/html2rss/auto_source/scraper/json_state/document_scanner.rb
329
+ - lib/html2rss/auto_source/scraper/json_state/value_finder.rb
300
330
  - lib/html2rss/auto_source/scraper/meta_oembed.rb
301
331
  - lib/html2rss/auto_source/scraper/microdata.rb
302
332
  - lib/html2rss/auto_source/scraper/microformats2.rb
@@ -307,10 +337,19 @@ files:
307
337
  - lib/html2rss/auto_source/scraper/semantic_html.rb
308
338
  - lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb
309
339
  - lib/html2rss/auto_source/scraper/sitemap.rb
340
+ - lib/html2rss/auto_source/scraper/sitemap/parser.rb
310
341
  - lib/html2rss/auto_source/scraper/wordpress_api.rb
311
342
  - lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb
312
343
  - lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb
313
344
  - lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb
345
+ - lib/html2rss/auto_source/scraper/xhr_articles.rb
346
+ - lib/html2rss/auto_source/segment.rb
347
+ - lib/html2rss/auto_source/segmenter.rb
348
+ - lib/html2rss/auto_source/segmenter/cluster.rb
349
+ - lib/html2rss/auto_source/segmenter/list.rb
350
+ - lib/html2rss/auto_source/segmenter/primary_link.rb
351
+ - lib/html2rss/auto_source/segmenter/semantic.rb
352
+ - lib/html2rss/capture.rb
314
353
  - lib/html2rss/channel.rb
315
354
  - lib/html2rss/cli.rb
316
355
  - lib/html2rss/config.rb
@@ -344,29 +383,39 @@ files:
344
383
  - lib/html2rss/html/article_extractor/heading_extractor.rb
345
384
  - lib/html2rss/html/article_extractor/id_generator.rb
346
385
  - lib/html2rss/html/article_extractor/image_extractor.rb
386
+ - lib/html2rss/html/article_rules.rb
387
+ - lib/html2rss/html/article_rules/category.rb
388
+ - lib/html2rss/html/article_rules/date.rb
389
+ - lib/html2rss/html/article_rules/enclosure.rb
390
+ - lib/html2rss/html/article_rules/image.rb
347
391
  - lib/html2rss/html/navigator.rb
348
392
  - lib/html2rss/html/navigator/text_extractor.rb
349
393
  - lib/html2rss/html/rendering.rb
350
394
  - lib/html2rss/html/rendering/audio_renderer.rb
351
395
  - lib/html2rss/html/rendering/description_builder.rb
396
+ - lib/html2rss/html/rendering/escaped_attributes.rb
352
397
  - lib/html2rss/html/rendering/image_renderer.rb
353
398
  - lib/html2rss/html/rendering/media_renderer.rb
354
399
  - lib/html2rss/html/rendering/pdf_renderer.rb
355
400
  - lib/html2rss/html/rendering/video_renderer.rb
401
+ - lib/html2rss/html/sst_article_extractor.rb
402
+ - lib/html2rss/link_destination.rb
403
+ - lib/html2rss/link_destination/destination_facts.rb
404
+ - lib/html2rss/link_destination/noise_policy.rb
405
+ - lib/html2rss/link_destination/path_classifier.rb
406
+ - lib/html2rss/link_destination/text_classifier.rb
407
+ - lib/html2rss/mcp.rb
408
+ - lib/html2rss/mcp/server.rb
356
409
  - lib/html2rss/request_service.rb
357
410
  - lib/html2rss/request_service/blocked_surface.rb
358
411
  - lib/html2rss/request_service/botasaurus_contract.rb
359
412
  - lib/html2rss/request_service/botasaurus_strategy.rb
360
- - lib/html2rss/request_service/browserless_strategy.rb
361
413
  - lib/html2rss/request_service/budget.rb
362
414
  - lib/html2rss/request_service/context.rb
363
415
  - lib/html2rss/request_service/faraday_strategy.rb
364
416
  - lib/html2rss/request_service/local_file_strategy.rb
365
417
  - lib/html2rss/request_service/network_guard.rb
366
418
  - lib/html2rss/request_service/policy.rb
367
- - lib/html2rss/request_service/puppet_commander.rb
368
- - lib/html2rss/request_service/puppet_commander/navigation_guards.rb
369
- - lib/html2rss/request_service/puppet_commander/preload_runner.rb
370
419
  - lib/html2rss/request_service/response.rb
371
420
  - lib/html2rss/request_service/response_guard.rb
372
421
  - lib/html2rss/request_service/strategy.rb
@@ -378,6 +427,14 @@ files:
378
427
  - lib/html2rss/request_session/pager/offset.rb
379
428
  - lib/html2rss/request_session/pager/rel_next.rb
380
429
  - lib/html2rss/request_session/pager/url_template.rb
430
+ - lib/html2rss/scoring.rb
431
+ - lib/html2rss/scoring/cluster_scorer.rb
432
+ - lib/html2rss/scoring/container_assessor.rb
433
+ - lib/html2rss/scoring/engine.rb
434
+ - lib/html2rss/scoring/link_resolver.rb
435
+ - lib/html2rss/scoring/observation.rb
436
+ - lib/html2rss/scoring/ranked_segment.rb
437
+ - lib/html2rss/scoring/score.rb
381
438
  - lib/html2rss/selectors.rb
382
439
  - lib/html2rss/selectors/extractors.rb
383
440
  - lib/html2rss/selectors/extractors/attribute.rb
@@ -399,6 +456,15 @@ files:
399
456
  - lib/html2rss/selectors/post_processors/sanitize_html.rb
400
457
  - lib/html2rss/selectors/post_processors/substring.rb
401
458
  - lib/html2rss/selectors/post_processors/template.rb
459
+ - lib/html2rss/selectors/schema_doc.rb
460
+ - lib/html2rss/sst.rb
461
+ - lib/html2rss/sst/attrs.rb
462
+ - lib/html2rss/sst/document.rb
463
+ - lib/html2rss/sst/index.rb
464
+ - lib/html2rss/sst/node.rb
465
+ - lib/html2rss/sst/normalizer.rb
466
+ - lib/html2rss/sst/tags.rb
467
+ - lib/html2rss/sst/text.rb
402
468
  - lib/html2rss/status.rb
403
469
  - lib/html2rss/url.rb
404
470
  - lib/html2rss/version.rb
@@ -409,7 +475,7 @@ licenses:
409
475
  - MIT
410
476
  metadata:
411
477
  allowed_push_host: https://rubygems.org
412
- changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.24.0
478
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.26.0
413
479
  rubygems_mfa_required: 'true'
414
480
  rdoc_options: []
415
481
  require_paths:
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- module Discovery
6
- class DomClustering
7
- ##
8
- # Scores candidate DOM groups using heading, time, date, and word-count signals.
9
- class GroupScorer
10
- def initialize
11
- @text_words = {}.compare_by_identity
12
- @has_date = {}.compare_by_identity
13
- end
14
-
15
- # @param groups [Hash{String => Array<Nokogiri::XML::Node>}] candidate DOM groups
16
- # @return [Array<Nokogiri::XML::Node>] nodes from the highest-scoring group
17
- def select_best_group(groups)
18
- best_nodes = []
19
- best_score = -1
20
-
21
- groups.each_value do |nodes|
22
- score = score_group(nodes)
23
- next if score.negative?
24
-
25
- (best_nodes = nodes) && (best_score = score) if score > best_score
26
- end
27
-
28
- best_nodes
29
- end
30
-
31
- # @param nodes [Array<Nokogiri::XML::Node>]
32
- # @return [Float] average visible word count across nodes
33
- def avg_words(nodes)
34
- nodes.sum { |n| text_words(n) } / nodes.size.to_f
35
- end
36
-
37
- private
38
-
39
- def score_group(nodes)
40
- avg_w = avg_words(nodes)
41
- return -1 if avg_w < 5
42
-
43
- score = nodes.size + (avg_w / 5.0)
44
- score += 20 if nodes_heading?(nodes)
45
- score += 20 if nodes_time?(nodes)
46
- score += 40 if nodes_date?(nodes)
47
- score
48
- end
49
-
50
- def nodes_heading?(nodes)
51
- nodes.any? do |n|
52
- n.at_css(Html2rss::Html::Navigator::HEADING_TAGS.join(',')) ||
53
- n.at_css('.font-bold, .font-semibold')
54
- end
55
- end
56
-
57
- def nodes_time?(nodes)
58
- nodes.any? { |n| n.at_css('time, [datetime]') }
59
- end
60
-
61
- def nodes_date?(nodes)
62
- nodes.any? { |n| date?(n) }
63
- end
64
-
65
- def text_words(node)
66
- @text_words[node] ||= Html2rss::Html::Navigator.extract_visible_text(node).to_s.scan(/\p{Alnum}+/).size
67
- end
68
-
69
- def date?(node)
70
- @has_date[node] ||= begin
71
- text = Html2rss::Html::Navigator.extract_visible_text(node).to_s
72
- text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
73
- text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
74
- end
75
- end
76
- end
77
- end
78
- end
79
- end
80
- end
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- module Discovery
6
- class DomClustering
7
- ##
8
- # Filters layout wrapper groups and resolves 1-to-1 nested card wrappers.
9
- class OverlapResolver
10
- # @param layout_tags [Set<String>] tags treated as layout containers
11
- # @param word_counter [#avg_words] averages visible word counts for nodes
12
- def initialize(layout_tags:, word_counter:)
13
- @layout_tags = layout_tags
14
- @word_counter = word_counter
15
- end
16
-
17
- # Discard group A if any node of A contains > 1 node of another group B
18
- #
19
- # @param groups [Hash{String => Array<Nokogiri::XML::Node>}] candidate groups
20
- # @return [Hash{String => Array<Nokogiri::XML::Node>}] groups that are not layout wrappers
21
- def filter_containers(groups)
22
- groups.reject do |cls_a, nodes_a|
23
- groups.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
24
- end
25
- end
26
-
27
- # If group A contains group B with the same size, keep the real content card.
28
- #
29
- # @param groups [Hash{String => Array<Nokogiri::XML::Node>}] candidate groups
30
- # @return [Hash{String => Array<Nokogiri::XML::Node>}] groups after 1-to-1 resolution
31
- def filter_1_to_1_overlap(groups)
32
- discarded = Set.new
33
- groups.each_key do |cls_a|
34
- groups.each_key do |cls_b|
35
- next if cls_a == cls_b || discarded.include?(cls_a) || discarded.include?(cls_b)
36
-
37
- resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
38
- end
39
- end
40
-
41
- groups.except(*discarded)
42
- end
43
-
44
- private
45
-
46
- attr_reader :layout_tags, :word_counter
47
-
48
- # rubocop:disable Metrics/MethodLength
49
- def container_of?(nodes_a, nodes_b)
50
- return false unless layout_tags.include?(nodes_b.first.name)
51
-
52
- nodes_a.any? do |node_a|
53
- count = 0
54
- nodes_b.each do |node_b|
55
- next if node_a == node_b
56
-
57
- if Html2rss::Html::Navigator.descendant_of?(node_b, node_a)
58
- count += 1
59
- break if count > 1
60
- end
61
- end
62
- count > 1
63
- end
64
- end
65
- # rubocop:enable Metrics/MethodLength
66
-
67
- def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
68
- nodes_a = groups[cls_a]
69
- nodes_b = groups[cls_b]
70
- return if nodes_a.size != nodes_b.size
71
-
72
- nested = nodes_a.zip(nodes_b).all? { |a, b| a != b && Html2rss::Html::Navigator.descendant_of?(b, a) }
73
- return unless nested
74
-
75
- discarded << (keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b)
76
- end
77
-
78
- def keep_descendant?(nodes_a, nodes_b)
79
- word_counter.avg_words(nodes_b) >= 0.8 * word_counter.avg_words(nodes_a) &&
80
- layout_tags.include?(nodes_b.first.name)
81
- end
82
- end
83
- end
84
- end
85
- end
86
- end
@@ -1,119 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- module Discovery
6
- ##
7
- # Discovers repeated content card nodes in anchorless or classless DOM trees
8
- # by evaluating class groups first and falling back to 1-level tag structure signatures.
9
- class DomClustering
10
- # Node tags considered layout containers
11
- LAYOUT_TAG_NAMES = Set['div', 'section', 'article', 'li', 'ul', 'ol'].freeze
12
- # HTML/layout tags excluded from candidate nodes (owned by Html::Navigator).
13
- EXCLUDED_TAGS = Html2rss::Html::Navigator::CLUSTER_EXCLUDED_TAGS
14
-
15
- class << self
16
- ##
17
- # Clusters elements in parsed_body and returns the best set of content card nodes.
18
- #
19
- # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
20
- # @param minimum_selector_frequency [Integer] minimum frequency for candidate groups
21
- # @return [Array<Nokogiri::XML::Node>] candidate nodes of the top-scoring cluster
22
- def call(parsed_body, minimum_selector_frequency:)
23
- new(parsed_body, minimum_frequency: minimum_selector_frequency).call
24
- end
25
- end
26
-
27
- # @param parsed_body [Nokogiri::HTML::Document]
28
- # @param minimum_frequency [Integer]
29
- def initialize(parsed_body, minimum_frequency:)
30
- @parsed_body = parsed_body
31
- @minimum_frequency = minimum_frequency
32
- @cache = {}.compare_by_identity
33
- end
34
-
35
- # @return [Array<Nokogiri::XML::Node>]
36
- def call
37
- class_result = cluster_by_class
38
- return class_result unless class_result.empty?
39
-
40
- cluster_by_structure
41
- end
42
-
43
- private
44
-
45
- attr_reader :parsed_body, :minimum_frequency, :cache
46
-
47
- def cluster_by_class
48
- class_groups = collect_class_groups
49
- return [] if class_groups.empty?
50
-
51
- score_groups(class_groups)
52
- end
53
-
54
- def cluster_by_structure
55
- structure_groups = collect_structure_groups
56
- return [] if structure_groups.empty?
57
-
58
- score_groups(structure_groups)
59
- end
60
-
61
- def score_groups(candidate_groups)
62
- scorer = GroupScorer.new
63
- resolver = OverlapResolver.new(layout_tags: LAYOUT_TAG_NAMES, word_counter: scorer)
64
-
65
- non_containers = resolver.filter_containers(candidate_groups)
66
- final_groups = resolver.filter_1_to_1_overlap(non_containers)
67
-
68
- scorer.select_best_group(final_groups)
69
- end
70
-
71
- def collect_class_groups
72
- groups = Hash.new { |h, k| h[k] = [] }
73
- parsed_body.css('[class]').each { |node| add_class_node(node, groups) }
74
- groups.select { |_, nodes| nodes.size >= minimum_frequency }
75
- end
76
-
77
- def add_class_node(node, groups)
78
- return if EXCLUDED_TAGS.include?(node.name)
79
- return if Html2rss::Html::Navigator.ignored_container_path?(node, cache)
80
-
81
- cls = normalize_class(node['class'])
82
- groups[cls] << node unless cls.empty?
83
- end
84
-
85
- def collect_structure_groups
86
- groups = Hash.new { |h, k| h[k] = [] }
87
- parsed_body.xpath('//*').each { |node| add_structure_node(node, groups) }
88
- groups.select { |_, nodes| nodes.size >= minimum_frequency }
89
- end
90
-
91
- def add_structure_node(node, groups)
92
- return if EXCLUDED_TAGS.include?(node.name)
93
- return if Html2rss::Html::Navigator.ignored_container_path?(node, cache)
94
-
95
- sig = structure_signature(node)
96
- groups[sig] << node unless sig.empty?
97
- end
98
-
99
- def normalize_class(class_attr)
100
- class_str = class_attr.to_s.strip
101
- return '' if class_str.empty?
102
-
103
- if class_str.include?(' ')
104
- class_str.split(/\s+/).sort.join(' ')
105
- else
106
- class_str
107
- end
108
- end
109
-
110
- def structure_signature(node)
111
- child_elements = node.element_children
112
- return '' if child_elements.empty?
113
-
114
- child_elements.map(&:name).join('>')
115
- end
116
- end
117
- end
118
- end
119
- end
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Html2rss
4
- class AutoSource
5
- module Discovery
6
- ##
7
- # Builds repeated-list article container candidates from generic HTML.
8
- class ListCandidates
9
- ##
10
- # Simplify an XPath selector by removing index notation.
11
- #
12
- # @param xpath [String] original XPath
13
- # @return [String] XPath without positional indexes
14
- def self.simplify_xpath(xpath)
15
- xpath.gsub(/\[\d+\]/, '')
16
- end
17
-
18
- # @param parsed_body [Nokogiri::HTML::Document] parsed document
19
- # @param minimum_selector_frequency [Integer] minimum repeated anchor path count
20
- # @param use_top_selectors [Integer] number of frequent anchor paths to inspect
21
- def initialize(parsed_body, minimum_selector_frequency:, use_top_selectors:)
22
- @parsed_body = parsed_body
23
- @minimum_selector_frequency = minimum_selector_frequency
24
- @use_top_selectors = use_top_selectors
25
- end
26
-
27
- ##
28
- # @param anchor_filter [#call] predicate for scraper-specific anchor eligibility
29
- # @param boundary_condition [#call] predicate for article container boundary
30
- # @yieldparam article_tag [Nokogiri::XML::Node] candidate article container
31
- # @yieldparam selected_anchor [Nokogiri::XML::Node] anchor that made the container eligible
32
- # @return [Enumerator]
33
- def each_article_tag(anchor_filter:, boundary_condition:)
34
- return enum_for(:each_article_tag, anchor_filter:, boundary_condition:) unless block_given?
35
-
36
- article_tags(anchor_filter:, boundary_condition:).each { yield _1[:article_tag], _1[:selected_anchor] }
37
- end
38
-
39
- private
40
-
41
- attr_reader :parsed_body, :minimum_selector_frequency, :use_top_selectors
42
-
43
- def article_tags(anchor_filter:, boundary_condition:)
44
- cache = {}.compare_by_identity
45
- selectors(anchor_filter:).flat_map do |selector|
46
- article_tags_for_selector(selector, boundary_condition, cache)
47
- end
48
- end
49
-
50
- def article_tags_for_selector(selector, boundary_condition, cache = {}.compare_by_identity)
51
- parsed_body.xpath(selector).filter_map do |selected_tag|
52
- next if Html2rss::Html::Navigator.ignored_container_path?(selected_tag, cache)
53
-
54
- article_tag = Html2rss::Html::Navigator.parent_until_condition(selected_tag, boundary_condition)
55
- next unless article_tag
56
-
57
- { article_tag:, selected_anchor: selected_tag }
58
- end
59
- end
60
-
61
- def selectors(anchor_filter:)
62
- anchor_counts(anchor_filter:)
63
- .select { |_selector, count| count >= minimum_selector_frequency }
64
- .max_by(use_top_selectors, &:last)
65
- .map(&:first)
66
- end
67
-
68
- def anchor_counts(anchor_filter:)
69
- cache = {}.compare_by_identity
70
- Hash.new(0).tap do |counts|
71
- each_anchor(anchor_filter:) do |node|
72
- next if Html2rss::Html::Navigator.ignored_container_path?(node, cache)
73
-
74
- path = self.class.simplify_xpath(node.path)
75
- counts[path] += 1
76
- end
77
- end
78
- end
79
-
80
- def each_anchor(anchor_filter:)
81
- return enum_for(:each_anchor, anchor_filter:) unless block_given?
82
-
83
- traversal_root&.css(Html2rss::Html::Navigator::MAIN_ANCHOR_SELECTOR)&.each do |node|
84
- yield node if anchor_filter.call(node)
85
- end
86
- end
87
-
88
- def traversal_root
89
- parsed_body.at_css('body, html') || parsed_body.root
90
- end
91
- end
92
- end
93
- end
94
- end