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.
- 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 +12 -9
- 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 +93 -0
- data/lib/html2rss/auto_source/scraper/schema.rb +39 -21
- data/lib/html2rss/auto_source/scraper.rb +6 -4
- 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 +32 -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/auto_source_contract.rb +7 -0
- 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 +64 -18
- data/lib/html2rss/doctor/botasaurus.rb +130 -0
- data/lib/html2rss/feed_builder/rss.rb +1 -2
- data/lib/html2rss/feed_pipeline/README.md +7 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +78 -19
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +1 -0
- data/lib/html2rss/feed_pipeline.rb +48 -21
- data/lib/html2rss/feed_resolution/README.md +52 -0
- data/lib/html2rss/feed_resolution/candidate_generator.rb +139 -0
- data/lib/html2rss/feed_resolution/diag.rb +32 -0
- data/lib/html2rss/feed_resolution/options.rb +31 -0
- data/lib/html2rss/feed_resolution/policy.rb +47 -0
- data/lib/html2rss/feed_resolution/probe.rb +59 -0
- data/lib/html2rss/feed_resolution/scorer.rb +49 -0
- data/lib/html2rss/feed_resolution.rb +268 -0
- 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 -40
- 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 +297 -0
- data/lib/html2rss/recon.rb +252 -0
- data/lib/html2rss/request_service/blocked_surface.rb +1 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +8 -1
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- 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 +27 -4
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +15 -1
- data/lib/html2rss/request_session.rb +4 -2
- data/lib/html2rss/scrape_target.rb +24 -0
- 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 +48 -24
- data/lib/html2rss/surface_category.rb +63 -0
- data/lib/html2rss/syndication/README.md +21 -0
- data/lib/html2rss/syndication/candidate_catalog.rb +26 -0
- data/lib/html2rss/syndication/discovery.rb +215 -0
- data/lib/html2rss/syndication/parser.rb +137 -0
- data/lib/html2rss/syndication.rb +10 -0
- data/lib/html2rss/test/enhance_audit.rb +192 -0
- data/lib/html2rss/test.rb +458 -0
- data/lib/html2rss/url.rb +23 -2
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +157 -35
- data/schema/html2rss-config.schema.json +47 -29
- metadata +34 -18
- data/lib/html2rss/mcp/inspect.rb +0 -241
- 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
|
|
@@ -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
|
|
33
|
-
|
|
|
34
|
-
| `Response#parsed_body`
|
|
35
|
-
| Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection
|
|
36
|
-
| Sitemap detection (CSS/XPath)
|
|
37
|
-
| Selectors path / Sanitize transformers
|
|
38
|
-
| `SST::Normalizer`
|
|
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
|
-
|
|
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
|
|
@@ -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
|