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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb40d85981c1bc11576b0c9d7a3eae3948bd49239ad8b9a65bb6a4ea103b54f9
4
- data.tar.gz: 6c4636413e1e995e98adec8d8cc742a5833208c2197a024abe90f4b81fce1774
3
+ metadata.gz: f53482ae1084593193664ab8301bd451f53f68b4afabc8f0a336d9ae6dcd357c
4
+ data.tar.gz: 65ef2c8261a216040484134f71de9b8579378d6f7aa20100d0c79cf684efcd65
5
5
  SHA512:
6
- metadata.gz: 9134076ad05417b8b7176da26033e4d66137f862781378b44080d4ea4fe67e8932156c8eb7d22b58ad97f0d7f5e32102c96ef84741d681c2c7571b3add09afb0
7
- data.tar.gz: 61958c85cb791adc78d6d18debcdbfb7a90948332696dac0a4ab4aee39f6b098c3366f948d8905d3d36e2052e03e75037006bd2e6cfa3420b9a11e46e3ebce18
6
+ metadata.gz: 18c6b5dfb6f23753b1c4885339b16505800b91235e65f397d7195d2301b7c32974c12be0087c35c2e63a5bd08185518ea49c6bd252cc68f22521814c6f6e74f8
7
+ data.tar.gz: 458fff5c3579ea95190e458a9cf5814418467fdfc37cedc1244faf4d7c7eb6582640b9d8465329f68eac3624c33c1691514da25c1ce1a7cb6ddb2c7999c38638
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`, `browserless`)
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 `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,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'sanitize'
5
+ require 'nokogiri'
6
+
7
+ module Html2rss
8
+ ##
9
+ # Article is a simple data object representing an article extracted from a page.
10
+ # It is enumerable and responds to all keys specified in PROVIDED_KEYS.
11
+ # rubocop:disable Metrics/ClassLength
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 article description/content
30
+ # @option options [String, Html2rss::Url] :url canonical article URL
31
+ # @option options [String, Html2rss::Url] :image image URL for fallback enclosure rendering
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({}) { |(k, v), h| h[k] = v.freeze if v }.freeze
40
+
41
+ @description = @url = @image = @guid = @enclosures = @enclosure = @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
+ # @return [String] rendered article description
69
+ def description
70
+ return @description unless @description == NOT_SET
71
+
72
+ @description = Html::Rendering::DescriptionBuilder.new(
73
+ base: @to_h[:description],
74
+ title:,
75
+ url:,
76
+ enclosures:,
77
+ image:
78
+ ).call
79
+ end
80
+
81
+ # @return [Url, nil]
82
+ def url
83
+ return @url unless @url == NOT_SET
84
+
85
+ @url = Url.sanitize(@to_h[:url])
86
+ end
87
+
88
+ # @return [Url, nil]
89
+ def image
90
+ return @image unless @image == NOT_SET
91
+
92
+ @image = Url.sanitize(@to_h[:image])
93
+ end
94
+
95
+ # @return [String, nil]
96
+ def author = blank_string_to_nil(@to_h[:author])
97
+
98
+ # Generates a unique identifier based on the URL and ID using CRC32.
99
+ # @return [String]
100
+ def guid
101
+ return @guid unless @guid == NOT_SET
102
+
103
+ @guid = Zlib.crc32(fetch_guid).to_s(36).encode('utf-8')
104
+ end
105
+
106
+ ##
107
+ # Returns a deterministic fingerprint used to detect duplicate articles.
108
+ #
109
+ # @return [String, Integer]
110
+ def deduplication_fingerprint
111
+ dedup_from_url || dedup_from_id || dedup_from_guid || hash
112
+ end
113
+
114
+ # @return [Array<Html2rss::Article::Enclosure>] normalized enclosure objects
115
+ def enclosures
116
+ return @enclosures unless @enclosures == NOT_SET
117
+
118
+ @enclosures = Array(@to_h[:enclosures])
119
+ .map { |enclosure| Enclosure.new(**enclosure) }
120
+ end
121
+
122
+ # @return [Html2rss::Article::Enclosure, nil]
123
+ def enclosure
124
+ return @enclosure unless @enclosure == NOT_SET
125
+
126
+ @enclosure = case (object = @to_h[:enclosures]&.first)
127
+ when Hash
128
+ Enclosure.new(**object)
129
+ when nil
130
+ Enclosure.new(url: image) if image
131
+ else
132
+ Log.warn "Article: unknown enclosure type: #{object.class}"
133
+ nil
134
+ end
135
+ end
136
+
137
+ # @return [Array<String>] normalized, unique category names
138
+ def categories
139
+ return @categories unless @categories == NOT_SET
140
+
141
+ @categories = @to_h[:categories].dup.to_a.tap do |categories|
142
+ categories.map! { |category| category.to_s.strip }
143
+ categories.reject!(&:empty?)
144
+ categories.uniq!
145
+ end
146
+ end
147
+
148
+ # Parses and returns the published_at time.
149
+ # @return [DateTime, nil]
150
+ def published_at
151
+ return @published_at unless @published_at == NOT_SET
152
+
153
+ string = @to_h[:published_at].to_s.strip
154
+ @published_at = string.empty? ? nil : DateTime.parse(string)
155
+ rescue ArgumentError
156
+ @published_at = nil
157
+ end
158
+
159
+ # @return [Class, nil] scraper class that produced this article
160
+ def scraper
161
+ @to_h[:scraper]
162
+ end
163
+
164
+ # @param other [Object] value compared against this article
165
+ # @return [Integer, nil] comparison result for compatible Article values
166
+ def <=>(other)
167
+ return nil unless other.is_a?(Article)
168
+
169
+ 0 if other.all? { |key, value| value == public_send(key) ? public_send(key) <=> value : false }
170
+ end
171
+
172
+ private
173
+
174
+ def dedup_from_url
175
+ return unless (value = url)
176
+
177
+ [value.to_s, id].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
178
+ end
179
+
180
+ def dedup_from_id
181
+ return if id.to_s.empty?
182
+
183
+ id
184
+ end
185
+
186
+ def dedup_from_guid
187
+ value = guid
188
+ return if value.to_s.empty?
189
+
190
+ [value, title, description].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
191
+ end
192
+
193
+ def fetch_guid
194
+ guid = @to_h[:guid].map { |s| s.to_s.strip }.reject(&:empty?).join if @to_h[:guid].is_a?(Array)
195
+
196
+ guid || [url, id].join('#!/')
197
+ end
198
+
199
+ def blank_string_to_nil(value)
200
+ return if value.is_a?(String) && value.strip.empty?
201
+
202
+ value
203
+ end
204
+ end
205
+ # rubocop:enable Metrics/ClassLength
206
+ 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