html2rss 0.22.2 → 0.24.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 (146) 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 +213 -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/category_extractor.rb +29 -114
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
  29. data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
  30. data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
  32. data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
  33. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
  35. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
  36. data/lib/html2rss/auto_source/scraper.rb +10 -3
  37. data/lib/html2rss/auto_source.rb +30 -35
  38. data/lib/html2rss/channel.rb +198 -0
  39. data/lib/html2rss/cli.rb +5 -6
  40. data/lib/html2rss/config/auto_source_contract.rb +51 -0
  41. data/lib/html2rss/config/request_controls.rb +136 -0
  42. data/lib/html2rss/config/request_headers.rb +1 -1
  43. data/lib/html2rss/config/schema.rb +65 -114
  44. data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
  45. data/lib/html2rss/config/validator.rb +6 -6
  46. data/lib/html2rss/config.rb +176 -19
  47. data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
  48. data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
  49. data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
  50. data/lib/html2rss/feed_builder/json_feed.rb +81 -0
  51. data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
  52. data/lib/html2rss/feed_builder/rss.rb +116 -0
  53. data/lib/html2rss/feed_builder.rb +15 -0
  54. data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
  55. data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
  56. data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
  57. data/lib/html2rss/feed_pipeline.rb +83 -89
  58. data/lib/html2rss/feed_result.rb +92 -0
  59. data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
  60. data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
  61. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
  62. data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
  63. data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
  64. data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
  65. data/lib/html2rss/html/article_extractor.rb +134 -0
  66. data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
  67. data/lib/html2rss/html/navigator.rb +146 -0
  68. data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
  69. data/lib/html2rss/html/rendering/description_builder.rb +89 -0
  70. data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
  71. data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
  72. data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
  73. data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
  74. data/lib/html2rss/html/rendering.rb +25 -0
  75. data/lib/html2rss/html.rb +8 -0
  76. data/lib/html2rss/request_service/blocked_surface.rb +67 -0
  77. data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
  78. data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
  79. data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
  80. data/lib/html2rss/request_service/budget.rb +97 -18
  81. data/lib/html2rss/request_service/context.rb +1 -3
  82. data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
  83. data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
  84. data/lib/html2rss/request_service/network_guard.rb +125 -0
  85. data/lib/html2rss/request_service/policy.rb +10 -82
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
  88. data/lib/html2rss/request_service/puppet_commander.rb +22 -191
  89. data/lib/html2rss/request_service/response.rb +9 -1
  90. data/lib/html2rss/request_service/response_guard.rb +1 -1
  91. data/lib/html2rss/request_service/strategy.rb +123 -5
  92. data/lib/html2rss/request_service.rb +7 -2
  93. data/lib/html2rss/request_session/pager/base.rb +83 -0
  94. data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
  95. data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
  96. data/lib/html2rss/request_session/pager/offset.rb +48 -0
  97. data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
  98. data/lib/html2rss/request_session/pager/url_template.rb +48 -0
  99. data/lib/html2rss/request_session/pager.rb +75 -0
  100. data/lib/html2rss/request_session.rb +29 -16
  101. data/lib/html2rss/selectors/item_scope.rb +30 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +1 -1
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
  104. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
  105. data/lib/html2rss/selectors/post_processors/template.rb +7 -3
  106. data/lib/html2rss/selectors.rb +84 -64
  107. data/lib/html2rss/status.rb +132 -0
  108. data/lib/html2rss/version.rb +1 -3
  109. data/lib/html2rss.rb +34 -22
  110. data/schema/html2rss-config.schema.json +109 -9
  111. metadata +71 -43
  112. data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
  113. data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
  114. data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
  115. data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
  116. data/lib/html2rss/blocked_surface.rb +0 -65
  117. data/lib/html2rss/category_extractor.rb +0 -116
  118. data/lib/html2rss/config/class_methods.rb +0 -183
  119. data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
  120. data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
  121. data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
  122. data/lib/html2rss/html_extractor/id_generator.rb +0 -67
  123. data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
  124. data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
  125. data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
  126. data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
  127. data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
  128. data/lib/html2rss/html_extractor.rb +0 -190
  129. data/lib/html2rss/html_navigator.rb +0 -71
  130. data/lib/html2rss/json_feed_builder/item.rb +0 -94
  131. data/lib/html2rss/json_feed_builder.rb +0 -59
  132. data/lib/html2rss/rendering/audio_renderer.rb +0 -36
  133. data/lib/html2rss/rendering/description_builder.rb +0 -87
  134. data/lib/html2rss/rendering/image_renderer.rb +0 -41
  135. data/lib/html2rss/rendering/media_renderer.rb +0 -37
  136. data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
  137. data/lib/html2rss/rendering/video_renderer.rb +0 -36
  138. data/lib/html2rss/rendering.rb +0 -23
  139. data/lib/html2rss/request_controls.rb +0 -133
  140. data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
  141. data/lib/html2rss/request_session/runtime_input.rb +0 -71
  142. data/lib/html2rss/request_session/runtime_policy.rb +0 -84
  143. data/lib/html2rss/rss_builder/article.rb +0 -208
  144. data/lib/html2rss/rss_builder/channel.rb +0 -118
  145. data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
  146. data/lib/html2rss/rss_builder.rb +0 -101
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb40d85981c1bc11576b0c9d7a3eae3948bd49239ad8b9a65bb6a4ea103b54f9
4
- data.tar.gz: 6c4636413e1e995e98adec8d8cc742a5833208c2197a024abe90f4b81fce1774
3
+ metadata.gz: 8e71e2d9112602603e9cbdf23907d2ad6baabc633c22bd9305094c2ab405f9ad
4
+ data.tar.gz: 1239aecc1f56a8ab4f5f83990671d4655127ad481907552e89fcce30af7d25ef
5
5
  SHA512:
6
- metadata.gz: 9134076ad05417b8b7176da26033e4d66137f862781378b44080d4ea4fe67e8932156c8eb7d22b58ad97f0d7f5e32102c96ef84741d681c2c7571b3add09afb0
7
- data.tar.gz: 61958c85cb791adc78d6d18debcdbfb7a90948332696dac0a4ab4aee39f6b098c3366f948d8905d3d36e2052e03e75037006bd2e6cfa3420b9a11e46e3ebce18
6
+ metadata.gz: fe0e1fe5a2b81886b0bda2f9087e12c42a5b309d105bcd7c1ea2268690740ee36390ad54bafb94757d758204968617569f981f489afe05572f41be28c1e4025a
7
+ data.tar.gz: cfad45944b508e5fafaae484cdb625e33ee43e336a2ab74b336f398c7eaab306432cf06d524ddb81b7ec5a002e77c065d7af491a6b245d0a2f5b2df6098aeda2
data/README.md CHANGED
@@ -13,125 +13,28 @@ Most people looking for a first working feed should start with `html2rss-web`, r
13
13
  Detailed usage guides, reference docs, and the feed directory live on the project website:
14
14
 
15
15
  - [Ruby gem documentation](https://html2rss.github.io/ruby-gem)
16
+ - [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto` = `faraday` → `botasaurus`; pin `browserless` explicitly)
17
+ - [Selectors & pagination](https://html2rss.github.io/ruby-gem/reference/selectors#paginated-feeds)
16
18
  - [Web application](https://html2rss.github.io/web-application)
17
19
  - [Feed directory](https://html2rss.github.io/feed-directory)
18
20
  - [Contributing guide](https://html2rss.github.io/get-involved/contributing)
19
21
  - [GitHub Discussions](https://github.com/orgs/html2rss/discussions)
20
22
  - [Sponsor on GitHub](https://github.com/sponsors/gildesmarais)
21
23
 
22
- ### 💻 Try in Browser
24
+ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new?repo=html2rss/html2rss) (also covered in the [installation guide](https://html2rss.github.io/ruby-gem/installation)).
23
25
 
24
- You can develop html2rss directly in your browser using GitHub Codespaces:
26
+ ## Architecture
25
27
 
26
- [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?repo=html2rss/html2rss)
27
-
28
- The Codespace comes pre-configured with Ruby 3.4 (compatible with Ruby 4.0), all dependencies, and VS Code extensions ready to go!
29
-
30
- ## 🤝 Contributing
31
-
32
- Please see the [contributing guide](https://html2rss.github.io/get-involved/contributing) for details on how to contribute.
33
-
34
- ## 🏗️ Architecture
35
-
36
- ### Core Components
37
-
38
- 1. **Config** - Loads and validates configuration (YAML/hash)
39
- 2. **RequestService** - Fetches pages using Faraday, Botasaurus, or Browserless
40
- 3. **Selectors** - Extracts content via CSS selectors with extractors/post-processors
41
- 4. **AutoSource** - Auto-detects content using Schema.org, JSON state blobs, semantic HTML, and structural patterns
42
- 5. **RssBuilder** - Assembles Article objects and renders RSS 2.0
43
-
44
- ### Data Flow
28
+ 1. **Config** loads and validates configuration (YAML/hash); schema via `html2rss schema` / `schema/html2rss-config.schema.json`
29
+ 2. **RequestService** — fetches pages (`faraday`, `botasaurus`, or explicit `browserless`)
30
+ 3. **Selectors** extracts content via CSS selectors with extractors/post-processors
31
+ 4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns)
32
+ 5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1)
45
33
 
46
34
  ```text
47
35
  Config -> Request -> Extraction -> Processing -> Building -> Output
48
36
  ```
49
37
 
50
- ### Request Strategies
51
-
52
- - `auto` (default): pipeline fallback orchestration (`faraday` -> `botasaurus` -> `browserless`) based on extraction outcome and retry policy.
53
- - `faraday`: direct HTTP fetch.
54
- - `botasaurus`: delegates fetching to a Botasaurus scrape API. Requires `BOTASAURUS_SCRAPER_URL` (for example `http://localhost:4010`).
55
- - `browserless`: remote browser rendering via Browserless (`BROWSERLESS_IO_WEBSOCKET_URL` and token as needed).
56
-
57
- Auto fallback shares one request budget across all strategy attempts. For pagination-heavy or dynamic pages, increase `request.max_requests` (or `--max-requests`) when retries exhaust the budget.
58
-
59
- Auto fallback decisions are hidden at the default `LOG_LEVEL=warn`; run with `LOG_LEVEL=info` to include them in CLI output.
60
-
61
- Supported `request.botasaurus` options:
62
-
63
- - `navigation_mode` (`auto`, `get`, `google_get`, `google_get_bypass`; default `auto`)
64
- - `max_retries` (`0..3`; default `2`)
65
- - `wait_for_selector` (string)
66
- - `wait_timeout_seconds` (integer)
67
- - `block_images` (boolean)
68
- - `block_images_and_css` (boolean)
69
- - `wait_for_complete_page_load` (boolean)
70
- - `headless` (boolean, default `false`)
71
- - `proxy` (string)
72
- - `user_agent` (string)
73
- - `window_size` (two-item integer array, for example `[1920, 1080]`)
74
- - `lang` (string, for example `en-US`)
75
-
76
- Minimal YAML config example:
77
-
78
- ```yaml
79
- channel:
80
- url: https://example.com
81
- strategy: botasaurus
82
- auto_source: {}
83
- request:
84
- botasaurus:
85
- navigation_mode: auto
86
- max_retries: 2
87
- headless: false
88
- ```
89
-
90
- Example request payload shape:
91
-
92
- ```json
93
- {
94
- "url": "https://example.com",
95
- "navigation_mode": "auto",
96
- "max_retries": 2,
97
- "headless": false
98
- }
99
- ```
100
-
101
- Example usage:
102
-
103
- ```bash
104
- BOTASAURUS_SCRAPER_URL=http://localhost:4010 html2rss auto https://example.com --strategy botasaurus
105
- ```
106
-
107
- Policy note: html2rss still enforces local request policy preflight and timeout budget. Botasaurus handles browser navigation/rendering internals, so some policy details are delegated to upstream execution.
108
-
109
- ### Config schema workflow
110
-
111
- The config schema is generated from the runtime `dry-validation` contracts and exported for client-side tooling.
112
-
113
- - Ruby API: `Html2rss::Config.json_schema`
114
- - CLI: `html2rss schema`
115
- - CLI options:
116
- - `html2rss schema --write tmp/html2rss-config.schema.json`
117
- - `html2rss schema --no-pretty`
118
- - Runtime validation API: `Html2rss::Config.validate(config_hash)`
119
- - Runtime validation CLI: `html2rss validate config.yml`
120
- - Packaged JSON file: `schema/html2rss-config.schema.json`
121
-
122
- If you are an editor integration, automation script, or AI tool, prefer these stable discovery points:
123
-
124
- - call `html2rss schema` to read the current exported schema
125
- - read `schema/html2rss-config.schema.json` when working from the repository or installed gem
126
- - use `Html2rss::Config.schema_path` if you already have Ruby loaded
127
- - use `Html2rss::Config.validate` or `html2rss validate config.yml` when you need authoritative runtime validation of selector references
128
-
129
- Run `bundle exec rake config:schema` before committing to regenerate `schema/html2rss-config.schema.json` and keep the checked-in JSON Schema in sync with the validators. The exported schema covers client-side validation, while runtime validation remains authoritative for dynamic cross-field checks such as selector-key references.
130
-
131
- ## 📄 License
132
-
133
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
134
-
135
- ## 💖 Sponsoring
38
+ ## License
136
39
 
137
- If you find `html2rss` useful, please consider [sponsoring the project](https://github.com/sponsors/gildesmarais).
40
+ This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
@@ -3,8 +3,7 @@
3
3
  require 'set' # rubocop:disable Lint/RedundantRequireStatement
4
4
 
5
5
  module Html2rss
6
- # Shared helpers that operate on `RssBuilder::Article` collections.
7
- module Articles
6
+ class Article
8
7
  ##
9
8
  # Deduplicates a list of articles while preserving their original order.
10
9
  #
@@ -15,7 +14,7 @@ module Html2rss
15
14
  # defaults to the article object's hash to preserve the original entry.
16
15
  class Deduplicator
17
16
  ##
18
- # @param articles [Array<Html2rss::RssBuilder::Article>]
17
+ # @param articles [Array<Html2rss::Article>]
19
18
  # @raise [ArgumentError] if articles are not provided
20
19
  def initialize(articles)
21
20
  raise ArgumentError, 'articles must be provided' unless articles
@@ -26,7 +25,7 @@ module Html2rss
26
25
  ##
27
26
  # Returns the list of unique articles, preserving the order of the
28
27
  # original collection and keeping the first occurrence of a duplicate.
29
- # @return [Array<Html2rss::RssBuilder::Article>]
28
+ # @return [Array<Html2rss::Article>]
30
29
  def call
31
30
  seen = Set.new
32
31
 
@@ -3,9 +3,9 @@
3
3
  require 'mime/types'
4
4
 
5
5
  module Html2rss
6
- class RssBuilder
6
+ class Article
7
7
  ##
8
- # Represents an enclosure for an RSS item.
8
+ # Represents an enclosure attached to an article (RSS / JSON Feed media).
9
9
  class Enclosure
10
10
  ##
11
11
  # Guesses the content type based on the file extension of the URL.
@@ -24,38 +24,22 @@ module Html2rss
24
24
  content_type.first&.to_s || 'application/octet-stream'
25
25
  end
26
26
 
27
- # @param enclosure [Html2rss::RssBuilder::Enclosure, nil] built enclosure object for the current RSS item
28
- # @param maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
29
- # @return [void]
30
- def self.add(enclosure, maker)
31
- return unless enclosure
32
-
33
- maker.enclosure.tap do |enclosure_maker|
34
- enclosure_maker.url = enclosure.url.to_s
35
- enclosure_maker.type = enclosure.type
36
- enclosure_maker.length = enclosure.bits_length
37
- end
38
- end
39
-
40
27
  # @param url [Html2rss::Url] absolute enclosure URL
41
28
  # @param type [String, nil] optional enclosure MIME type
42
- # @param bits_length [Integer] enclosure byte length (historical name)
43
- def initialize(url:, type: nil, bits_length: 0)
29
+ # @param bytes_length [Integer] enclosure length in bytes
30
+ def initialize(url:, type: nil, bytes_length: 0)
44
31
  raise ArgumentError, 'An Enclosure requires an absolute URL' if !url || !url.absolute?
45
32
 
46
33
  @url = url
47
34
  @type = type
48
- @bits_length = bits_length
35
+ @bytes_length = bytes_length
49
36
  end
50
37
 
51
38
  # @return [String] explicit MIME type or one inferred from URL extension
52
39
  def type = @type || self.class.guess_content_type_from_url(url)
53
40
 
54
41
  # @return [Integer] enclosure length in bytes
55
- def bytes_length = @bits_length
56
-
57
- # @return [Integer] enclosure length in bytes (legacy reader name)
58
- def bits_length = bytes_length
42
+ attr_reader :bytes_length
59
43
 
60
44
  # @return [Html2rss::Url] absolute enclosure URL
61
45
  attr_reader :url
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Html2rss
6
+ ##
7
+ # Article is a simple data object representing an article extracted from a page.
8
+ # It is enumerable and responds to all keys specified in PROVIDED_KEYS.
9
+ #
10
+ # Description and enclosure wire presentation live in {FeedBuilder::ItemPresentation}.
11
+ # rubocop:disable Metrics/ClassLength -- value object retains Marshal + defensive freeze helpers
12
+ class Article
13
+ include Enumerable
14
+ include Comparable
15
+
16
+ # Allowed article attributes accepted by the value object constructor.
17
+ PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
18
+ # Separator used to build deterministic deduplication fingerprints.
19
+ DEDUP_FINGERPRINT_SEPARATOR = '#!/'
20
+ # Sentinel object used to pre-initialize instance variables in the constructor.
21
+ # This ensures all Article instances share the exact same object shape (Ruby 3.3+ optimization),
22
+ # preventing performance warnings and slower instance variable access due to shape transitions
23
+ # when attributes are lazily/conditionally accessed in different sequences.
24
+ NOT_SET = Object.new.freeze
25
+
26
+ # @param options [Hash{Symbol => String}]
27
+ # @option options [String] :id stable article identifier
28
+ # @option options [String] :title article title
29
+ # @option options [String] :description raw extracted description/content (not feed-rendered HTML)
30
+ # @option options [String, Html2rss::Url] :url canonical article URL
31
+ # @option options [String, Html2rss::Url] :image image URL for description / JSON Feed +image+
32
+ # @option options [String] :author author name
33
+ # @option options [String] :guid explicit GUID override
34
+ # @option options [String, Time, DateTime] :published_at publication timestamp
35
+ # @option options [Array<Hash{Symbol => Object}>] :enclosures enclosure attribute hashes
36
+ # @option options [Array<String>] :categories category labels
37
+ # @option options [Class] :scraper scraper class that produced the article
38
+ def initialize(**options)
39
+ @to_h = options.each_with_object({}) { |(key, value), hash| hash[key] = freeze_option(value) }.freeze
40
+
41
+ @url = @image = @guid = @enclosures = @categories = @published_at = NOT_SET
42
+
43
+ return unless (unknown_keys = options.keys - PROVIDED_KEYS).any?
44
+
45
+ Log.warn "Article: unknown keys found: #{unknown_keys.join(', ')}"
46
+ end
47
+
48
+ # Checks if the article is valid based on the presence of URL, ID, and either title or description.
49
+ # @return [Boolean] True if the article is valid, otherwise false.
50
+ def valid?
51
+ !url.to_s.empty? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
52
+ end
53
+
54
+ # @yield [key, value]
55
+ # @return [Enumerator] if no block is given
56
+ def each
57
+ return enum_for(:each) unless block_given?
58
+
59
+ PROVIDED_KEYS.each { |key| yield(key, public_send(key)) }
60
+ end
61
+
62
+ # @return [String, nil] stable article identifier
63
+ def id = blank_string_to_nil(@to_h[:id])
64
+
65
+ # @return [String, nil] article title
66
+ def title = blank_string_to_nil(@to_h[:title])
67
+
68
+ # Raw extracted description — feed HTML enrichment is {FeedBuilder::ItemPresentation.description_for}.
69
+ #
70
+ # @return [String, nil]
71
+ def description = blank_string_to_nil(@to_h[:description])
72
+
73
+ # @return [Url, nil]
74
+ def url
75
+ return @url unless @url == NOT_SET
76
+
77
+ @url = Url.sanitize(@to_h[:url])
78
+ end
79
+
80
+ # @return [Url, nil]
81
+ def image
82
+ return @image unless @image == NOT_SET
83
+
84
+ @image = Url.sanitize(@to_h[:image])
85
+ end
86
+
87
+ # @return [String, nil]
88
+ def author = blank_string_to_nil(@to_h[:author])
89
+
90
+ # Generates a unique identifier based on the URL and ID using CRC32.
91
+ # @return [String]
92
+ def guid
93
+ return @guid unless @guid == NOT_SET
94
+
95
+ @guid = Zlib.crc32(fetch_guid).to_s(36).encode('utf-8')
96
+ end
97
+
98
+ ##
99
+ # Returns a deterministic fingerprint used to detect duplicate articles.
100
+ #
101
+ # @return [String, Integer]
102
+ def deduplication_fingerprint
103
+ dedup_from_url || dedup_from_id || dedup_from_guid || hash
104
+ end
105
+
106
+ # @return [Array<Html2rss::Article::Enclosure>] normalized enclosure objects
107
+ def enclosures
108
+ return @enclosures unless @enclosures == NOT_SET
109
+
110
+ @enclosures = Array(@to_h[:enclosures])
111
+ .map { |enclosure| Enclosure.new(**enclosure) }
112
+ .freeze
113
+ end
114
+
115
+ # @return [Array<String>] normalized, unique category names
116
+ def categories
117
+ return @categories unless @categories == NOT_SET
118
+
119
+ @categories = @to_h[:categories].dup.to_a.tap do |categories|
120
+ categories.map! { |category| category.to_s.strip }
121
+ categories.reject!(&:empty?)
122
+ categories.uniq!
123
+ end.freeze
124
+ end
125
+
126
+ # Parses and returns the published_at time.
127
+ # @return [DateTime, nil]
128
+ def published_at
129
+ return @published_at unless @published_at == NOT_SET
130
+
131
+ string = @to_h[:published_at].to_s.strip
132
+ @published_at = string.empty? ? nil : DateTime.parse(string)
133
+ rescue ArgumentError
134
+ @published_at = nil
135
+ end
136
+
137
+ # @return [Class, nil] scraper class that produced this article
138
+ def scraper
139
+ @to_h[:scraper]
140
+ end
141
+
142
+ # @param other [Object] value compared against this article
143
+ # @return [Integer, nil] comparison result for compatible Article values
144
+ def <=>(other)
145
+ return nil unless other.is_a?(Article)
146
+
147
+ 0 if other.all? { |key, value| value == public_send(key) ? public_send(key) <=> value : false }
148
+ end
149
+
150
+ private
151
+
152
+ ##
153
+ # Marshal only the constructor payload so lazy +NOT_SET+ sentinels do not break
154
+ # {FeedResult} cache round-trips.
155
+ #
156
+ # @return [Hash{Symbol => Object}]
157
+ def marshal_dump = @to_h
158
+
159
+ ##
160
+ # @param payload [Hash{Symbol => Object}] constructor options from {#marshal_dump}
161
+ # @return [void]
162
+ def marshal_load(payload)
163
+ initialize(**payload)
164
+ end
165
+
166
+ def freeze_option(value)
167
+ case value
168
+ when String then value.dup.freeze
169
+ when Array then freeze_array_option(value)
170
+ when Hash then value.transform_values { freeze_option(_1) }.freeze
171
+ else value
172
+ end
173
+ end
174
+
175
+ def freeze_array_option(value)
176
+ value.map do |entry|
177
+ entry.is_a?(Hash) ? entry.transform_values { freeze_option(_1) }.freeze : freeze_option(entry)
178
+ end.freeze
179
+ end
180
+
181
+ def dedup_from_url
182
+ return unless (value = url)
183
+
184
+ [value.to_s, id].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
185
+ end
186
+
187
+ def dedup_from_id
188
+ return if id.to_s.empty?
189
+
190
+ id
191
+ end
192
+
193
+ def dedup_from_guid
194
+ value = guid
195
+ return if value.to_s.empty?
196
+
197
+ [value, title, description].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
198
+ end
199
+
200
+ def fetch_guid
201
+ guid = @to_h[:guid].map { |s| s.to_s.strip }.reject(&:empty?).join if @to_h[:guid].is_a?(Array)
202
+
203
+ guid || [url, id].join('#!/')
204
+ end
205
+
206
+ def blank_string_to_nil(value)
207
+ return if value.is_a?(String) && value.strip.empty?
208
+
209
+ value
210
+ end
211
+ end
212
+ # rubocop:enable Metrics/ClassLength
213
+ end
@@ -0,0 +1,80 @@
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
@@ -0,0 +1,86 @@
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