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.
- checksums.yaml +4 -4
- data/README.md +82 -25
- data/html2rss.gemspec +0 -1
- data/lib/html2rss/article.rb +1 -2
- data/lib/html2rss/auto_source/README.md +8 -8
- data/lib/html2rss/auto_source/cleanup.rb +92 -1
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +3 -5
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +15 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -6
- data/lib/html2rss/auto_source/scraper/native_feed.rb +5 -2
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/cluster.rb +2 -3
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +1 -2
- data/lib/html2rss/auto_source/segmenter.rb +1 -2
- data/lib/html2rss/auto_source.rb +25 -11
- data/lib/html2rss/batch.rb +169 -0
- data/lib/html2rss/capture/README.md +15 -6
- data/lib/html2rss/capture.rb +158 -15
- data/lib/html2rss/channel.rb +1 -2
- data/lib/html2rss/cli/probe_view.rb +66 -0
- data/lib/html2rss/cli/render.rb +124 -0
- data/lib/html2rss/cli/validate.rb +91 -0
- data/lib/html2rss/cli.rb +318 -193
- data/lib/html2rss/config/request_headers.rb +4 -14
- data/lib/html2rss/config/schema.rb +7 -5
- data/lib/html2rss/config/validator.rb +1 -0
- data/lib/html2rss/config.rb +59 -22
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +5 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +29 -10
- data/lib/html2rss/feed_pipeline.rb +27 -16
- data/lib/html2rss/feed_resolution/README.md +11 -11
- data/lib/html2rss/feed_resolution.rb +6 -12
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +24 -14
- data/lib/html2rss/html/article_extractor.rb +4 -9
- data/lib/html2rss/html/feed_link.rb +4 -2
- data/lib/html2rss/html/navigator.rb +4 -39
- data/lib/html2rss/html/probe.rb +82 -0
- data/lib/html2rss/html/sst_article_extractor.rb +1 -2
- data/lib/html2rss/link_destination/noise_policy.rb +1 -2
- data/lib/html2rss/link_destination/path_classifier.rb +20 -19
- data/lib/html2rss/mcp/README.md +80 -0
- data/lib/html2rss/mcp/contract.rb +135 -13
- data/lib/html2rss/mcp/outcome/playbook.rb +122 -0
- data/lib/html2rss/mcp/outcome.rb +112 -42
- data/lib/html2rss/mcp/runtime.rb +45 -0
- data/lib/html2rss/mcp/server/tools.rb +277 -0
- data/lib/html2rss/mcp/server.rb +91 -232
- data/lib/html2rss/mcp.rb +3 -0
- data/lib/html2rss/page_recon/README.md +69 -0
- data/lib/html2rss/page_recon/diagnostics.rb +211 -0
- data/lib/html2rss/page_recon.rb +56 -2
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +11 -7
- data/lib/html2rss/request_service/policy.rb +1 -2
- data/lib/html2rss/request_service/response.rb +3 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +2 -0
- data/lib/html2rss/request_session.rb +1 -2
- data/lib/html2rss/scrape_target.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +2 -4
- data/lib/html2rss/selectors/post_processors.rb +0 -1
- data/lib/html2rss/selectors.rb +33 -10
- data/lib/html2rss/sst/attrs.rb +1 -2
- data/lib/html2rss/sst/normalizer.rb +49 -11
- data/lib/html2rss/status.rb +3 -6
- data/lib/html2rss/syndication/README.md +6 -6
- data/lib/html2rss/syndication/discovery.rb +2 -4
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +1 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +9 -29
- metadata +17 -18
- data/lib/html2rss/mcp/inspect.rb +0 -138
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32c090eccd93210c9631778ff50ec7d32d36c7eccaa711cdd1a0dd5c3ea53616
|
|
4
|
+
data.tar.gz: 93e4d2f253a019d7de585c8acaaa8d06143accb4f3a43a4f8d13f9c96d838390
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
89
|
+
`Html2rss.capture` returns a `Capture::CaptureResult`. Use `result.yaml` or `result.config`.
|
|
41
90
|
|
|
42
91
|
```ruby
|
|
43
|
-
|
|
44
|
-
File.write('my-feed.yml',
|
|
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 `
|
|
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:** `
|
|
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
|
|
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
|
|
90
|
-
|
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
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`
|
|
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 `
|
|
111
|
-
| `capture-feed-config` | Capture YAML →
|
|
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
|
-
`
|
|
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'
|
data/lib/html2rss/article.rb
CHANGED
|
@@ -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
|
|
36
|
-
|
|
|
37
|
-
| `Response#parsed_body`
|
|
38
|
-
| Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection
|
|
39
|
-
| Sitemap detection (CSS/XPath)
|
|
40
|
-
| Selectors path / Sanitize transformers
|
|
41
|
-
| `SST::Normalizer`
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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>]
|
|
47
|
-
def
|
|
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
|
-
|
|
50
|
-
|
|
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.
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
#
|
|
19
|
-
|
|
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
|
-
#
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
172
|
-
|
|
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
|
|
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?)
|