html2rss 0.29.1 → 0.31.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -8
  3. data/html2rss.gemspec +1 -3
  4. data/lib/html2rss/article/deduplicator.rb +2 -4
  5. data/lib/html2rss/article.rb +16 -39
  6. data/lib/html2rss/auto_source/README.md +1 -1
  7. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  8. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  10. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  11. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  13. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  14. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  15. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  16. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  17. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  19. data/lib/html2rss/auto_source/scraper.rb +4 -14
  20. data/lib/html2rss/auto_source/segment.rb +7 -4
  21. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  22. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  23. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  24. data/lib/html2rss/auto_source.rb +2 -2
  25. data/lib/html2rss/batch.rb +6 -4
  26. data/lib/html2rss/capture/README.md +7 -5
  27. data/lib/html2rss/capture.rb +68 -36
  28. data/lib/html2rss/channel.rb +3 -3
  29. data/lib/html2rss/cli/probe_view.rb +5 -3
  30. data/lib/html2rss/cli/render.rb +6 -5
  31. data/lib/html2rss/cli/validate.rb +28 -27
  32. data/lib/html2rss/cli.rb +1 -1
  33. data/lib/html2rss/config/issue_mapper.rb +108 -0
  34. data/lib/html2rss/config/request_headers.rb +6 -4
  35. data/lib/html2rss/config/schema.rb +16 -1
  36. data/lib/html2rss/config/selectors_validator.rb +92 -70
  37. data/lib/html2rss/config/validation_issue.rb +60 -0
  38. data/lib/html2rss/config/validation_report.rb +55 -0
  39. data/lib/html2rss/config/validator.rb +10 -6
  40. data/lib/html2rss/config.rb +35 -101
  41. data/lib/html2rss/defaults.rb +42 -30
  42. data/lib/html2rss/doctor/botasaurus.rb +19 -9
  43. data/lib/html2rss/doctor.rb +8 -0
  44. data/lib/html2rss/error.rb +1 -1
  45. data/lib/html2rss/feed_builder/rss.rb +9 -14
  46. data/lib/html2rss/feed_pipeline/README.md +8 -8
  47. data/lib/html2rss/feed_pipeline/auto_fallback.rb +1 -1
  48. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  49. data/lib/html2rss/feed_pipeline/strategy_plan.rb +7 -3
  50. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  51. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  52. data/lib/html2rss/feed_resolution/options.rb +3 -1
  53. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  54. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  55. data/lib/html2rss/html/article_extractor.rb +2 -4
  56. data/lib/html2rss/html/feed_link.rb +3 -1
  57. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  58. data/lib/html2rss/html/navigator.rb +10 -10
  59. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  60. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  61. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  62. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  63. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  64. data/lib/html2rss/mcp/README.md +5 -3
  65. data/lib/html2rss/mcp/config_argument.rb +3 -1
  66. data/lib/html2rss/mcp/contract.rb +15 -25
  67. data/lib/html2rss/mcp/outcome/playbook.rb +7 -7
  68. data/lib/html2rss/mcp/outcome.rb +36 -43
  69. data/lib/html2rss/mcp/server/tools.rb +112 -132
  70. data/lib/html2rss/mcp/server.rb +27 -19
  71. data/lib/html2rss/page_recon/README.md +5 -5
  72. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  73. data/lib/html2rss/page_recon.rb +9 -5
  74. data/lib/html2rss/recon.rb +20 -31
  75. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  76. data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
  77. data/lib/html2rss/request_service/compressed_body.rb +17 -9
  78. data/lib/html2rss/request_service/httpx_strategy.rb +239 -0
  79. data/lib/html2rss/request_service/network_guard.rb +7 -5
  80. data/lib/html2rss/request_service/response.rb +1 -2
  81. data/lib/html2rss/request_service/response_guard.rb +0 -16
  82. data/lib/html2rss/request_service/strategy.rb +22 -11
  83. data/lib/html2rss/request_service.rb +46 -85
  84. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  85. data/lib/html2rss/scoring/engine.rb +8 -8
  86. data/lib/html2rss/scoring/observation.rb +5 -3
  87. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  88. data/lib/html2rss/scrape_target.rb +3 -1
  89. data/lib/html2rss/selector_candidates.rb +234 -0
  90. data/lib/html2rss/selectors/README.md +64 -0
  91. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  92. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  93. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  94. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  95. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  96. data/lib/html2rss/selectors/extractors.rb +33 -20
  97. data/lib/html2rss/selectors/item_env.rb +31 -0
  98. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  99. data/lib/html2rss/selectors/option_spec.rb +140 -0
  100. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  101. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  102. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  103. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  104. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  105. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  106. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  107. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  108. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  109. data/lib/html2rss/selectors/post_processors.rb +6 -5
  110. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  111. data/lib/html2rss/selectors/step_env.rb +24 -0
  112. data/lib/html2rss/selectors.rb +231 -142
  113. data/lib/html2rss/sst/attrs.rb +5 -3
  114. data/lib/html2rss/sst/document.rb +3 -1
  115. data/lib/html2rss/sst/index.rb +3 -1
  116. data/lib/html2rss/sst/node.rb +29 -10
  117. data/lib/html2rss/sst/normalizer.rb +12 -16
  118. data/lib/html2rss/sst/tags.rb +7 -7
  119. data/lib/html2rss/status.rb +13 -8
  120. data/lib/html2rss/surface_category.rb +14 -32
  121. data/lib/html2rss/syndication/parser.rb +2 -2
  122. data/lib/html2rss/test/enhance_audit.rb +1 -3
  123. data/lib/html2rss/test/policy.rb +106 -0
  124. data/lib/html2rss/test.rb +126 -172
  125. data/lib/html2rss/url.rb +25 -4
  126. data/lib/html2rss/version.rb +1 -1
  127. data/lib/html2rss.rb +27 -19
  128. data/lib/tasks/config_schema.rake +2 -2
  129. data/schema/html2rss-config.schema.json +11 -8
  130. metadata +19 -44
  131. data/lib/html2rss/request_service/faraday_strategy.rb +0 -233
  132. data/lib/html2rss/selectors/item_scope.rb +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32c090eccd93210c9631778ff50ec7d32d36c7eccaa711cdd1a0dd5c3ea53616
4
- data.tar.gz: 93e4d2f253a019d7de585c8acaaa8d06143accb4f3a43a4f8d13f9c96d838390
3
+ metadata.gz: e3450f1c6b664e4f9c7377220b65640a9e4bd640782d4f8831538c65a172cec3
4
+ data.tar.gz: 1f5543f86a3389a4f2abb9a76dec44ba41a4ffecadcf8d3d674ff084c459aaec
5
5
  SHA512:
6
- metadata.gz: 93956c4dc3d35e06c81dff939d4ea5184da93834a0fb48f1b3dbd4dea21d615177ad45fa34e4f16abf396a184cc18acbe31c354df6764ba858ec340c78a3b08c
7
- data.tar.gz: b906784ed2ae53b80aa2fc00fe813550befcfac723d97346ffb87cff7f01ca6b0f5db0a6e4a2430dd10485c20f6d4b251a54a1b9e0462998f2ab628c0123a809
6
+ metadata.gz: 6b2d0fc1afbd44a13405eb21ad89ea32dbdf8d3c0dd1dd0f6430f877398a2cb5275781de7d65386fbde71f43db34e35a54c1fb5d13a73a265c8a8a274f496f87
7
+ data.tar.gz: 6402bc203d89b23fc388872b310eb4c6aaa6b9423025a40ec6eaa606ecf5c78a64c09823a103b263344b72acae4fa385e11526f59571b699eaa54ec27379cf66
data/README.md CHANGED
@@ -13,7 +13,7 @@ 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`; or pin concrete strategies)
16
+ - [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto` = `default` → `botasaurus`; or pin concrete strategies)
17
17
  - [Selectors & pagination](https://html2rss.github.io/ruby-gem/reference/selectors#paginated-feeds)
18
18
  - [Web application](https://html2rss.github.io/web-application)
19
19
  - [Feed directory](https://html2rss.github.io/feed-directory)
@@ -26,7 +26,7 @@ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new
26
26
  ## Architecture
27
27
 
28
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 `local_file`)
29
+ 2. **RequestService** — fetches pages (`default` (HTTPX), `botasaurus`, or `local_file`)
30
30
  3. **Selectors** — extracts content via CSS selectors with extractors/post-processors
31
31
  4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns)
32
32
  5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1)
@@ -82,7 +82,7 @@ Historic CLI aliases: `feed` → `apply`, `auto` → `scrape`.
82
82
 
83
83
  Inspect follows redirects and reports the landing URL in `final_url`. CLI text shows a `Final:` line **only when** the landing URL differs from what you typed — that line means the redirect succeeded, not that inspect stopped early.
84
84
 
85
- Cross-host redirects (e.g. `https://apex.example/` → `https://www.example/`) set `Host` per hop via Faraday/Net::HTTP; html2rss does not pin the entry hostname. When `final_url` differs and status is 4xx, retry on `final_url` or pass the site's canonical hostname. Details: [`lib/html2rss/page_recon/README.md`](lib/html2rss/page_recon/README.md).
85
+ Cross-host redirects (e.g. `https://apex.example/` → `https://www.example/`) set `Host` per hop via HTTPX; html2rss does not pin the entry hostname. When `final_url` differs and status is 4xx, retry on `final_url` or pass the site's canonical hostname. Details: [`lib/html2rss/page_recon/README.md`](lib/html2rss/page_recon/README.md).
86
86
 
87
87
  ## Capture API
88
88
 
@@ -112,7 +112,7 @@ stdio uses stdout for JSON-RPC, so the daemon logs to **stderr**. It defaults to
112
112
 
113
113
  HTTP transport needs `rack`, `rackup`, and `webrick` (declared gem dependencies). It listens on `127.0.0.1` only; do not expose it on a public interface without your own auth and Host/Origin controls.
114
114
 
115
- **Strategy note:** MCP `scrape` / `capture` with `strategy: "auto"` run Faraday → Botasaurus AutoFallback. `inspect` uses Faraday when `auto` (cheap diagnostic); pin `botasaurus` when you need browser rendering for inspect.
115
+ **Strategy note:** MCP `scrape` / `capture` with `strategy: "auto"` run default (HTTPX) → Botasaurus AutoFallback. `inspect` uses default (HTTPX) when `auto` (cheap diagnostic); pin `botasaurus` when you need browser rendering for inspect.
116
116
 
117
117
  **Tool-call budget:** `scrape` is 1 call (auto already hops). Durable config is `capture` → `test` → `apply` (or `validate` → `test` → `apply` when you already have YAML). Call `inspect` only when scrape/capture is weak or you need recon (final URL, status, https→http, native RSS/Atom).
118
118
 
@@ -157,7 +157,7 @@ Module guide: [`lib/html2rss/mcp/README.md`](lib/html2rss/mcp/README.md).
157
157
  | ----------------------- | --------------------------------------------------------------- |
158
158
  | `html2rss://schema` | Full JSON Schema for feed configurations |
159
159
  | `html2rss://extractors` | Registered extractor **names** (options live in schema `$defs`) |
160
- | `html2rss://strategies` | Published MCP strategies (`auto`, `faraday`, `botasaurus`) |
160
+ | `html2rss://strategies` | Published MCP strategies (`auto`, `default`, `httpx`, `botasaurus`) |
161
161
  | `html2rss://runtime` | `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, `botasaurus_configured` (never the scraper URL) |
162
162
 
163
163
  ### Prompts
@@ -183,11 +183,11 @@ Set `BOTASAURUS_SCRAPER_URL` to `http://127.0.0.1:4010` and use strategy `botasa
183
183
 
184
184
  | Strategy | Description |
185
185
  | ------------ | --------------------------------------------------------------------------- |
186
- | `auto` | Tries `faraday`, falls back to `botasaurus` (default in gem/CLI/MCP scrape) |
187
- | `faraday` | Plain HTTP requests via Faraday |
186
+ | `auto` | Tries `default`, falls back to `botasaurus` (default in gem/CLI/MCP scrape) |
187
+ | `default` | Plain HTTP requests via HTTPX (alias: `httpx`; legacy: `faraday`) |
188
188
  | `botasaurus` | Puppeteer-backed scraping for JavaScript pages |
189
189
 
190
- `inspect` keeps Faraday when `auto` for cheap diagnostics. Elsewhere, strategy can be set via CLI (`--strategy`), gem API keyword argument, or feed config `request.strategy`. See the [request strategies docs](https://html2rss.github.io/ruby-gem/reference/strategy) for more details.
190
+ `inspect` keeps `default` when `auto` for cheap diagnostics. Elsewhere, strategy can be set via CLI (`--strategy`), gem API keyword argument, or feed config `strategy`. See the [request strategies docs](https://html2rss.github.io/ruby-gem/reference/strategy) for more details.
191
191
 
192
192
  ## License
193
193
 
data/html2rss.gemspec CHANGED
@@ -35,9 +35,7 @@ Gem::Specification.new do |spec|
35
35
  spec.add_dependency 'addressable', '~> 2.7'
36
36
  spec.add_dependency 'brotli'
37
37
  spec.add_dependency 'dry-validation'
38
- spec.add_dependency 'faraday', '> 2.0.1', '< 3.0'
39
- spec.add_dependency 'faraday-follow_redirects'
40
- spec.add_dependency 'faraday-gzip', '~> 3'
38
+ spec.add_dependency 'httpx', '~> 1.8'
41
39
  spec.add_dependency 'kramdown'
42
40
  spec.add_dependency 'mcp', '~> 1.2'
43
41
  spec.add_dependency 'mime-types', '> 3.0'
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set' # rubocop:disable Lint/RedundantRequireStatement
4
-
5
3
  module Html2rss
6
4
  class Article
7
5
  ##
@@ -40,9 +38,9 @@ module Html2rss
40
38
  attr_reader :articles
41
39
 
42
40
  def deduplication_fingerprint_for(article)
43
- return unless article.respond_to?(:deduplication_fingerprint)
44
-
45
41
  article.deduplication_fingerprint
42
+ rescue NoMethodError
43
+ nil
46
44
  end
47
45
  end
48
46
  end
@@ -5,14 +5,9 @@ require 'zlib'
5
5
  module Html2rss
6
6
  ##
7
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
8
  #
10
9
  # Description and enclosure wire presentation live in {FeedBuilder::ItemPresentation}.
11
- # rubocop:disable-next Metrics/ClassLength -- value object retains Marshal + defensive freeze helpers
12
10
  class Article
13
- include Enumerable
14
- include Comparable
15
-
16
11
  # Allowed article attributes accepted by the value object constructor.
17
12
  PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
18
13
  # Separator used to build deterministic deduplication fingerprints.
@@ -23,26 +18,24 @@ module Html2rss
23
18
  # when attributes are lazily/conditionally accessed in different sequences.
24
19
  NOT_SET = Object.new.freeze
25
20
 
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
21
+ # @param id [String, nil] stable article identifier
22
+ # @param title [String, nil] article title
23
+ # @param description [String, nil] raw extracted description/content (not feed-rendered HTML)
24
+ # @param url [String, Html2rss::Url, nil] canonical article URL
25
+ # @param image [String, Html2rss::Url, nil] image URL for description / JSON Feed +image+
26
+ # @param author [String, nil] author name
27
+ # @param guid [String, Array, nil] explicit GUID override
28
+ # @param published_at [String, Time, DateTime, nil] publication timestamp
29
+ # @param enclosures [Array<Hash{Symbol => Object}>, nil] enclosure attribute hashes
30
+ # @param categories [Array<String>, nil] category labels
31
+ # @param scraper [Class, nil] scraper class that produced the article
32
+ # rubocop:disable-next Metrics/ParameterLists -- eleven article keys stay explicit
33
+ def initialize(id: nil, title: nil, description: nil, url: nil, image: nil, author: nil, guid: nil,
34
+ published_at: nil, enclosures: nil, categories: nil, scraper: nil)
35
+ @to_h = { id:, title:, description:, url:, image:, author:, guid:, published_at:, enclosures:, categories:,
36
+ scraper: }.compact.transform_values { freeze_option(_1) }.freeze
40
37
 
41
38
  @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
39
  end
47
40
 
48
41
  # Checks if the article is valid based on the presence of URL, ID, and either title or description.
@@ -51,14 +44,6 @@ module Html2rss
51
44
  !url.to_s.empty? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
52
45
  end
53
46
 
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
47
  # @return [String, nil] stable article identifier
63
48
  def id = blank_string_to_nil(@to_h[:id])
64
49
 
@@ -139,14 +124,6 @@ module Html2rss
139
124
  @to_h[:scraper]
140
125
  end
141
126
 
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
127
  private
151
128
 
152
129
  ##
@@ -28,7 +28,7 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
28
28
 
29
29
  6. **Entry URL resolution** (pipeline, not this class) — when AutoFallback sees a weak homepage extract, `FeedResolution` may rewrite the scrape URL to a listing/feed before escalating strategies. See {Html2rss::FeedPipeline} and {Html2rss::FeedResolution}.
30
30
 
31
- Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (repeated tag paths), `:cluster` (class/structure grids for anchorless cards). Scoring ranks and demotes; `LinkDestination::NoisePolicy` owns content-anchor eligibility. Cleanup owns feed-item admission.
31
+ Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (cards grouped by parent; `main` is a filter), `:cluster` (class/structure grids for anchorless cards). Scoring ranks and demotes; `LinkDestination::NoisePolicy` owns content-anchor eligibility. Cleanup owns feed-item admission.
32
32
 
33
33
  ## Nokogiri vs SST boundaries
34
34
 
@@ -17,7 +17,7 @@ module Html2rss
17
17
  MIN_WORDS = 3
18
18
 
19
19
  # Allowed URL schemes for article filtering.
20
- VALID_SCHEMES = %w[http https].to_set.freeze
20
+ VALID_SCHEMES = Set['http', 'https'].freeze
21
21
 
22
22
  # Photo-credit agencies (single list → junk title regexes).
23
23
  CREDIT_AGENCIES = [
@@ -97,13 +97,14 @@ module Html2rss
97
97
  JUNK_TITLE_RULES.find { |_, pattern| pattern.match?(normalized) }&.first
98
98
  end
99
99
 
100
- # @param items [Array] RSS or article-like objects with title and link/url
100
+ # @param items [Array<Html2rss::Article, RSS::Rss::Channel::Item>]
101
101
  # @return [AuditResult]
102
102
  def audit_feed_items(items)
103
- metrics = initial_audit_metrics(items.size)
103
+ articles = items.map { |item| feed_item_as_article(item) }
104
+ metrics = initial_audit_metrics(articles.size)
104
105
  violations = Hash.new(0)
105
- warnings = audit_url_diversity(items, metrics, violations)
106
- audit_item_titles(items, metrics, violations)
106
+ warnings = audit_url_diversity(articles, metrics, violations)
107
+ audit_item_titles(articles, metrics, violations)
107
108
  warnings = finalize_audit_warnings(warnings, metrics)
108
109
  log_audit(metrics, warnings, violations)
109
110
  AuditResult.new(warnings:, metrics: metrics.freeze, violations: violations.freeze)
@@ -256,8 +257,16 @@ module Html2rss
256
257
  url&.without_fragment&.to_s
257
258
  end
258
259
 
260
+ def feed_item_as_article(item)
261
+ case item
262
+ when Article then item
263
+ else
264
+ Article.new(title: item.title, url: item.link)
265
+ end
266
+ end
267
+
259
268
  def item_url(item)
260
- raw = item.respond_to?(:link) ? item.link : item.url
269
+ raw = item.url
261
270
  raw.nil? || raw.to_s.empty? ? nil : Html2rss::Url.from_absolute(raw.to_s)
262
271
  rescue ArgumentError
263
272
  nil
@@ -22,6 +22,11 @@ module Html2rss
22
22
  # @return [Symbol]
23
23
  def self.options_key = :html
24
24
 
25
+ ##
26
+ # @param _opts [Hash] unused options
27
+ # @return [Integer]
28
+ def self.request_slots(_opts = {}) = 0
29
+
25
30
  ##
26
31
  # @param parsed_body [Nokogiri::HTML::Document]
27
32
  # @return [Boolean]
@@ -14,6 +14,11 @@ module Html2rss
14
14
  # @return [Symbol] scraper config key
15
15
  def self.options_key = :json_state
16
16
 
17
+ ##
18
+ # @param _opts [Hash] unused options
19
+ # @return [Integer]
20
+ def self.request_slots(_opts = {}) = 0
21
+
17
22
  class << self
18
23
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
19
24
  def articles?(parsed_body)
@@ -8,7 +8,7 @@ module Html2rss
8
8
  module Scraper
9
9
  ##
10
10
  # Scrapes OpenGraph meta tags and oEmbed JSON descriptors from HTML pages.
11
- class MetaOembed
11
+ class MetaOembed # rubocop:disable Metrics/ClassLength -- request_slots + extractable? stay on the scraper
12
12
  include Enumerable
13
13
 
14
14
  # Selector for OpenGraph meta tags.
@@ -30,6 +30,10 @@ module Html2rss
30
30
  # @return [Symbol] scraper config key
31
31
  def self.options_key = :meta_oembed
32
32
 
33
+ # @param _opts [Hash] unused options
34
+ # @return [Integer]
35
+ def self.request_slots(_opts = {}) = 0
36
+
33
37
  class << self
34
38
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
35
39
  # @return [Boolean] whether OpenGraph tags or oEmbed link exist
@@ -57,6 +61,9 @@ module Html2rss
57
61
  @request_session = request_session
58
62
  end
59
63
 
64
+ # @return [Boolean]
65
+ def extractable? = self.class.articles?(parsed_body)
66
+
60
67
  ##
61
68
  # Yields normalized article hash extracted from OpenGraph meta tags and optional oEmbed data.
62
69
  #
@@ -17,6 +17,11 @@ module Html2rss
17
17
  # @return [Symbol] scraper config key
18
18
  def self.options_key = :microdata
19
19
 
20
+ ##
21
+ # @param _opts [Hash] unused options
22
+ # @return [Integer]
23
+ def self.request_slots(_opts = {}) = 0
24
+
20
25
  class << self
21
26
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
22
27
  def articles?(parsed_body)
@@ -81,6 +86,9 @@ module Html2rss
81
86
  @url = url
82
87
  end
83
88
 
89
+ # @return [Boolean]
90
+ def extractable? = self.class.articles?(parsed_body)
91
+
84
92
  ##
85
93
  # Iterates over normalized article hashes extracted from supported Microdata roots.
86
94
  #
@@ -18,6 +18,11 @@ module Html2rss
18
18
  # @return [Symbol] scraper config key
19
19
  def self.options_key = :microformats2
20
20
 
21
+ ##
22
+ # @param _opts [Hash] unused options
23
+ # @return [Integer]
24
+ def self.request_slots(_opts = {}) = 0
25
+
21
26
  class << self
22
27
  # @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
23
28
  # @return [Boolean] whether Microformats2 h-entry elements exist
@@ -38,6 +43,9 @@ module Html2rss
38
43
  @url = Html2rss::Url.from_absolute(url)
39
44
  end
40
45
 
46
+ # @return [Boolean]
47
+ def extractable? = self.class.articles?(parsed_body)
48
+
41
49
  ##
42
50
  # Yields normalized article hashes extracted from h-entry elements.
43
51
  #
@@ -51,6 +51,9 @@ module Html2rss
51
51
  @request_session = request_session
52
52
  end
53
53
 
54
+ # @return [Boolean]
55
+ def extractable? = self.class.articles?(parsed_body)
56
+
54
57
  ##
55
58
  # @yieldparam article [Hash{Symbol => Object}]
56
59
  # @return [Enumerator, void]
@@ -19,9 +19,6 @@ module Html2rss
19
19
  ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
20
20
  ].to_set.freeze
21
21
 
22
- # Attributes exposed by `#call` in generated article hashes.
23
- DEFAULT_ATTRIBUTES = %i[id title description author url image published_at categories].freeze
24
-
25
22
  # @param schema_object [Hash{Symbol => Object}] parsed schema.org object
26
23
  # @param url [String, Html2rss::Url, nil] base URL used for relative normalization
27
24
  def initialize(schema_object, url:)
@@ -29,8 +26,10 @@ module Html2rss
29
26
  @base_url = normalized_base_url(url)
30
27
  end
31
28
 
32
- # @return [Hash] the scraped article hash with DEFAULT_ATTRIBUTES
33
- def call = DEFAULT_ATTRIBUTES.to_h { [_1, public_send(_1)] }
29
+ # @return [Hash{Symbol => Object}] scraped article attributes
30
+ def call
31
+ { id:, title:, description:, author:, url:, image:, published_at:, categories: }
32
+ end
34
33
 
35
34
  # @return [String, nil] stable schema object identifier
36
35
  def id
@@ -46,6 +46,11 @@ module Html2rss
46
46
  # @return [Symbol] scraper config key
47
47
  def self.options_key = :schema
48
48
 
49
+ ##
50
+ # @param _opts [Hash] unused options
51
+ # @return [Integer]
52
+ def self.request_slots(_opts = {}) = 0
53
+
49
54
  class << self
50
55
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
51
56
  # @return [Boolean] whether the page includes supported schema types
@@ -166,6 +171,9 @@ module Html2rss
166
171
  @opts = opts
167
172
  end
168
173
 
174
+ # @return [Boolean]
175
+ def extractable? = self.class.articles?(parsed_body)
176
+
169
177
  ##
170
178
  # @yield [Hash] Each scraped article_hash
171
179
  # @return [Array<Hash>] the scraped article_hashes
@@ -15,6 +15,11 @@ module Html2rss
15
15
  # @return [Symbol] config key used to enable or configure this scraper
16
16
  def self.options_key = :semantic_html
17
17
 
18
+ ##
19
+ # @param _opts [Hash] unused options
20
+ # @return [Integer]
21
+ def self.request_slots(_opts = {}) = 0
22
+
18
23
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
19
24
  # @return [Boolean] true when at least one semantic container has an eligible anchor
20
25
  def self.articles?(parsed_body)
@@ -54,6 +54,9 @@ module Html2rss
54
54
  @max_age_days = opts.fetch(:max_age_days, Scraper::Sitemap::Parser::DEFAULT_MAX_AGE_DAYS)
55
55
  end
56
56
 
57
+ # @return [Boolean]
58
+ def extractable? = self.class.articles?(parsed_body)
59
+
57
60
  ##
58
61
  # Yields article hashes from the sitemap.
59
62
  #
@@ -56,6 +56,9 @@ module Html2rss
56
56
  @page_scope = PageScope.from(parsed_body:, url: @url)
57
57
  end
58
58
 
59
+ # @return [Boolean]
60
+ def extractable? = self.class.articles?(parsed_body)
61
+
59
62
  ##
60
63
  # Yields article hashes from the WordPress posts API.
61
64
  #
@@ -36,11 +36,11 @@ module Html2rss
36
36
  SCRAPERS = SCRAPER_TIERS.flatten.freeze
37
37
 
38
38
  # Heuristic scrapers that share one memoized SST::Document per page.
39
- HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
39
+ HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
40
40
  # Scrapers that accept a shared follow-up +request_session+.
41
- REQUEST_SESSION_SCRAPERS = [NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
41
+ REQUEST_SESSION_SCRAPERS = Set[NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
42
42
  # Scrapers that consume browser-captured XHR/fetch JSON bodies.
43
- CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
43
+ CAPTURED_RESPONSE_SCRAPERS = Set[XhrArticles].freeze
44
44
 
45
45
  ##
46
46
  # Error raised when no suitable scraper is found.
@@ -110,7 +110,7 @@ module Html2rss
110
110
  # @param tier [Array<Class>]
111
111
  # @return [Boolean]
112
112
  def self.heuristic_tier?(tier)
113
- tier.intersect?(HEURISTIC_SCRAPERS)
113
+ HEURISTIC_SCRAPERS.intersect?(tier)
114
114
  end
115
115
 
116
116
  ##
@@ -163,16 +163,6 @@ module Html2rss
163
163
  scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
164
164
  end
165
165
 
166
- ##
167
- # @param instance [Object]
168
- # @param parsed_body [Nokogiri::HTML::Document]
169
- # @return [Boolean]
170
- def self.extractable_instance?(instance, parsed_body)
171
- return instance.extractable? if instance.respond_to?(:extractable?)
172
-
173
- instance.class.articles?(parsed_body)
174
- end
175
-
176
166
  ##
177
167
  # @param parsed_body [Nokogiri::HTML::Document]
178
168
  # @param body [String, nil] raw response body (preferred for blocked-surface checks)
@@ -2,9 +2,14 @@
2
2
 
3
3
  module Html2rss
4
4
  class AutoSource
5
+ Segment = Data.define(:root_node, :primary_link, :strategy, :position)
6
+
5
7
  ##
6
8
  # One candidate content block discovered by {Segmenter}.
7
- Segment = Data.define(:root_node, :primary_link, :strategy, :position) do
9
+ class Segment
10
+ # Allowed Segmenter strategies.
11
+ STRATEGIES = Set[:semantic, :list, :cluster].freeze
12
+
8
13
  ##
9
14
  # @param root_node [SST::Node]
10
15
  # @param primary_link [SST::Node, nil]
@@ -17,13 +22,11 @@ module Html2rss
17
22
  unless primary_link.nil? || primary_link.is_a?(SST::Node)
18
23
  raise ArgumentError, 'primary_link must be SST::Node or nil'
19
24
  end
20
- raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
25
+ raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless STRATEGIES.include?(strategy)
21
26
  raise ArgumentError, 'position must be Integer' unless position.is_a?(Integer)
22
27
 
23
28
  new(root_node:, primary_link:, strategy:, position:)
24
29
  end
25
30
  end
26
- # Allowed Segmenter strategies.
27
- Segment::STRATEGIES = %i[semantic list cluster].to_set.freeze
28
31
  end
29
32
  end
@@ -13,15 +13,18 @@ module Html2rss
13
13
  # @param segmenter [Segmenter]
14
14
  # @return [Array<Segment>]
15
15
  def call(segmenter)
16
+ primary = PrimaryLink.new(segmenter)
16
17
  nodes = best_group_nodes(segmenter)
17
- nodes.each_with_index.map do |node, position|
18
- Segment.build(root_node: node, primary_link: nil, strategy: :cluster, position:)
18
+ nodes.each_with_index.filter_map do |node, position|
19
+ link = primary.select(node)
20
+ next unless link || segmenter.permit_unanchored
21
+
22
+ Segment.build(root_node: node, primary_link: link, strategy: :cluster, position:)
19
23
  end
20
24
  end
21
25
 
22
26
  def best_group_nodes(segmenter)
23
- class_groups = collect_class_groups(segmenter)
24
- groups = class_groups.empty? ? collect_structure_groups(segmenter) : class_groups
27
+ groups = collect_groups(segmenter)
25
28
  return [] if groups.empty?
26
29
 
27
30
  resolver = OverlapResolver.new(index: segmenter.index)
@@ -33,33 +36,29 @@ module Html2rss
33
36
  module_function :best_group_nodes
34
37
  private_class_method :best_group_nodes
35
38
 
36
- def collect_class_groups(segmenter) # rubocop:disable Metrics/AbcSize
37
- groups = Hash.new { |h, k| h[k] = [] }
39
+ def collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
40
+ class_groups = Hash.new { |hash, key| hash[key] = [] }
41
+ structure_groups = Hash.new { |hash, key| hash[key] = [] }
42
+ min = segmenter.minimum_selector_frequency
43
+
38
44
  segmenter.index.each_node do |node|
39
45
  next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
40
46
  next if segmenter.index.ignored_chrome?(node)
41
47
 
42
48
  cls = normalize_class(node.attrs.class_names)
43
- groups[cls] << node unless cls.empty?
44
- end
45
- groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
46
- end
47
- module_function :collect_class_groups
48
- private_class_method :collect_class_groups
49
-
50
- def collect_structure_groups(segmenter) # rubocop:disable Metrics/AbcSize
51
- groups = Hash.new { |h, k| h[k] = [] }
52
- segmenter.index.each_node do |node|
53
- next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
54
- next if segmenter.index.ignored_chrome?(node)
49
+ class_groups[cls] << node unless cls.empty?
55
50
 
56
51
  sig = structure_signature(node)
57
- groups[sig] << node unless sig.empty?
52
+ structure_groups[sig] << node unless sig.empty?
58
53
  end
59
- groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
54
+
55
+ frequent_class = class_groups.select { |_, nodes| nodes.size >= min }
56
+ return frequent_class unless frequent_class.empty?
57
+
58
+ structure_groups.select { |_, nodes| nodes.size >= min }
60
59
  end
61
- module_function :collect_structure_groups
62
- private_class_method :collect_structure_groups
60
+ module_function :collect_groups
61
+ private_class_method :collect_groups
63
62
 
64
63
  def normalize_class(class_names)
65
64
  return '' if class_names.empty?
@@ -73,7 +72,7 @@ module Html2rss
73
72
  kids = node.children
74
73
  return '' if kids.empty?
75
74
 
76
- kids.map { |c| c.name.to_s }.join('>')
75
+ kids.map { |child| child.name.to_s }.join('>')
77
76
  end
78
77
  module_function :structure_signature
79
78
  private_class_method :structure_signature