html2rss 0.30.0 → 0.32.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -3
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +34 -7
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_document.rb +135 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +74 -11
  10. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +4 -8
  11. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  12. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  13. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  14. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  16. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  17. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  18. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  19. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  20. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  21. data/lib/html2rss/auto_source/scraper.rb +12 -17
  22. data/lib/html2rss/auto_source/segment.rb +7 -4
  23. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  24. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  25. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  26. data/lib/html2rss/auto_source.rb +58 -3
  27. data/lib/html2rss/batch.rb +3 -1
  28. data/lib/html2rss/capture/README.md +6 -4
  29. data/lib/html2rss/capture.rb +67 -35
  30. data/lib/html2rss/channel.rb +3 -3
  31. data/lib/html2rss/cli/probe_view.rb +5 -3
  32. data/lib/html2rss/cli/render.rb +6 -5
  33. data/lib/html2rss/cli/validate.rb +28 -27
  34. data/lib/html2rss/cli.rb +6 -2
  35. data/lib/html2rss/config/auto_source_contract.rb +3 -0
  36. data/lib/html2rss/config/issue_mapper.rb +108 -0
  37. data/lib/html2rss/config/request_headers.rb +2 -3
  38. data/lib/html2rss/config/schema.rb +16 -1
  39. data/lib/html2rss/config/selectors_validator.rb +92 -70
  40. data/lib/html2rss/config/validation_issue.rb +60 -0
  41. data/lib/html2rss/config/validation_report.rb +55 -0
  42. data/lib/html2rss/config/validator.rb +10 -6
  43. data/lib/html2rss/config.rb +44 -114
  44. data/lib/html2rss/defaults.rb +42 -30
  45. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  46. data/lib/html2rss/doctor.rb +8 -0
  47. data/lib/html2rss/error.rb +1 -1
  48. data/lib/html2rss/feed_builder/rss.rb +9 -14
  49. data/lib/html2rss/feed_pipeline/auto_fallback.rb +11 -2
  50. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  51. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  52. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  53. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  54. data/lib/html2rss/feed_resolution/options.rb +3 -1
  55. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  56. data/lib/html2rss/feed_resolution.rb +30 -2
  57. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  58. data/lib/html2rss/html/article_extractor.rb +2 -4
  59. data/lib/html2rss/html/feed_link.rb +3 -1
  60. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  61. data/lib/html2rss/html/navigator.rb +10 -10
  62. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  63. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  64. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  65. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  66. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  67. data/lib/html2rss/mcp/README.md +2 -0
  68. data/lib/html2rss/mcp/config_argument.rb +3 -1
  69. data/lib/html2rss/mcp/contract.rb +7 -21
  70. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  71. data/lib/html2rss/mcp/outcome.rb +36 -43
  72. data/lib/html2rss/mcp/server/tools.rb +111 -131
  73. data/lib/html2rss/mcp/server.rb +26 -18
  74. data/lib/html2rss/page_recon/README.md +5 -5
  75. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  76. data/lib/html2rss/page_recon.rb +9 -5
  77. data/lib/html2rss/recon.rb +19 -30
  78. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  79. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  80. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  81. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  82. data/lib/html2rss/request_service/network_guard.rb +7 -5
  83. data/lib/html2rss/request_service/response.rb +6 -4
  84. data/lib/html2rss/request_service/strategy.rb +3 -8
  85. data/lib/html2rss/request_service.rb +11 -2
  86. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  87. data/lib/html2rss/scoring/engine.rb +8 -8
  88. data/lib/html2rss/scoring/observation.rb +5 -3
  89. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  90. data/lib/html2rss/scrape_target.rb +3 -1
  91. data/lib/html2rss/selector_candidates.rb +234 -0
  92. data/lib/html2rss/selectors/README.md +64 -0
  93. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  94. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  95. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  96. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  97. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  98. data/lib/html2rss/selectors/extractors.rb +33 -20
  99. data/lib/html2rss/selectors/item_env.rb +31 -0
  100. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  101. data/lib/html2rss/selectors/option_spec.rb +140 -0
  102. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  103. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  104. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  105. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  106. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  107. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  108. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  109. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  110. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  111. data/lib/html2rss/selectors/post_processors.rb +6 -5
  112. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  113. data/lib/html2rss/selectors/step_env.rb +24 -0
  114. data/lib/html2rss/selectors.rb +231 -142
  115. data/lib/html2rss/sst/attrs.rb +5 -3
  116. data/lib/html2rss/sst/document.rb +3 -1
  117. data/lib/html2rss/sst/index.rb +3 -1
  118. data/lib/html2rss/sst/node.rb +29 -10
  119. data/lib/html2rss/sst/normalizer.rb +12 -16
  120. data/lib/html2rss/sst/tags.rb +7 -7
  121. data/lib/html2rss/status.rb +13 -8
  122. data/lib/html2rss/surface_category.rb +14 -32
  123. data/lib/html2rss/syndication/parser.rb +2 -2
  124. data/lib/html2rss/test/enhance_audit.rb +1 -3
  125. data/lib/html2rss/test/policy.rb +106 -0
  126. data/lib/html2rss/test.rb +126 -173
  127. data/lib/html2rss/url.rb +25 -4
  128. data/lib/html2rss/version.rb +1 -1
  129. data/lib/html2rss.rb +32 -19
  130. data/lib/tasks/config_schema.rake +2 -2
  131. data/schema/html2rss-config.schema.json +26 -8
  132. metadata +16 -6
  133. data/lib/html2rss/selectors/item_scope.rb +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d839914f2d4d26e86998293a431a76168033523857ec8bc65f3f447441cb07e
4
- data.tar.gz: fd0008a8d7a527b0a2128df3a5603a06c154d61b522f6a14decded9889615b78
3
+ metadata.gz: 4841672d87f3828928fa35646176d878469ade0afce9f9e6a119ccd1cf3251ae
4
+ data.tar.gz: 618382810f59ff95713064d91a2ccb6df4c0dd2cf6287f31127cddd5848e8dbd
5
5
  SHA512:
6
- metadata.gz: 418b9fd5973f19985d7e80abb7734530689bff0753ec948813907f35fd1d2a139ec9fd26353c8c4c87ffc456f446be4a9cdfee9c406022fdfb6c6f0077f886fa
7
- data.tar.gz: 1a470470f9bdaea585fdccd56e231c3308624e745a98b830bfd1f0861dad4ce22a98c4a2216306cfd893d6d271efc1d68c6efe15ea6e015d43776086db3094ad
6
+ metadata.gz: 7abf8800a5943fca86ed9fa19058a1957ed101d52db70fd75042bda91abc2a9070d3bb5e6b0129e18103ebf407de2ea97a5e1a826cec3b5a7c0f21c12d0dfd61
7
+ data.tar.gz: 7831958f9d9b2af2db632b341cd8c902b45ed78feaf12dc21ee4f9c80c72237d56acba58e1b2aa7b0c842fc6dd3626beb48fd0e08ef932730920e33c23eb2f4b
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![html2rss logo](https://github.com/html2rss/html2rss/raw/master/support/logo.png)
1
+ ![html2rss logo](https://html2rss.github.io/assets/logo.png)
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/html2rss.svg)](http://rubygems.org/gems/html2rss) [![Yard Docs](http://img.shields.io/badge/yard-docs-blue.svg)](https://www.rubydoc.info/gems/html2rss) ![Retro Badge: valid RSS](https://validator.w3.org/feed/images/valid-rss-rogers.png) [![CI](https://github.com/html2rss/html2rss/workflows/lint%20and%20test/badge.svg)](https://github.com/html2rss/html2rss/actions)
4
4
 
@@ -62,6 +62,9 @@ html2rss capture https://example.com/news | html2rss test -
62
62
  # One-shot articles now
63
63
  html2rss scrape https://example.com/news
64
64
  html2rss scrape https://example.com/news --format jsonfeed --explain
65
+ # Off-domain item URLs are dropped by default. --keep-different-domain opts in
66
+ # and lets the remote source control published links to unrelated domains.
67
+ html2rss scrape https://example.com/app-list.json --keep-different-domain
65
68
 
66
69
  # Durable config workflow
67
70
  html2rss capture https://example.com/news --write feed.yml
@@ -82,7 +85,7 @@ Historic CLI aliases: `feed` → `apply`, `auto` → `scrape`.
82
85
 
83
86
  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
87
 
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).
88
+ 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
89
 
87
90
  ## Capture API
88
91
 
@@ -112,7 +115,7 @@ stdio uses stdout for JSON-RPC, so the daemon logs to **stderr**. It defaults to
112
115
 
113
116
  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
117
 
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.
118
+ **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
119
 
117
120
  **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
121
 
@@ -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
  ##
@@ -10,8 +10,9 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
10
10
 
11
11
  ## Live flow
12
12
 
13
- 1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
14
- 2. **Scraper tiers** — Enabled scrapers that claim the page (shallow `articles?` or instance `extractable?`) run in `Scraper::SCRAPER_TIERS` order. Merge within a tier, then stop when enough articles survive Cleanup:
13
+ 1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML, or parsed JSON for JSON media types). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
14
+ 2. **JSON document body** — When the response is a JSON media type (`Response#json_response?`), AutoSource runs `JsonDocument` only (no HTML tiers). Scalar JSON is `unsupported_surface`. Same-domain Cleanup stays on by default (`keep_different_domain: false`). If the scraper yielded items and Cleanup kept none, `Log.warn` emits `auto_source.admission_emptied` with drop tallies; a `different_domain` tally hints at `auto_source.cleanup.keep_different_domain: true` / `--keep-different-domain`, which lets the remote source control off-domain link targets (no affiliation or safety check). Guidance stays in help/docs and that structured warning — stdout feed output stays composable.
15
+ 3. **Scraper tiers** (HTML only) — Enabled scrapers that claim the page (shallow `articles?` or instance `extractable?`) run in `Scraper::SCRAPER_TIERS` order. Merge within a tier, then stop when enough articles survive Cleanup:
15
16
 
16
17
  0. Native feed: NativeFeed (head alternates + path discovery → RSS/Atom parse)
17
18
  1. In-page structured: Schema, Microdata, Microformats2, JsonState, XhrArticles
@@ -19,22 +20,23 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
19
20
  3. Heuristic: SemanticHtml
20
21
  4. Heuristic: Html (skipped when earlier tiers already admitted at least one clean article)
21
22
 
22
- 3. **Structured / API scrapers** — Schema, Microdata, MF2, JSON state, XHR JSON, oEmbed, WordPress REST, and Sitemap work on Nokogiri CSS/XPath or JSON/XML parsers. They do not use SST.
23
- 4. **Heuristic scrapers** — `SemanticHtml` and `Html` normalize once into an `SST::Document`, then:
23
+ 4. **Structured / API scrapers** — Schema, Microdata, MF2, JSON state, XHR JSON, oEmbed, WordPress REST, and Sitemap work on Nokogiri CSS/XPath or JSON/XML parsers. They do not use SST. `JsonDocument` walks a JSON response body (JSON Feed mapping or `JsonState.discover_articles`).
24
+ 5. **Heuristic scrapers** — `SemanticHtml` and `Html` normalize once into an `SST::Document`, then:
24
25
 
25
26
  `SST::Normalizer` → `AutoSource::Segmenter` → `Scoring::Engine` → extractor / article materialization.
26
27
 
27
- 5. **Cleanup** — Merge, dedupe, hard-exclude non-article destinations (via `PathClassifier` facts), drop junk titles, and trim to `limit`. Html is skipped when earlier tiers already admitted clean items.
28
+ 6. **Cleanup** — Merge, dedupe, hard-exclude non-article destinations (via `PathClassifier` facts), drop junk titles, and trim to `limit`. Html is skipped when earlier tiers already admitted clean items.
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
+ 7. **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
31
 
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.
32
+ 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
33
 
33
34
  ## Nokogiri vs SST boundaries
34
35
 
35
36
  | Surface | Owns DOM |
36
37
  | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
37
- | `Response#parsed_body` | Single HTML parse for the page |
38
+ | `Response#parsed_body` | HTML document, or Hash/Array for JSON bodies |
39
+ | `JsonDocument` | Parsed JSON Hash/Array (no Nokogiri) |
38
40
  | Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection | Nokogiri |
39
41
  | Sitemap detection (CSS/XPath) | Nokogiri; URL list parsing uses raw `response.body` (XML string) |
40
42
  | Selectors path / Sanitize transformers | Nokogiri (unchanged) |
@@ -51,7 +53,8 @@ Production heuristic scrapers should reuse one `SST::Document` memoized from `pa
51
53
 
52
54
  ## Non-goals
53
55
 
54
- - Replacing Selectors, Schema, Microdata, MF2, JsonState, or XhrArticles with SST.
56
+ - Replacing Selectors, Schema, Microdata, MF2, JsonState, JsonDocument, or XhrArticles with SST.
57
+ - Turning `keep_different_domain` on automatically for JSON documents (operators must opt in explicitly; the flag trusts the source for any published link domain).
55
58
  - A second HTML parser beside Nokogiri.
56
59
  - Dual Response payload (Nokogiri + SST always).
57
60
  - App-shell classification on SST.
@@ -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 = [
@@ -63,10 +63,28 @@ module Html2rss
63
63
  # Read-only ship-quality audit on RSS/article-like items (no mutation).
64
64
  AuditResult = Data.define(:warnings, :metrics, :violations)
65
65
 
66
+ # Config key / CLI flag named when different_domain drops emptied admission.
67
+ # Opt-in trusts the remote source for off-domain link targets (no affiliation check).
68
+ DIFFERENT_DOMAIN_HINT =
69
+ 'auto_source.cleanup.keep_different_domain: true (or --keep-different-domain); ' \
70
+ 'allows published items to link to any domain supplied by the source'
71
+
66
72
  class << self
73
+ # Mitigating config hint when admission drops emptied a non-empty capture.
74
+ # Other drop reasons remain counts only.
75
+ #
76
+ # @param drop_tallies [Hash{String => Integer}]
77
+ # @return [String, nil]
78
+ def admission_hint(drop_tallies)
79
+ return unless drop_tallies.fetch('different_domain', 0).positive?
80
+
81
+ DIFFERENT_DOMAIN_HINT
82
+ end
83
+
67
84
  # @param articles [Array<Article>] extracted article candidates
68
85
  # @param url [Html2rss::Url] feed source URL used for same-host filtering
69
- # @param keep_different_domain [Boolean] whether to keep off-domain entries
86
+ # @param keep_different_domain [Boolean] when true, keep items whose URLs are on a
87
+ # different domain than +url+ (source-controlled off-domain links; default false)
70
88
  # @return [Result] cleaned articles and frozen drop tallies
71
89
  def call(articles, url:, keep_different_domain: DEFAULT_CONFIG.fetch(:keep_different_domain)) # rubocop:disable Metrics/MethodLength -- ordered reject pipeline
72
90
  Log.debug "Cleanup: start with #{articles.size} articles"
@@ -97,13 +115,14 @@ module Html2rss
97
115
  JUNK_TITLE_RULES.find { |_, pattern| pattern.match?(normalized) }&.first
98
116
  end
99
117
 
100
- # @param items [Array] RSS or article-like objects with title and link/url
118
+ # @param items [Array<Html2rss::Article, RSS::Rss::Channel::Item>]
101
119
  # @return [AuditResult]
102
120
  def audit_feed_items(items)
103
- metrics = initial_audit_metrics(items.size)
121
+ articles = items.map { |item| feed_item_as_article(item) }
122
+ metrics = initial_audit_metrics(articles.size)
104
123
  violations = Hash.new(0)
105
- warnings = audit_url_diversity(items, metrics, violations)
106
- audit_item_titles(items, metrics, violations)
124
+ warnings = audit_url_diversity(articles, metrics, violations)
125
+ audit_item_titles(articles, metrics, violations)
107
126
  warnings = finalize_audit_warnings(warnings, metrics)
108
127
  log_audit(metrics, warnings, violations)
109
128
  AuditResult.new(warnings:, metrics: metrics.freeze, violations: violations.freeze)
@@ -256,8 +275,16 @@ module Html2rss
256
275
  url&.without_fragment&.to_s
257
276
  end
258
277
 
278
+ def feed_item_as_article(item)
279
+ case item
280
+ when Article then item
281
+ else
282
+ Article.new(title: item.title, url: item.link)
283
+ end
284
+ end
285
+
259
286
  def item_url(item)
260
- raw = item.respond_to?(:link) ? item.link : item.url
287
+ raw = item.url
261
288
  raw.nil? || raw.to_s.empty? ? nil : Html2rss::Url.from_absolute(raw.to_s)
262
289
  rescue ArgumentError
263
290
  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]
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Scraper
6
+ # Extracts articles from a parsed JSON response body (Hash or Array).
7
+ # JSON Feed documents map item fields directly; every other document walks
8
+ # via {JsonState.discover_articles}. Does not scan HTML.
9
+ class JsonDocument
10
+ include Enumerable
11
+
12
+ # Exact JSON Feed 1.0 / 1.1 +version+ URLs (no substring host matching).
13
+ JSON_FEED_VERSIONS = Set[
14
+ 'https://jsonfeed.org/version/1',
15
+ 'https://jsonfeed.org/version/1.1'
16
+ ].freeze
17
+
18
+ # @return [Symbol] scraper config key
19
+ def self.options_key = :json_document
20
+
21
+ # @param _opts [Hash] unused scraper options
22
+ # @return [Integer] zero — no additional HTTP requests
23
+ def self.request_slots(_opts = {}) = 0
24
+
25
+ # Not detectable from HTML alone; instance {#extractable?} requires a
26
+ # Hash or Array body (wired by AutoSource for JSON responses).
27
+ #
28
+ # @param _parsed_body [Object] unused
29
+ # @return [Boolean]
30
+ def self.articles?(_parsed_body) = false
31
+
32
+ # @param parsed_body [Hash, Array, Object] parsed JSON document (or HTML when misrouted)
33
+ # @param url [String, Html2rss::Url] base URL for relative link resolution
34
+ # @param _opts [Hash] scraper-specific options
35
+ # @option _opts [Object] :_reserved reserved for future scraper-specific options
36
+ def initialize(parsed_body, url:, **_opts)
37
+ @parsed_body = parsed_body
38
+ @url = url
39
+ end
40
+
41
+ # @return [Boolean] true when +parsed_body+ is a Hash or Array
42
+ def extractable?
43
+ @parsed_body.is_a?(Hash) || @parsed_body.is_a?(Array)
44
+ end
45
+
46
+ # @yield [Hash{Symbol => Object}] normalized article hash
47
+ # @return [Enumerator, void] article enumerator when no block is given
48
+ def each(&block)
49
+ return enum_for(:each) unless block
50
+ return unless extractable?
51
+
52
+ if json_feed?
53
+ each_json_feed_item(&block)
54
+ else
55
+ JsonState.discover_articles(@parsed_body, base_url: @url) do |article|
56
+ yield(article) if article
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def json_feed?
64
+ return false unless @parsed_body.is_a?(Hash)
65
+
66
+ JSON_FEED_VERSIONS.include?(@parsed_body[:version].to_s) && @parsed_body[:items].is_a?(Array)
67
+ end
68
+
69
+ def each_json_feed_item
70
+ @parsed_body[:items].each do |item|
71
+ article = map_json_feed_item(item)
72
+ yield article if article
73
+ end
74
+ end
75
+
76
+ def map_json_feed_item(item)
77
+ return unless item.is_a?(Hash)
78
+
79
+ article_url = resolve_json_feed_url(item)
80
+ return unless article_url
81
+
82
+ json_feed_article_hash(item, article_url)
83
+ end
84
+
85
+ def json_feed_article_hash(item, article_url)
86
+ {
87
+ title: string(item[:title]),
88
+ description: json_feed_description(item),
89
+ url: article_url,
90
+ image: resolve_optional_url(item[:image]),
91
+ published_at: string(item[:date_published]),
92
+ categories: json_feed_tags(item[:tags]),
93
+ id: string(item[:id]) || article_url.to_s
94
+ }.compact
95
+ end
96
+
97
+ def resolve_json_feed_url(item)
98
+ resolve_optional_url(item[:url]) || absolute_url(item[:id])
99
+ end
100
+
101
+ def resolve_optional_url(value)
102
+ str = string(value)
103
+ return unless str
104
+
105
+ Url.from_relative(str, @url)
106
+ rescue ArgumentError
107
+ nil
108
+ end
109
+
110
+ def absolute_url(value)
111
+ str = string(value)
112
+ return unless str
113
+
114
+ Url.from_absolute(str)
115
+ rescue ArgumentError
116
+ nil
117
+ end
118
+
119
+ def json_feed_description(item)
120
+ string(item[:content_text]) || string(item[:content_html])
121
+ end
122
+
123
+ def json_feed_tags(tags)
124
+ return unless tags.is_a?(Array)
125
+
126
+ names = tags.filter_map { |tag| string(tag) }
127
+ names.uniq!
128
+ names unless names.empty?
129
+ end
130
+
131
+ def string(value) = JsonState::ArticleNormalizer.string(value)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -5,15 +5,15 @@ module Html2rss
5
5
  module Scraper
6
6
  class JsonState
7
7
  # Shapes raw entries into the structure required downstream.
8
- module ArticleNormalizer
8
+ module ArticleNormalizer # rubocop:disable Metrics/ModuleLength -- field keys + URL/scalar unwrap stay co-located
9
9
  # Preferred keys when extracting title-like values from state payloads.
10
10
  TITLE_KEYS = %i[title headline name text].freeze
11
11
  # Preferred keys when extracting URL-like values from state payloads.
12
- URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
12
+ URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl alias self].freeze
13
13
  # Preferred keys when extracting description-like values from state payloads.
14
- DESCRIPTION_KEYS = %i[description summary excerpt dek subheading].freeze
14
+ DESCRIPTION_KEYS = %i[description summary excerpt dek subheading abstract].freeze
15
15
  # Preferred keys when extracting image-like values from state payloads.
16
- IMAGE_KEYS = %i[image imageUrl thumbnailUrl thumbnail src featuredImage coverImage heroImage].freeze
16
+ IMAGE_KEYS = %i[image imageUrl thumbnailUrl thumbnail src featuredImage coverImage heroImage icon].freeze
17
17
  # Preferred keys when extracting publication timestamps from state payloads.
18
18
  PUBLISHED_AT_KEYS = %i[published_at publishedAt datePublished date publicationDate pubDate updatedAt
19
19
  updated_at createdAt created_at].freeze
@@ -21,6 +21,12 @@ module Html2rss
21
21
  CATEGORY_KEYS = %i[categories tags section sections topic topics channel].freeze
22
22
  # Preferred keys when extracting identifier-like values from state payloads.
23
23
  ID_KEYS = %i[id guid uuid slug key].freeze
24
+ # Max times to unwrap a Hash URL value (e.g. path.alias, links.self.href).
25
+ MAX_URL_UNWRAP = 3
26
+ # Nested title/description keys (WordPress +rendered+, CMS +value+/+text+).
27
+ SCALAR_KEYS = %i[rendered value text].freeze
28
+ # Snake `_at`/`_on` or camel `At`/`On` suffix after a closed published-at miss.
29
+ DATE_KEY_SUFFIX = /_at\z|_on\z|[a-z]At\z|[a-z]On\z/
24
30
 
25
31
  module_function
26
32
 
@@ -44,37 +50,94 @@ module Html2rss
44
50
  url: article_url,
45
51
  image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
46
52
  log_key: 'JsonState: invalid image URL encountered'),
47
- published_at: string(ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)),
53
+ published_at: published_at(entry),
48
54
  categories: categories(entry),
49
55
  id: identifier(entry, article_url)
50
56
  }.compact
51
57
  end
52
58
  # rubocop:enable Metrics/MethodLength
53
59
 
54
- # @param value [Object] candidate scalar value
55
- # @return [String, nil] normalized non-empty string value
60
+ # @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
61
+ # @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
56
62
  def string(value)
63
+ value = unwrap_scalar(value)
64
+ return unless value.is_a?(String) || value.is_a?(Numeric)
65
+
57
66
  trimmed = value.to_s.strip
58
67
  trimmed unless trimmed.empty?
59
68
  end
60
69
 
70
+ # @param value [Object] candidate scalar or nested Hash
71
+ # @return [Object, nil] String/Numeric after at most {MAX_URL_UNWRAP} Hash unwraps
72
+ def unwrap_scalar(value)
73
+ depth = 0
74
+ while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
75
+ nested = SCALAR_KEYS.find { |key| value.key?(key) }
76
+ break unless nested
77
+
78
+ value = value[nested]
79
+ depth += 1
80
+ end
81
+ value unless value.is_a?(Hash) || value.is_a?(Array)
82
+ end
83
+
61
84
  # @param entry [Hash] raw article entry candidate
62
- # @param keys [Array<String>] preferred link keys
85
+ # @param keys [Array<Symbol>] preferred link keys
63
86
  # @param base_url [String, Html2rss::Url] base URL for relative link resolution
64
87
  # @param log_key [String] structured log message key
65
88
  # @return [Html2rss::Url, nil] resolved absolute URL
66
89
  def resolve_link(entry, keys:, base_url:, log_key:)
67
- value = ValueFinder.fetch(entry, keys)
68
- value = ValueFinder.fetch(value, keys) if value.is_a?(Hash)
90
+ value = unwrap_url(ValueFinder.fetch(entry, keys), keys) ||
91
+ unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
69
92
  string = string(value)
70
93
  return unless string
71
94
 
72
95
  Url.from_relative(string, base_url)
73
96
  rescue ArgumentError
74
- Log.debug(log_key, url: string)
97
+ Log.debug("#{log_key}: url=#{string}")
75
98
  nil
76
99
  end
77
100
 
101
+ # @param value [Object] candidate URL value, possibly a nested Hash
102
+ # @param keys [Array<Symbol>] preferred link keys
103
+ # @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} Hash unwraps
104
+ def unwrap_url(value, keys)
105
+ depth = 0
106
+ while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
107
+ value = ValueFinder.fetch(value, keys)
108
+ depth += 1
109
+ end
110
+ value unless value.is_a?(Hash)
111
+ end
112
+
113
+ # @param entry [Hash] raw article entry candidate
114
+ # @return [String, nil] publication timestamp string or unix timestamp digits
115
+ def published_at(entry)
116
+ value = ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)
117
+ string(value) || string(suffix_date_value(entry))
118
+ end
119
+
120
+ # @param hash [Hash, nil] entry or nested attributes/data hash
121
+ # @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
122
+ def suffix_date_value(hash)
123
+ return unless hash.is_a?(Hash)
124
+
125
+ hash.each do |key, value|
126
+ next unless date_suffix_key?(key)
127
+ next unless value.is_a?(String) || value.is_a?(Numeric)
128
+
129
+ return value
130
+ end
131
+
132
+ suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
133
+ end
134
+
135
+ # @param key [Symbol, String] candidate published-at key
136
+ # @return [Boolean] whether the key ends in a date suffix (_at/_on/At/On)
137
+ def date_suffix_key?(key)
138
+ key.to_s.match?(DATE_KEY_SUFFIX)
139
+ end
140
+
78
141
  # rubocop:disable Metrics/MethodLength
79
142
  # @param entry [Hash] raw article entry candidate
80
143
  # @return [Array<String>, nil] normalized unique categories
@@ -6,11 +6,6 @@ module Html2rss
6
6
  class JsonState
7
7
  # Identifies arrays that look like collections of article hashes.
8
8
  module CandidateDetector
9
- # Preferred keys when extracting title-like values from state payloads.
10
- TITLE_KEYS = %i[title headline name text].freeze
11
- # Preferred keys when extracting URL-like values from state payloads.
12
- URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
13
-
14
9
  module_function
15
10
 
16
11
  # @param document [Hash, Array, Object] candidate document node
@@ -48,13 +43,14 @@ module Html2rss
48
43
  # @param object [Hash] article candidate object
49
44
  # @return [Object, nil] detected title-like value
50
45
  def title_from(object)
51
- ValueFinder.fetch(object, TITLE_KEYS)
46
+ ValueFinder.fetch(object, ArticleNormalizer::TITLE_KEYS)
52
47
  end
53
48
 
54
49
  # @param object [Hash] article candidate object
55
- # @return [Object, nil] detected URL-like value
50
+ # @return [Object, nil] detected URL-like value (including links.self)
56
51
  def url_from(object)
57
- ValueFinder.fetch(object, URL_KEYS)
52
+ ValueFinder.fetch(object, ArticleNormalizer::URL_KEYS) ||
53
+ ValueFinder.fetch(object[:links], ArticleNormalizer::URL_KEYS)
58
54
  end
59
55
  end
60
56
  end
@@ -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
  #