html2rss 0.27.2 → 0.29.1

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +82 -25
  3. data/html2rss.gemspec +0 -1
  4. data/lib/html2rss/article.rb +1 -2
  5. data/lib/html2rss/auto_source/README.md +12 -9
  6. data/lib/html2rss/auto_source/cleanup.rb +92 -1
  7. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
  8. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
  9. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
  10. data/lib/html2rss/auto_source/scraper/native_feed.rb +93 -0
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +6 -4
  13. data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
  14. data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
  15. data/lib/html2rss/auto_source/segmenter.rb +1 -2
  16. data/lib/html2rss/auto_source.rb +32 -11
  17. data/lib/html2rss/batch.rb +169 -0
  18. data/lib/html2rss/capture/README.md +15 -6
  19. data/lib/html2rss/capture.rb +158 -15
  20. data/lib/html2rss/channel.rb +1 -2
  21. data/lib/html2rss/cli/probe_view.rb +66 -0
  22. data/lib/html2rss/cli/render.rb +124 -0
  23. data/lib/html2rss/cli/validate.rb +91 -0
  24. data/lib/html2rss/cli.rb +318 -193
  25. data/lib/html2rss/config/auto_source_contract.rb +7 -0
  26. data/lib/html2rss/config/request_headers.rb +4 -14
  27. data/lib/html2rss/config/schema.rb +7 -5
  28. data/lib/html2rss/config/validator.rb +1 -0
  29. data/lib/html2rss/config.rb +64 -18
  30. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  31. data/lib/html2rss/feed_builder/rss.rb +1 -2
  32. data/lib/html2rss/feed_pipeline/README.md +7 -5
  33. data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
  34. data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
  35. data/lib/html2rss/feed_pipeline.rb +48 -21
  36. data/lib/html2rss/feed_resolution/README.md +52 -0
  37. data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
  38. data/lib/html2rss/feed_resolution/diag.rb +32 -0
  39. data/lib/html2rss/feed_resolution/options.rb +31 -0
  40. data/lib/html2rss/feed_resolution/policy.rb +47 -0
  41. data/lib/html2rss/feed_resolution/probe.rb +59 -0
  42. data/lib/html2rss/feed_resolution/scorer.rb +49 -0
  43. data/lib/html2rss/feed_resolution.rb +268 -0
  44. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  45. data/lib/html2rss/html/article_extractor.rb +4 -9
  46. data/lib/html2rss/html/feed_link.rb +4 -2
  47. data/lib/html2rss/html/navigator.rb +4 -39
  48. data/lib/html2rss/html/probe.rb +82 -0
  49. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  50. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  51. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  52. data/lib/html2rss/mcp/README.md +80 -0
  53. data/lib/html2rss/mcp/contract.rb +135 -13
  54. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  55. data/lib/html2rss/mcp/outcome.rb +112 -40
  56. data/lib/html2rss/mcp/runtime.rb +45 -0
  57. data/lib/html2rss/mcp/server/tools.rb +277 -0
  58. data/lib/html2rss/mcp/server.rb +91 -232
  59. data/lib/html2rss/mcp.rb +3 -0
  60. data/lib/html2rss/page_recon/README.md +69 -0
  61. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  62. data/lib/html2rss/page_recon.rb +297 -0
  63. data/lib/html2rss/recon.rb +252 -0
  64. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  65. data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
  66. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  67. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  68. data/lib/html2rss/request_service/policy.rb +1 -2
  69. data/lib/html2rss/request_service/response.rb +27 -4
  70. data/lib/html2rss/request_service/strategy.rb +1 -2
  71. data/lib/html2rss/request_service.rb +15 -1
  72. data/lib/html2rss/request_session.rb +4 -2
  73. data/lib/html2rss/scrape_target.rb +24 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  75. data/lib/html2rss/selectors/post_processors.rb +0 -1
  76. data/lib/html2rss/selectors.rb +33 -10
  77. data/lib/html2rss/sst/attrs.rb +1 -2
  78. data/lib/html2rss/sst/normalizer.rb +49 -11
  79. data/lib/html2rss/status.rb +48 -24
  80. data/lib/html2rss/surface_category.rb +63 -0
  81. data/lib/html2rss/syndication/README.md +21 -0
  82. data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
  83. data/lib/html2rss/syndication/discovery.rb +215 -0
  84. data/lib/html2rss/syndication/parser.rb +137 -0
  85. data/lib/html2rss/syndication.rb +10 -0
  86. data/lib/html2rss/test/enhance_audit.rb +192 -0
  87. data/lib/html2rss/test.rb +458 -0
  88. data/lib/html2rss/url.rb +23 -2
  89. data/lib/html2rss/version.rb +1 -1
  90. data/lib/html2rss.rb +157 -35
  91. data/schema/html2rss-config.schema.json +47 -29
  92. metadata +34 -18
  93. data/lib/html2rss/mcp/inspect.rb +0 -241
  94. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +0 -59
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09b4a391aa87101ab6612c07c0c434b9ebc6198fc3bed8c14c2f8b90bd61bc6a'
4
- data.tar.gz: d7fac305311719a6b131be4e89fe78919aef1b45c763a22c471b24583b41d7fa
3
+ metadata.gz: 32c090eccd93210c9631778ff50ec7d32d36c7eccaa711cdd1a0dd5c3ea53616
4
+ data.tar.gz: 93e4d2f253a019d7de585c8acaaa8d06143accb4f3a43a4f8d13f9c96d838390
5
5
  SHA512:
6
- metadata.gz: d5fb2cdfc2b95e64de5ac42ffbe1479c15fe63b01f4b922d721d3099ffdd418c9293a1672217838612d5cf4f7bbc2e153cf83ffbcc85e8f30bf8a1a17bb20a79
7
- data.tar.gz: a9f51aa30dfdf8e3e222ed1a49216e857cd056f473408273ad3781e862d743f657a67473fe691423e1fb1252fbe2935ee6eb1cef009d3b2662d03b66f7bdb90c
6
+ metadata.gz: 93956c4dc3d35e06c81dff939d4ea5184da93834a0fb48f1b3dbd4dea21d615177ad45fa34e4f16abf396a184cc18acbe31c354df6764ba858ec340c78a3b08c
7
+ data.tar.gz: b906784ed2ae53b80aa2fc00fe813550befcfac723d97346ffb87cff7f01ca6b0f5db0a6e4a2430dd10485c20f6d4b251a54a1b9e0462998f2ab628c0123a809
data/README.md CHANGED
@@ -35,13 +35,63 @@ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new
35
35
  Config -> Request -> Extraction -> Processing -> Building -> Output
36
36
  ```
37
37
 
38
+ ## CLI Usage
39
+
40
+ | Verb | Job |
41
+ | -------- | ------------------------------------------------------------- |
42
+ | inspect | Cheap diagnostics (final URL, status, alternates, surface) |
43
+ | recon | Verdict + native feed preference (`BUILD` / `DEFER` / `DROP`) |
44
+ | capture | YAML draft config |
45
+ | validate | Schema only |
46
+ | test | Schema + live extraction (min items) |
47
+ | apply | Ship RSS from config or URL |
48
+ | scrape | Articles now (one-shot auto-source) |
49
+
50
+ Golden path: optional **inspect → recon → capture → test → apply**. Side door: **validate**. One-shot: **scrape**.
51
+
52
+ ```bash
53
+ # Diagnostics and reconnaissance
54
+ html2rss inspect https://example.com/news
55
+ html2rss recon https://example.com/news
56
+ html2rss recon --file urls.txt --verdict BUILD --url-only
57
+
58
+ # Composable pipes
59
+ html2rss recon --file urls.txt --verdict BUILD --url-only | html2rss capture -
60
+ html2rss capture https://example.com/news | html2rss test -
61
+
62
+ # One-shot articles now
63
+ html2rss scrape https://example.com/news
64
+ html2rss scrape https://example.com/news --format jsonfeed --explain
65
+
66
+ # Durable config workflow
67
+ html2rss capture https://example.com/news --write feed.yml
68
+ html2rss test feed.yml --min-items 5
69
+ html2rss apply feed.yml
70
+
71
+ # Schema validation (side door)
72
+ html2rss validate config.yml
73
+ html2rss validate "configs/**/*.yml"
74
+
75
+ # Export JSON Schema
76
+ html2rss schema --write schema/html2rss-config.schema.json
77
+ ```
78
+
79
+ Historic CLI aliases: `feed` → `apply`, `auto` → `scrape`.
80
+
81
+ ### Inspect output
82
+
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
+
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).
86
+
38
87
  ## Capture API
39
88
 
40
- The `Html2rss.capture` method analyzes any URL and produces a reusable feed config hash with an items selector and `enhance: true`. Use it to speed up writing feed configuration files.
89
+ `Html2rss.capture` returns a `Capture::CaptureResult`. Use `result.yaml` or `result.config`.
41
90
 
42
91
  ```ruby
43
- config = Html2rss.capture('https://example.com/articles')
44
- File.write('my-feed.yml', Html2rss::Config.to_yaml(config))
92
+ result = Html2rss.capture('https://example.com/articles')
93
+ File.write('my-feed.yml', result.yaml)
94
+ # or: File.write('my-feed.yml', Html2rss::Config.to_yaml(result.config))
45
95
  ```
46
96
 
47
97
  The CLI alias `html2rss capture` prints the generated config as YAML to stdout. See [`lib/html2rss/capture/README.md`](lib/html2rss/capture/README.md) for detailed documentation.
@@ -62,9 +112,9 @@ stdio uses stdout for JSON-RPC, so the daemon logs to **stderr**. It defaults to
62
112
 
63
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.
64
114
 
65
- **Strategy note:** MCP `scrape_url` / `capture_config` with `strategy: "auto"` run Faraday → Botasaurus AutoFallback. `inspect_url` 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 Faraday → Botasaurus AutoFallback. `inspect` uses Faraday when `auto` (cheap diagnostic); pin `botasaurus` when you need browser rendering for inspect.
66
116
 
67
- **Tool-call budget:** `scrape_url` is 1 call (auto already hops). Durable config is `capture_config` → `validate_config` → `apply_config`. Call `inspect_url` only when scrape/capture is weak or you need recon (final URL, status, https→http, native RSS/Atom).
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).
68
118
 
69
119
  Cursor / Claude Desktop `mcp.json` must put Botasaurus on the **MCP process** (not only your shell):
70
120
 
@@ -82,17 +132,24 @@ Cursor / Claude Desktop `mcp.json` must put Botasaurus on the **MCP process** (n
82
132
  }
83
133
  ```
84
134
 
85
- Read `html2rss://runtime` for a boolean `botasaurus_configured` (the URL is never returned). Every tool result is a JSON envelope (`ok`, `next_step`, `guidance`, `payload`) in both the text body and `structuredContent`. Follow `next_step` / `guidance`; do not parse scrape text as a raw item array.
135
+ Read `html2rss://runtime` for `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, and `botasaurus_configured` (the scraper URL is never returned). Refresh `tools/list` when `catalog_fingerprint` differs from your cache. Every tool result is a JSON envelope (`ok`, `next_step`, `guidance`, `payload`) in both the text body and `structuredContent`. Follow `next_step` / `guidance`; do not parse scrape text as a raw item array.
136
+
137
+ Module guide: [`lib/html2rss/mcp/README.md`](lib/html2rss/mcp/README.md).
86
138
 
87
139
  ### Tools
88
140
 
89
- | Name | When to use |
90
- | ----------------- | --------------------------------------------------------------------------- |
91
- | `scrape_url` | One-shot articles now (`payload.items`; empty is still success) |
92
- | `inspect_url` | Weak scrape/capture or recon (final_url, status, scheme_downgrade, feeds) |
93
- | `capture_config` | YAML draft in `payload.yaml`; strive `enhance: true` |
94
- | `validate_config` | Schema-check a `config` hash XOR `yaml` string (`isError` on failure) |
95
- | `apply_config` | RSS in `payload.rss`; `isError` when zero items; confirm `payload.item_count` |
141
+ | Name | When to use |
142
+ | --------------- | ------------------------------------------------------------------------------ |
143
+ | `scrape` | One-shot articles now (`payload.items`; empty is still success) |
144
+ | `batch_scrape` | Parallel one-shot scrape across multiple URLs (`urls`, `limit`, `concurrency`) |
145
+ | `inspect` | Weak scrape/capture or recon (final_url, status, scheme_downgrade, feeds) |
146
+ | `batch_inspect` | Parallel diagnostics across multiple URLs (`urls`, `strategy`, `concurrency`) |
147
+ | `recon` | Verdict + native_feed preference |
148
+ | `batch_recon` | Parallel recon across multiple URLs |
149
+ | `capture` | YAML draft in `payload.yaml`; strive `enhance: true` |
150
+ | `validate` | Schema-check a `config` hash XOR `yaml` string (`isError` on failure) |
151
+ | `test` | Schema + live extraction; `quality_report.enhance_gains` when enhance on; optional `compare_enhance` |
152
+ | `apply` | RSS in `payload.rss`; `isError` when zero items; `quality_report` may include `enhance_gains` |
96
153
 
97
154
  ### Resources
98
155
 
@@ -100,15 +157,15 @@ Read `html2rss://runtime` for a boolean `botasaurus_configured` (the URL is neve
100
157
  | ----------------------- | --------------------------------------------------------------- |
101
158
  | `html2rss://schema` | Full JSON Schema for feed configurations |
102
159
  | `html2rss://extractors` | Registered extractor **names** (options live in schema `$defs`) |
103
- | `html2rss://strategies` | Published MCP strategies (`auto`, `faraday`, `botasaurus`) |
104
- | `html2rss://runtime` | `botasaurus_configured` boolean (never the scraper URL) |
160
+ | `html2rss://strategies` | Published MCP strategies (`auto`, `faraday`, `botasaurus`) |
161
+ | `html2rss://runtime` | `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, `botasaurus_configured` (never the scraper URL) |
105
162
 
106
163
  ### Prompts
107
164
 
108
- | Name | Description |
109
- | --------------------- | -------------------------------------------------------------------- |
110
- | `scrape-webpage` | One `scrape_url` call; inspect only if weak or recon |
111
- | `capture-feed-config` | Capture YAML → validate → apply; catalog rewrite; strive enhance |
165
+ | Name | Description |
166
+ | --------------------- | ------------------------------------------------------------ |
167
+ | `scrape-webpage` | One `scrape` call; `inspect` only if weak or recon needed |
168
+ | `capture-feed-config` | Capture YAML → test → apply; catalog rewrite; strive enhance |
112
169
 
113
170
  The MCP module (`Html2rss::MCP`) lazy-loads the `mcp` gem — no cost when the server is not running.
114
171
 
@@ -124,13 +181,13 @@ Set `BOTASAURUS_SCRAPER_URL` to `http://127.0.0.1:4010` and use strategy `botasa
124
181
 
125
182
  ## Request Strategies
126
183
 
127
- | Strategy | Description |
128
- | ------------ | ----------------------------------------------------------------------------- |
129
- | `auto` | Tries `faraday`, falls back to `botasaurus` (default in gem/CLI/MCP scrape) |
130
- | `faraday` | Plain HTTP requests via Faraday |
131
- | `botasaurus` | Puppeteer-backed scraping for JavaScript pages |
184
+ | Strategy | Description |
185
+ | ------------ | --------------------------------------------------------------------------- |
186
+ | `auto` | Tries `faraday`, falls back to `botasaurus` (default in gem/CLI/MCP scrape) |
187
+ | `faraday` | Plain HTTP requests via Faraday |
188
+ | `botasaurus` | Puppeteer-backed scraping for JavaScript pages |
132
189
 
133
- `inspect_url` 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 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.
134
191
 
135
192
  ## License
136
193
 
data/html2rss.gemspec CHANGED
@@ -45,7 +45,6 @@ Gem::Specification.new do |spec|
45
45
  spec.add_dependency 'rack', '~> 3.0'
46
46
  spec.add_dependency 'rackup', '~> 2.0'
47
47
  spec.add_dependency 'regexp_parser'
48
- spec.add_dependency 'reverse_markdown', '~> 3.0'
49
48
  spec.add_dependency 'rss'
50
49
  spec.add_dependency 'sanitize'
51
50
  spec.add_dependency 'thor'
@@ -8,7 +8,7 @@ module Html2rss
8
8
  # It is enumerable and responds to all keys specified in PROVIDED_KEYS.
9
9
  #
10
10
  # Description and enclosure wire presentation live in {FeedBuilder::ItemPresentation}.
11
- # rubocop:disable Metrics/ClassLength -- value object retains Marshal + defensive freeze helpers
11
+ # rubocop:disable-next Metrics/ClassLength -- value object retains Marshal + defensive freeze helpers
12
12
  class Article
13
13
  include Enumerable
14
14
  include Comparable
@@ -209,5 +209,4 @@ module Html2rss
209
209
  value
210
210
  end
211
211
  end
212
- # rubocop:enable Metrics/ClassLength
213
212
  end
@@ -10,9 +10,10 @@ 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).
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
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:
15
15
 
16
+ 0. Native feed: NativeFeed (head alternates + path discovery → RSS/Atom parse)
16
17
  1. In-page structured: Schema, Microdata, Microformats2, JsonState, XhrArticles
17
18
  2. Follow-up IO: WordPress API, Sitemap, MetaOembed
18
19
  3. Heuristic: SemanticHtml
@@ -25,18 +26,20 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
25
26
 
26
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.
27
28
 
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
+
28
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.
29
32
 
30
33
  ## Nokogiri vs SST boundaries
31
34
 
32
- | Surface | Owns DOM |
33
- | --- | --- |
34
- | `Response#parsed_body` | Single HTML parse for the page |
35
- | Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection | Nokogiri |
36
- | Sitemap detection (CSS/XPath) | Nokogiri; URL list parsing uses raw `response.body` (XML string) |
37
- | Selectors path / Sanitize transformers | Nokogiri (unchanged) |
38
- | `SST::Normalizer` | Sole Nokogiri consumer on the heuristic auto-source path |
39
- | Segmenter, Scoring, `Html::SstArticleExtractor`, heuristic chrome (`SST::Tags` / `SST::Text`) | SST only |
35
+ | Surface | Owns DOM |
36
+ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
37
+ | `Response#parsed_body` | Single HTML parse for the page |
38
+ | Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection | Nokogiri |
39
+ | Sitemap detection (CSS/XPath) | Nokogiri; URL list parsing uses raw `response.body` (XML string) |
40
+ | Selectors path / Sanitize transformers | Nokogiri (unchanged) |
41
+ | `SST::Normalizer` | Sole Nokogiri consumer on the heuristic auto-source path |
42
+ | Segmenter, Scoring, `Html::SstArticleExtractor`, heuristic chrome (`SST::Tags` / `SST::Text`) | SST only |
40
43
 
41
44
  Production heuristic scrapers should reuse one `SST::Document` memoized from `parsed_body` (or a shared Document passed in), not re-parse HTML strings.
42
45
 
@@ -47,13 +47,22 @@ module Html2rss
47
47
  [:video_chrome, /\AClipped\s+From\s+Video\b/i],
48
48
  [:video_chrome, /\AVideo\s*[•·]/i],
49
49
  [:template, /\ACreated\s+from\s+Template\s+ID\b/i],
50
- [:template, /(\{\{[^}]+\}\}|%\{\w+\})/]
50
+ [:template, /(\{\{[^}]+\}\}|%\{\w+\})/],
51
+ [:cta, /\Aread more\z/i],
52
+ [:cta, /\Alearn more\z/i],
53
+ [:cta, /\Apdf\z/i]
51
54
  ].freeze
52
55
  private_constant :JUNK_TITLE_RULES
53
56
 
57
+ # Minimum present-title length before short-title warnings.
58
+ MIN_TITLE_LENGTH = 4
59
+
54
60
  # Admitted articles plus reason → count tallies for drops.
55
61
  Result = Data.define(:articles, :drop_tallies)
56
62
 
63
+ # Read-only ship-quality audit on RSS/article-like items (no mutation).
64
+ AuditResult = Data.define(:warnings, :metrics, :violations)
65
+
57
66
  class << self
58
67
  # @param articles [Array<Article>] extracted article candidates
59
68
  # @param url [Html2rss::Url] feed source URL used for same-host filtering
@@ -88,8 +97,83 @@ module Html2rss
88
97
  JUNK_TITLE_RULES.find { |_, pattern| pattern.match?(normalized) }&.first
89
98
  end
90
99
 
100
+ # @param items [Array] RSS or article-like objects with title and link/url
101
+ # @return [AuditResult]
102
+ def audit_feed_items(items)
103
+ metrics = initial_audit_metrics(items.size)
104
+ violations = Hash.new(0)
105
+ warnings = audit_url_diversity(items, metrics, violations)
106
+ audit_item_titles(items, metrics, violations)
107
+ warnings = finalize_audit_warnings(warnings, metrics)
108
+ log_audit(metrics, warnings, violations)
109
+ AuditResult.new(warnings:, metrics: metrics.freeze, violations: violations.freeze)
110
+ end
111
+
91
112
  private
92
113
 
114
+ def initial_audit_metrics(item_count)
115
+ {
116
+ item_count:,
117
+ unique_url_count: 0,
118
+ junk_title_count: 0,
119
+ short_title_count: 0,
120
+ low_word_count: 0
121
+ }
122
+ end
123
+
124
+ def audit_url_diversity(items, metrics, violations)
125
+ unique_urls = items.filter_map { |item| url_identity(item_url(item)) }.uniq
126
+ metrics[:unique_url_count] = unique_urls.size
127
+ return [] unless items.size >= 2 && unique_urls.size < 2
128
+
129
+ violations[:duplicate_urls] += 1
130
+ [:duplicate_urls]
131
+ end
132
+
133
+ def audit_item_titles(items, metrics, violations)
134
+ items.each do |item|
135
+ title = normalize_title(item.title)
136
+ next if title.empty?
137
+
138
+ audit_short_title(title, metrics, violations)
139
+ audit_title_quality(title, metrics, violations)
140
+ end
141
+ end
142
+
143
+ def audit_short_title(title, metrics, violations)
144
+ return unless title.length < MIN_TITLE_LENGTH
145
+
146
+ metrics[:short_title_count] += 1
147
+ violations[:short_title] += 1
148
+ end
149
+
150
+ def audit_title_quality(title, metrics, violations)
151
+ reason = junk_reason(title)
152
+ if reason
153
+ metrics[:junk_title_count] += 1
154
+ violations[reason] += 1
155
+ elsif !word_count_at_least?(title, MIN_WORDS)
156
+ metrics[:low_word_count] += 1
157
+ violations[:low_word_count] += 1
158
+ end
159
+ end
160
+
161
+ def finalize_audit_warnings(warnings, metrics)
162
+ warnings = warnings.dup
163
+ warnings << :generic_titles if metrics[:junk_title_count].positive?
164
+ warnings << :short_titles if metrics[:short_title_count].positive?
165
+ warnings << :low_word_count if metrics[:low_word_count].positive?
166
+ warnings.freeze
167
+ end
168
+
169
+ def log_audit(metrics, warnings, violations)
170
+ Log.debug(
171
+ 'Cleanup.audit_feed_items: ' \
172
+ "item_count=#{metrics[:item_count]} unique_urls=#{metrics[:unique_url_count]} " \
173
+ "warnings=#{warnings.join(',')} violations=#{violations.keys.join(',')}"
174
+ )
175
+ end
176
+
93
177
  def reject_invalid!(articles, tallies)
94
178
  tally_reject!(articles, tallies, 'invalid') { |article| !article.valid? }
95
179
  end
@@ -172,6 +256,13 @@ module Html2rss
172
256
  url&.without_fragment&.to_s
173
257
  end
174
258
 
259
+ def item_url(item)
260
+ raw = item.respond_to?(:link) ? item.link : item.url
261
+ raw.nil? || raw.to_s.empty? ? nil : Html2rss::Url.from_absolute(raw.to_s)
262
+ rescue ArgumentError
263
+ nil
264
+ end
265
+
175
266
  def normalize_title(title)
176
267
  title.to_s.strip.gsub(/\s+/, ' ')
177
268
  end
@@ -8,8 +8,6 @@ module Html2rss
8
8
  class JsonState
9
9
  # Scans DOM nodes for JSON payloads containing article data.
10
10
  module DocumentScanner # rubocop:disable Metrics/ModuleLength
11
- # Selector for JSON-only script tags.
12
- JSON_SCRIPT_SELECTOR = 'script[type="application/json"]'
13
11
  # Regex patterns for known global JavaScript state assignments.
14
12
  GLOBAL_ASSIGNMENT_PATTERNS = [
15
13
  /(?:window|self|globalThis)\.__NEXT_DATA__\s*=\s*/m,
@@ -35,16 +33,16 @@ module Html2rss
35
33
  def json_documents(parsed_body)
36
34
  # Use identity-based cache to avoid double-parsing of the same document.
37
35
  # WeakMap allows the Nokogiri Document (key) to be garbage collected.
38
- # rubocop:disable ThreadSafety/ClassInstanceVariable
36
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
39
37
  (@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||=
40
38
  script_documents(parsed_body) + assignment_documents(parsed_body)
41
- # rubocop:enable ThreadSafety/ClassInstanceVariable
42
39
  end
43
40
 
44
41
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
45
42
  # @return [Array<Hash, Array>] JSON documents extracted from JSON script tags
46
43
  def script_documents(parsed_body)
47
- parsed_body.css(JSON_SCRIPT_SELECTOR).filter_map { parse_json(_1.text) }
44
+ ::Html2rss::Html::Probe.scripts(parsed_body, ::Html2rss::Html::Probe::APPLICATION_JSON)
45
+ .filter_map { parse_json(_1.text) }
48
46
  end
49
47
 
50
48
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
@@ -13,9 +13,6 @@ module Html2rss
13
13
 
14
14
  # Selector for OpenGraph meta tags.
15
15
  OG_META_SELECTOR = 'meta[property^="og:"], meta[property^="article:"], meta[name^="twitter:"]'
16
- # Selector for oEmbed JSON link tag.
17
- OEMBED_LINK_SELECTOR = 'link[rel="alternate"][type="application/json+oembed"][href]'
18
-
19
16
  # Mapping of meta property names to article attribute keys.
20
17
  META_MAP = {
21
18
  'og:title' => :title,
@@ -40,7 +37,11 @@ module Html2rss
40
37
  return false unless parsed_body
41
38
 
42
39
  !parsed_body.at_css('meta[property="og:title"]').nil? ||
43
- !parsed_body.at_css(OEMBED_LINK_SELECTOR).nil?
40
+ ::Html2rss::Html::Probe.alternate_links(
41
+ parsed_body,
42
+ rel: 'alternate',
43
+ mime: ::Html2rss::Html::Probe::APPLICATION_JSON_OEMBED
44
+ ).any?
44
45
  end
45
46
  end
46
47
 
@@ -109,7 +110,7 @@ module Html2rss
109
110
 
110
111
  # @return [Hash{Symbol => Object}] oEmbed fields hash
111
112
  def fetch_oembed_data
112
- return {} unless request_session && (link_node = parsed_body.at_css(OEMBED_LINK_SELECTOR))
113
+ return {} unless request_session && (link_node = oembed_link_node)
113
114
  return {} unless (oembed_url = resolve_url(link_node['href']))
114
115
 
115
116
  response = request_session.follow_up(url: oembed_url, relation: :auto_source, origin_url: url)
@@ -119,6 +120,15 @@ module Html2rss
119
120
  {}
120
121
  end
121
122
 
123
+ # @return [Nokogiri::XML::Element, nil] oEmbed descriptor link node
124
+ def oembed_link_node
125
+ ::Html2rss::Html::Probe.alternate_links(
126
+ parsed_body,
127
+ rel: 'alternate',
128
+ mime: ::Html2rss::Html::Probe::APPLICATION_JSON_OEMBED
129
+ ).first
130
+ end
131
+
122
132
  # @param response [Html2rss::RequestService::Response, nil] HTTP response
123
133
  # @return [Hash{Symbol => Object}] parsed oEmbed fields
124
134
  def parse_oembed_response(response)
@@ -39,15 +39,17 @@ module Html2rss
39
39
  # @param node [Nokogiri::XML::Element] itemscope candidate node
40
40
  # @return [String, nil] supported schema type name when present
41
41
  def supported_type_name(node)
42
- normalized_types(node['itemtype']).find { SUPPORTED_TYPES.include?(_1) }
42
+ itemtype_type_names(node['itemtype']).find { SUPPORTED_TYPES.include?(_1) }
43
43
  end
44
44
 
45
45
  # @param itemtype [String, nil] raw itemtype attribute value
46
- # @return [Array<String>] normalized schema type names
47
- def normalized_types(itemtype)
46
+ # @return [Array<String>] canonical schema type names
47
+ def itemtype_type_names(itemtype)
48
48
  itemtype.to_s.split.filter_map do |value|
49
- type = value.split('/').last.to_s.split('#').last.to_s
50
- type unless type.empty?
49
+ short = value.split('/').last.to_s.split('#').last.to_s
50
+ next if short.empty?
51
+
52
+ Schema.canonicalize_type(short)
51
53
  end
52
54
  end
53
55
 
@@ -189,7 +191,7 @@ module Html2rss
189
191
  item = call(node)
190
192
  itemtype = node['itemtype']
191
193
  itemid = node['itemid']
192
- item[:@type] = Microdata.normalized_types(itemtype).first if itemtype
194
+ item[:@type] = Microdata.itemtype_type_names(itemtype).first if itemtype
193
195
  item[:@id] = itemid if present?(itemid)
194
196
  item
195
197
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Scraper
6
+ ##
7
+ # Promotes a page's native RSS/Atom feed via {Syndication::Discovery} + {Syndication::Parser}.
8
+ class NativeFeed
9
+ include Enumerable
10
+
11
+ # @return [Symbol] scraper config key
12
+ def self.options_key = :native_feed
13
+
14
+ ##
15
+ # @param _opts [Hash] unused options
16
+ # @return [Integer] follow-up request slots (discovery probes + feed fetch share the budget)
17
+ def self.request_slots(_opts = {})
18
+ 1
19
+ end
20
+
21
+ ##
22
+ # Shallow claim: head advertises a syndication alternate, or body looks feed-capable
23
+ # enough that path discovery is worth a follow-up slot.
24
+ #
25
+ # @param parsed_body [Nokogiri::HTML::Document, nil]
26
+ # @return [Boolean]
27
+ def self.articles?(parsed_body)
28
+ return false unless parsed_body.is_a?(Nokogiri::HTML::Document)
29
+
30
+ return true if ::Html2rss::Html::FeedLink.from_document(parsed_body).any?
31
+
32
+ parsed_body.css('head link[rel~="alternate"][href]').any? do |node|
33
+ href = node['href'].to_s
34
+ ::Html2rss::Html::Probe.mime_match?(
35
+ node['type'],
36
+ ::Html2rss::Html::Probe::APPLICATION_RSS_XML,
37
+ ::Html2rss::Html::Probe::APPLICATION_ATOM_XML
38
+ ) || href.match?(/rss|atom|feed|\.xml/i)
39
+ end
40
+ end
41
+
42
+ ##
43
+ # @param parsed_body [Nokogiri::HTML::Document]
44
+ # @param url [String, Html2rss::Url]
45
+ # @param request_session [Html2rss::RequestSession, nil]
46
+ # @param _opts [Hash]
47
+ # @option _opts [Object] :_reserved reserved for future scraper-specific options
48
+ def initialize(parsed_body, url:, request_session: nil, **_opts)
49
+ @parsed_body = parsed_body
50
+ @url = Html2rss::Url.from_absolute(url)
51
+ @request_session = request_session
52
+ end
53
+
54
+ ##
55
+ # @yieldparam article [Hash{Symbol => Object}]
56
+ # @return [Enumerator, void]
57
+ def each(&)
58
+ return enum_for(:each) unless block_given?
59
+
60
+ articles = fetch_articles
61
+ if articles.empty?
62
+ Log.info("#{self.class}: host=#{url.host} item_count=0 fallback=true")
63
+ return
64
+ end
65
+
66
+ Log.info("#{self.class}: host=#{url.host} item_count=#{articles.size} fallback=false")
67
+ articles.each(&)
68
+ end
69
+
70
+ private
71
+
72
+ attr_reader :parsed_body, :url, :request_session
73
+
74
+ def fetch_articles # rubocop:disable Metrics/MethodLength -- discovery + parse path
75
+ return [] unless request_session
76
+
77
+ response = Syndication::Discovery.best_feed_response(
78
+ page_url: url,
79
+ request_session:,
80
+ parsed_body:,
81
+ max_probes: self.class.request_slots
82
+ )
83
+ return [] unless response
84
+
85
+ Syndication::Parser.parse_response(response)
86
+ rescue Html2rss::Error, ArgumentError => error
87
+ Log.warn("#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})")
88
+ []
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end