html2rss 0.28.0 → 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 (80) 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 +8 -8
  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 +5 -2
  11. data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
  12. data/lib/html2rss/auto_source/scraper.rb +2 -3
  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 +25 -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/request_headers.rb +4 -14
  26. data/lib/html2rss/config/schema.rb +7 -5
  27. data/lib/html2rss/config/validator.rb +1 -0
  28. data/lib/html2rss/config.rb +59 -22
  29. data/lib/html2rss/doctor/botasaurus.rb +130 -0
  30. data/lib/html2rss/feed_builder/rss.rb +1 -2
  31. data/lib/html2rss/feed_pipeline/README.md +5 -5
  32. data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
  33. data/lib/html2rss/feed_pipeline.rb +27 -16
  34. data/lib/html2rss/feed_resolution/README.md +11 -11
  35. data/lib/html2rss/feed_resolution.rb +6 -12
  36. data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
  37. data/lib/html2rss/html/article_extractor.rb +4 -9
  38. data/lib/html2rss/html/feed_link.rb +4 -2
  39. data/lib/html2rss/html/navigator.rb +4 -39
  40. data/lib/html2rss/html/probe.rb +82 -0
  41. data/lib/html2rss/html/sst_article_extractor.rb +1 -2
  42. data/lib/html2rss/link_destination/noise_policy.rb +1 -2
  43. data/lib/html2rss/link_destination/path_classifier.rb +20 -19
  44. data/lib/html2rss/mcp/README.md +80 -0
  45. data/lib/html2rss/mcp/contract.rb +135 -13
  46. data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
  47. data/lib/html2rss/mcp/outcome.rb +112 -42
  48. data/lib/html2rss/mcp/runtime.rb +45 -0
  49. data/lib/html2rss/mcp/server/tools.rb +277 -0
  50. data/lib/html2rss/mcp/server.rb +91 -232
  51. data/lib/html2rss/mcp.rb +3 -0
  52. data/lib/html2rss/page_recon/README.md +69 -0
  53. data/lib/html2rss/page_recon/diagnostics.rb +211 -0
  54. data/lib/html2rss/page_recon.rb +56 -2
  55. data/lib/html2rss/recon.rb +252 -0
  56. data/lib/html2rss/request_service/blocked_surface.rb +1 -0
  57. data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
  58. data/lib/html2rss/request_service/policy.rb +1 -2
  59. data/lib/html2rss/request_service/response.rb +3 -4
  60. data/lib/html2rss/request_service/strategy.rb +1 -2
  61. data/lib/html2rss/request_service.rb +2 -0
  62. data/lib/html2rss/request_session.rb +1 -2
  63. data/lib/html2rss/scrape_target.rb +1 -1
  64. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
  65. data/lib/html2rss/selectors/post_processors.rb +0 -1
  66. data/lib/html2rss/selectors.rb +33 -10
  67. data/lib/html2rss/sst/attrs.rb +1 -2
  68. data/lib/html2rss/sst/normalizer.rb +49 -11
  69. data/lib/html2rss/status.rb +3 -6
  70. data/lib/html2rss/syndication/README.md +6 -6
  71. data/lib/html2rss/syndication/discovery.rb +2 -4
  72. data/lib/html2rss/test/enhance_audit.rb +192 -0
  73. data/lib/html2rss/test.rb +458 -0
  74. data/lib/html2rss/url.rb +1 -2
  75. data/lib/html2rss/version.rb +1 -1
  76. data/lib/html2rss.rb +157 -35
  77. data/schema/html2rss-config.schema.json +9 -29
  78. metadata +17 -18
  79. data/lib/html2rss/mcp/inspect.rb +0 -138
  80. 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: 38cdc153241c05684ee40477e4df06ca2c88f013c971387ddd0ee6d8163c178a
4
- data.tar.gz: eda1c02f0199568f8f71d24b22480b5eaa9a83c7e48c23678e9695e98e2dab8f
3
+ metadata.gz: 32c090eccd93210c9631778ff50ec7d32d36c7eccaa711cdd1a0dd5c3ea53616
4
+ data.tar.gz: 93e4d2f253a019d7de585c8acaaa8d06143accb4f3a43a4f8d13f9c96d838390
5
5
  SHA512:
6
- metadata.gz: 5e063256aa063027d53986e38703cc0065d12e7aaee45cdfa7724a724f599a0f645e836a0573c9633865d007532bb82c3c2a6632984c5385dcc6fafd5ed0409d
7
- data.tar.gz: 146ab91ee7925fc678ecdbe16895bd830bf9d7620bfc5239ccf12e109ef0913142da27f77c1be34f839cce350f51552ebcf163a5c92448c0b836b6a9fd8d7e4f
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
@@ -32,14 +32,14 @@ Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (rep
32
32
 
33
33
  ## Nokogiri vs SST boundaries
34
34
 
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 |
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 |
43
43
 
44
44
  Production heuristic scrapers should reuse one `SST::Document` memoized from `parsed_body` (or a shared Document passed in), not re-parse HTML strings.
45
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
@@ -31,8 +31,11 @@ module Html2rss
31
31
 
32
32
  parsed_body.css('head link[rel~="alternate"][href]').any? do |node|
33
33
  href = node['href'].to_s
34
- type = node['type'].to_s.downcase
35
- type.include?('rss') || type.include?('atom') || href.match?(/rss|atom|feed|\.xml/i)
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)
36
39
  end
37
40
  end
38
41
 
@@ -15,27 +15,33 @@ module Html2rss
15
15
  class Schema
16
16
  include Enumerable
17
17
 
18
- # Selector for JSON-LD script tags containing Schema.org objects.
19
- TAG_SELECTOR = 'script[type="application/ld+json"]'
18
+ # Matches a leading schema.org URL prefix on @type values (http or https).
19
+ SCHEMA_ORG_PREFIX_RE = %r{\Ahttps?://schema\.org/}i
20
+
21
+ # Container types that must never be emitted as feed items (walk children only).
22
+ DENIED_CONTAINER_TYPES = Set[
23
+ 'ItemList', 'Blog', 'BreadcrumbList', 'WebPage', 'CollectionPage'
24
+ ].freeze
25
+
26
+ # Canonical Schema.org type names keyed by folded wire forms (case-insensitive lookup).
27
+ CANONICAL_BY_DOWNCASE = begin
28
+ canonical_types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES | DENIED_CONTAINER_TYPES | Set['Product']
29
+ canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
30
+ end.freeze
20
31
 
21
32
  # Pre-compiled regex for supported schema types (short name or schema.org URL; string or array @type).
22
33
  # Allows preceding entries in a JSON @type array (e.g. ["WebPage","NewsArticle"]).
23
34
  SUPPORTED_TYPES_RE = begin
24
35
  types = Thing::SUPPORTED_TYPES | ItemList::SUPPORTED_TYPES
25
36
  type_re = Regexp.union(types.to_a)
26
- %r{"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
37
+ %r{(?i)"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
27
38
  end.freeze
28
39
 
29
- # Matches a leading schema.org URL prefix on @type values (http or https).
30
- SCHEMA_ORG_PREFIX_RE = %r{\Ahttps?://schema\.org/}i
31
-
32
40
  # Prefer these keys when recursively walking unsupported container objects.
33
41
  COLLECTION_KEYS = %i[itemListElement blogPost mainEntity hasPart].freeze
34
42
 
35
- # Container types that must never be emitted as feed items (walk children only).
36
- DENIED_CONTAINER_TYPES = Set[
37
- 'ItemList', 'Blog', 'BreadcrumbList', 'WebPage', 'CollectionPage'
38
- ].freeze
43
+ # Shared empty type set for nil/unsupported wire forms (avoids per-call Set.new).
44
+ EMPTY_TYPES = Set.new.freeze
39
45
 
40
46
  # @return [Symbol] scraper config key
41
47
  def self.options_key = :schema
@@ -44,7 +50,8 @@ module Html2rss
44
50
  # @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
45
51
  # @return [Boolean] whether the page includes supported schema types
46
52
  def articles?(parsed_body)
47
- parsed_body.css(TAG_SELECTOR).any? { |script| supported_schema_type?(script) }
53
+ ::Html2rss::Html::Probe.scripts(parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
54
+ .any? { |script| supported_schema_type?(script) }
48
55
  end
49
56
 
50
57
  # @param script [Nokogiri::XML::Element] schema JSON-LD script tag
@@ -105,14 +112,26 @@ module Html2rss
105
112
  case object
106
113
  when Array
107
114
  object.each_with_object(Set.new) { |item, set| set.merge(normalize_types(item)) }
108
- when String, Symbol
109
- short = object.to_s.sub(SCHEMA_ORG_PREFIX_RE, '')
110
- short.empty? ? Set.new : Set[short]
111
115
  else
112
- Set.new
116
+ name = canonicalize_type(object)
117
+ name ? Set[name] : EMPTY_TYPES
113
118
  end
114
119
  end
115
120
 
121
+ # Canonical short Schema.org type name for a scalar wire form.
122
+ #
123
+ # @param object [String, Symbol, nil] raw `@type` / itemtype token
124
+ # @return [String, nil]
125
+ # @api private
126
+ def canonicalize_type(object)
127
+ return unless object.is_a?(String) || object.is_a?(Symbol)
128
+
129
+ short = object.to_s.sub(SCHEMA_ORG_PREFIX_RE, '')
130
+ return if short.empty?
131
+
132
+ CANONICAL_BY_DOWNCASE.fetch(::Html2rss::Html::Probe.fold(short), short)
133
+ end
134
+
116
135
  private
117
136
 
118
137
  # @param hash [Hash] candidate schema object
@@ -150,15 +169,15 @@ module Html2rss
150
169
  ##
151
170
  # @yield [Hash] Each scraped article_hash
152
171
  # @return [Array<Hash>] the scraped article_hashes
153
- def each(&)
172
+ def each
154
173
  return enum_for(:each) unless block_given?
155
174
 
156
- schema_objects.filter_map do |schema_object|
175
+ schema_objects.each do |schema_object|
157
176
  next unless (klass = self.class.scraper_for_schema_object(schema_object))
158
177
  next unless (results = klass.new(schema_object, url:).call)
159
178
 
160
179
  if results.is_a?(Array)
161
- results.each { |result| yield(result) } # rubocop:disable Style/ExplicitBlockArgument
180
+ results.each { yield(_1) }
162
181
  else
163
182
  yield(results)
164
183
  end
@@ -168,9 +187,8 @@ module Html2rss
168
187
  private
169
188
 
170
189
  def schema_objects
171
- @parsed_body.css(TAG_SELECTOR).flat_map do |tag|
172
- Schema.from(tag)
173
- end
190
+ ::Html2rss::Html::Probe.scripts(@parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
191
+ .flat_map { |tag| Schema.from(tag) }
174
192
  end
175
193
 
176
194
  attr_reader :parsed_body, :url
@@ -118,7 +118,7 @@ module Html2rss
118
118
  # @return [SST::Document, nil]
119
119
  def self.normalize_sst(parsed_body)
120
120
  SST::Normalizer.call(parsed_body)
121
- rescue ArgumentError
121
+ rescue SST::Normalizer::EmptyTree
122
122
  nil
123
123
  end
124
124
 
@@ -145,7 +145,7 @@ module Html2rss
145
145
  # @option opts [Hash] :html scraper toggle and configuration
146
146
  # @option opts [Hash] :sitemap scraper toggle and configuration
147
147
  # @return [Object, nil]
148
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
148
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
149
149
  def self.build_instance(scraper, parsed_body, opts:, url:, request_session: nil, body: nil, document: nil,
150
150
  link_resolver: nil, captured_responses: [])
151
151
  return unless opts.dig(scraper.options_key, :enabled)
@@ -162,7 +162,6 @@ module Html2rss
162
162
  )
163
163
  scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
164
164
  end
165
- # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
166
165
 
167
166
  ##
168
167
  # @param instance [Object]
@@ -64,7 +64,7 @@ module Html2rss
64
64
  def normalize_class(class_names)
65
65
  return '' if class_names.empty?
66
66
 
67
- class_names.sort.join(' ')
67
+ class_names.map { ::Html2rss::Html::Probe.fold(_1) }.sort.join(' ')
68
68
  end
69
69
  module_function :normalize_class
70
70
  private_class_method :normalize_class
@@ -122,7 +122,7 @@ module Html2rss
122
122
  groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
123
123
  end
124
124
 
125
- # rubocop:disable Metrics/MethodLength
125
+ # rubocop:disable-next Metrics/MethodLength
126
126
  def container_of?(nodes_a, nodes_b)
127
127
  return false unless @layout_tags.include?(nodes_b.first.name)
128
128
 
@@ -139,7 +139,6 @@ module Html2rss
139
139
  count > 1
140
140
  end
141
141
  end
142
- # rubocop:enable Metrics/MethodLength
143
142
 
144
143
  def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
145
144
  nodes_a = groups[cls_a]
@@ -37,7 +37,7 @@ module Html2rss
37
37
 
38
38
  private
39
39
 
40
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
40
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
41
41
  def candidate_facts(anchor, container)
42
42
  destination = @link_resolver.destination_facts(anchor)
43
43
  return unless destination
@@ -63,7 +63,6 @@ module Html2rss
63
63
 
64
64
  { anchor:, destination: destination.destination, score: }
65
65
  end
66
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
67
66
 
68
67
  def first_heading(container)
69
68
  (@headings ||= {}.compare_by_identity)[container] ||= container.find(&:heading?)