html2rss 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +99 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +140 -49
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +47 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +41 -10
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +318 -0
- data/lib/html2rss/cli.rb +94 -17
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +79 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +46 -32
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +31 -1
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +40 -15
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
- data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
- data/lib/html2rss/html/article_extractor.rb +95 -17
- data/lib/html2rss/html/article_rules/category.rb +28 -11
- data/lib/html2rss/html/article_rules/date.rb +60 -6
- data/lib/html2rss/html/article_rules/description.rb +122 -0
- data/lib/html2rss/html/card_walk.rb +42 -0
- data/lib/html2rss/html/feed_link.rb +34 -0
- data/lib/html2rss/html/navigator.rb +18 -0
- data/lib/html2rss/html/sst_article_extractor.rb +147 -31
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +49 -32
- data/lib/html2rss/mcp/config_argument.rb +42 -0
- data/lib/html2rss/mcp/contract.rb +173 -0
- data/lib/html2rss/mcp/inspect.rb +241 -0
- data/lib/html2rss/mcp/outcome.rb +188 -0
- data/lib/html2rss/mcp/server.rb +421 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
- data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
- data/lib/html2rss/request_service/policy.rb +2 -2
- data/lib/html2rss/request_service/response.rb +72 -7
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +10 -10
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +31 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +62 -7
- data/schema/html2rss-config.schema.json +428 -94
- metadata +65 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- data/lib/html2rss/scoring/anchor_score.rb +0 -34
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 286a89b05b23997b8cb5ef8b2930a59bd698bdeb07ba5007b3e30313b91324cd
|
|
4
|
+
data.tar.gz: 709147e30bb631551e8927e496fea5d76ef32373a76834b66adb54cbcdc8df16
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9cabc478aa9d449697e2d8e99a5b91f6e2071d498eac34bdbc7828fffa06d2b02395d4f8e02853f0553ea97bdc35ca5a59c0b33fcc53b82ef809a247674826c
|
|
7
|
+
data.tar.gz: 32d343c2f3008917dbdbbb0859c29e710a89559e2ab079dcd3ba76672450ddaeb63572c8c71e0a1f5f7b327be7df667d785d2f7fb1069389e12a63093d83e3c9
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Most people looking for a first working feed should start with `html2rss-web`, r
|
|
|
13
13
|
Detailed usage guides, reference docs, and the feed directory live on the project website:
|
|
14
14
|
|
|
15
15
|
- [Ruby gem documentation](https://html2rss.github.io/ruby-gem)
|
|
16
|
-
- [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto` = `faraday` → `botasaurus`; pin
|
|
16
|
+
- [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto` = `faraday` → `botasaurus`; or pin concrete strategies)
|
|
17
17
|
- [Selectors & pagination](https://html2rss.github.io/ruby-gem/reference/selectors#paginated-feeds)
|
|
18
18
|
- [Web application](https://html2rss.github.io/web-application)
|
|
19
19
|
- [Feed directory](https://html2rss.github.io/feed-directory)
|
|
@@ -26,7 +26,7 @@ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new
|
|
|
26
26
|
## Architecture
|
|
27
27
|
|
|
28
28
|
1. **Config** — loads and validates configuration (YAML/hash); schema via `html2rss schema` / `schema/html2rss-config.schema.json`
|
|
29
|
-
2. **RequestService** — fetches pages (`faraday`, `botasaurus`, or
|
|
29
|
+
2. **RequestService** — fetches pages (`faraday`, `botasaurus`, or `local_file`)
|
|
30
30
|
3. **Selectors** — extracts content via CSS selectors with extractors/post-processors
|
|
31
31
|
4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns)
|
|
32
32
|
5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1)
|
|
@@ -35,6 +35,103 @@ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new
|
|
|
35
35
|
Config -> Request -> Extraction -> Processing -> Building -> Output
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Capture API
|
|
39
|
+
|
|
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.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
config = Html2rss.capture('https://example.com/articles')
|
|
44
|
+
File.write('my-feed.yml', Html2rss::Config.to_yaml(config))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
## MCP Server
|
|
50
|
+
|
|
51
|
+
html2rss ships with an [MCP](https://modelcontextprotocol.io/) server that exposes gem capabilities as AI-consumable tools, resources, and prompts:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Start with stdio transport (default; for Cursor/Claude Desktop)
|
|
55
|
+
html2rss mcp
|
|
56
|
+
|
|
57
|
+
# Start with HTTP transport (binds 127.0.0.1 only — local use)
|
|
58
|
+
html2rss mcp --transport http --port 8080
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
stdio uses stdout for JSON-RPC, so the daemon logs to **stderr**. It defaults to `LOG_LEVEL=info` (the gem library default stays `warn`) so a foreground watcher sees the start banner, each tool call, and pipeline fallbacks. Use `LOG_LEVEL=debug` for more detail or `LOG_LEVEL=warn` to quiet it.
|
|
62
|
+
|
|
63
|
+
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
|
+
|
|
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.
|
|
66
|
+
|
|
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).
|
|
68
|
+
|
|
69
|
+
Cursor / Claude Desktop `mcp.json` must put Botasaurus on the **MCP process** (not only your shell):
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"html2rss": {
|
|
75
|
+
"command": "mise",
|
|
76
|
+
"args": ["exec", "--", "html2rss", "mcp"],
|
|
77
|
+
"env": {
|
|
78
|
+
"BOTASAURUS_SCRAPER_URL": "http://127.0.0.1:4010"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
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.
|
|
86
|
+
|
|
87
|
+
### Tools
|
|
88
|
+
|
|
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` |
|
|
96
|
+
|
|
97
|
+
### Resources
|
|
98
|
+
|
|
99
|
+
| URI | Description |
|
|
100
|
+
| ----------------------- | --------------------------------------------------------------- |
|
|
101
|
+
| `html2rss://schema` | Full JSON Schema for feed configurations |
|
|
102
|
+
| `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) |
|
|
105
|
+
|
|
106
|
+
### Prompts
|
|
107
|
+
|
|
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 |
|
|
112
|
+
|
|
113
|
+
The MCP module (`Html2rss::MCP`) lazy-loads the `mcp` gem — no cost when the server is not running.
|
|
114
|
+
|
|
115
|
+
## Botasaurus scrape API (Docker)
|
|
116
|
+
|
|
117
|
+
Start the Botasaurus scrape API for JavaScript-rendered pages (this compose file is **not** the MCP server):
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
docker compose -f docker-compose.botasaurus.yml up -d
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Set `BOTASAURUS_SCRAPER_URL` to `http://127.0.0.1:4010` and use strategy `botasaurus` in MCP tools, Capture, or the CLI.
|
|
124
|
+
|
|
125
|
+
## Request Strategies
|
|
126
|
+
|
|
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 |
|
|
132
|
+
|
|
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.
|
|
134
|
+
|
|
38
135
|
## License
|
|
39
136
|
|
|
40
137
|
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
data/html2rss.gemspec
CHANGED
|
@@ -39,14 +39,17 @@ Gem::Specification.new do |spec|
|
|
|
39
39
|
spec.add_dependency 'faraday-follow_redirects'
|
|
40
40
|
spec.add_dependency 'faraday-gzip', '~> 3'
|
|
41
41
|
spec.add_dependency 'kramdown'
|
|
42
|
+
spec.add_dependency 'mcp', '~> 1.2'
|
|
42
43
|
spec.add_dependency 'mime-types', '> 3.0'
|
|
43
44
|
spec.add_dependency 'nokogiri', '>= 1.10', '< 2.0'
|
|
44
|
-
spec.add_dependency '
|
|
45
|
+
spec.add_dependency 'rack', '~> 3.0'
|
|
46
|
+
spec.add_dependency 'rackup', '~> 2.0'
|
|
45
47
|
spec.add_dependency 'regexp_parser'
|
|
46
48
|
spec.add_dependency 'reverse_markdown', '~> 3.0'
|
|
47
49
|
spec.add_dependency 'rss'
|
|
48
50
|
spec.add_dependency 'sanitize'
|
|
49
51
|
spec.add_dependency 'thor'
|
|
50
52
|
spec.add_dependency 'tzinfo'
|
|
53
|
+
spec.add_dependency 'webrick', '~> 1.9'
|
|
51
54
|
spec.add_dependency 'zeitwerk'
|
|
52
55
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# AutoSource
|
|
2
|
+
|
|
3
|
+
How html2rss builds feed items when a config has no (or incomplete) CSS selectors.
|
|
4
|
+
|
|
5
|
+
## What and when
|
|
6
|
+
|
|
7
|
+
`Html2rss.auto_source` / `auto_json_feed` (and any feed config with `auto_source:`) fetch a page once, then run an ordered set of scrapers against that response. Use it when you want “guess articles from this URL” without hand-writing selectors. Prefer explicit `selectors:` when you already know the list markup — that path stays on Nokogiri + `Html::Navigator`.
|
|
8
|
+
|
|
9
|
+
Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` → per-scraper extraction → `Cleanup`.
|
|
10
|
+
|
|
11
|
+
## Live flow
|
|
12
|
+
|
|
13
|
+
1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML).
|
|
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
|
+
|
|
16
|
+
1. In-page structured: Schema, Microdata, Microformats2, JsonState, XhrArticles
|
|
17
|
+
2. Follow-up IO: WordPress API, Sitemap, MetaOembed
|
|
18
|
+
3. Heuristic: SemanticHtml
|
|
19
|
+
4. Heuristic: Html (skipped when earlier tiers already admitted at least one clean article)
|
|
20
|
+
|
|
21
|
+
3. **Structured / API scrapers** — Schema, Microdata, MF2, JSON state, XHR JSON, oEmbed, WordPress REST, and Sitemap work on Nokogiri CSS/XPath or JSON/XML parsers. They do not use SST.
|
|
22
|
+
4. **Heuristic scrapers** — `SemanticHtml` and `Html` normalize once into an `SST::Document`, then:
|
|
23
|
+
|
|
24
|
+
`SST::Normalizer` → `AutoSource::Segmenter` → `Scoring::Engine` → extractor / article materialization.
|
|
25
|
+
|
|
26
|
+
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
|
+
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
|
+
|
|
30
|
+
## Nokogiri vs SST boundaries
|
|
31
|
+
|
|
32
|
+
| Surface | Owns DOM |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `Response#parsed_body` | Single HTML parse for the page |
|
|
35
|
+
| Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection | Nokogiri |
|
|
36
|
+
| Sitemap detection (CSS/XPath) | Nokogiri; URL list parsing uses raw `response.body` (XML string) |
|
|
37
|
+
| Selectors path / Sanitize transformers | Nokogiri (unchanged) |
|
|
38
|
+
| `SST::Normalizer` | Sole Nokogiri consumer on the heuristic auto-source path |
|
|
39
|
+
| Segmenter, Scoring, `Html::SstArticleExtractor`, heuristic chrome (`SST::Tags` / `SST::Text`) | SST only |
|
|
40
|
+
|
|
41
|
+
Production heuristic scrapers should reuse one `SST::Document` memoized from `parsed_body` (or a shared Document passed in), not re-parse HTML strings.
|
|
42
|
+
|
|
43
|
+
## Constraints
|
|
44
|
+
|
|
45
|
+
- **`SST::Normalizer::MAX_NODES` (5_000)** — Beyond this, normalization degrades to a semantic-tag-only keep set and logs a warning.
|
|
46
|
+
- **Top-K** — `Scoring::Engine::TOP_K` (99) caps ranked segments materialized into articles; list strategy also budgets `use_top_selectors`.
|
|
47
|
+
- **Typed stages** — Pipeline stages take `SST::Document` / `Segment` / `RankedSegment`, not ad-hoc Hash bags. Internal scraper APIs may change; the public gem surface is `lib/html2rss.rb`.
|
|
48
|
+
|
|
49
|
+
## Non-goals
|
|
50
|
+
|
|
51
|
+
- Replacing Selectors, Schema, Microdata, MF2, JsonState, or XhrArticles with SST.
|
|
52
|
+
- A second HTML parser beside Nokogiri.
|
|
53
|
+
- Dual Response payload (Nokogiri + SST always).
|
|
54
|
+
- App-shell classification on SST.
|
|
55
|
+
- Rewriting Sanitize transformers off Nokogiri.
|
|
56
|
+
|
|
57
|
+
See also {file:CONTEXT CONTEXT.md} for module ownership (chrome, scoring, clustering), {Html2rss::FeedPipeline} for the request-strategy fallback chain (unrelated to article scraping), and {Html2rss::Capture} to turn a listing URL into a durable items-selector config.
|
|
@@ -6,84 +6,175 @@ module Html2rss
|
|
|
6
6
|
# Cleanup is responsible for cleaning up the extracted articles.
|
|
7
7
|
# :reek:MissingSafeMethod { enabled: false }
|
|
8
8
|
# It applies various strategies to filter and refine the article list.
|
|
9
|
-
|
|
9
|
+
# Sole producer of admission drop tallies for {Html2rss::Status}.
|
|
10
|
+
class Cleanup # rubocop:disable Metrics/ClassLength -- reject steps + tallies stay co-located
|
|
10
11
|
# Default cleanup behavior for auto-sourced article lists.
|
|
11
12
|
DEFAULT_CONFIG = {
|
|
12
|
-
keep_different_domain: false
|
|
13
|
-
min_words_title: 3
|
|
13
|
+
keep_different_domain: false
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
|
+
# Minimum alphanumeric word count for present titles.
|
|
17
|
+
MIN_WORDS = 3
|
|
18
|
+
|
|
16
19
|
# Allowed URL schemes for article filtering.
|
|
17
20
|
VALID_SCHEMES = %w[http https].to_set.freeze
|
|
18
21
|
|
|
22
|
+
# Photo-credit agencies (single list → junk title regexes).
|
|
23
|
+
CREDIT_AGENCIES = [
|
|
24
|
+
'AFP',
|
|
25
|
+
'Getty(?:\s+Images)?',
|
|
26
|
+
'Reuters',
|
|
27
|
+
'dpa',
|
|
28
|
+
'Imagn'
|
|
29
|
+
].freeze
|
|
30
|
+
private_constant :CREDIT_AGENCIES
|
|
31
|
+
|
|
32
|
+
AGENCY_ALT = CREDIT_AGENCIES.join('|').freeze
|
|
33
|
+
private_constant :AGENCY_ALT
|
|
34
|
+
|
|
35
|
+
# Sole denylist for extracted titles. Order: higher-frequency reasons first.
|
|
36
|
+
JUNK_TITLE_RULES = [
|
|
37
|
+
[:credit, %r{\A(?:#{AGENCY_ALT})(?:\s*/\s*(?:#{AGENCY_ALT}))*\z}ix],
|
|
38
|
+
[:credit, /\A(?:Image|Photo|Credit)\s*[:|]?\s*(?:#{AGENCY_ALT})\b/ix],
|
|
39
|
+
[:credit, /\ACourtesy\b.+\b(?:via|pool|Handout|#{AGENCY_ALT})\b/ix],
|
|
40
|
+
[:credit, /\bHandout\b.+\b(?:#{AGENCY_ALT})\b|\b(?:#{AGENCY_ALT})\b.+\bHandout\b/ix],
|
|
41
|
+
[:credit, /\A(?:Live\s+Updates|Analysis)\s*[•·.:-]?\s*.*\b(?:#{AGENCY_ALT})\b/ix],
|
|
42
|
+
[:cms_token, /\A(?:lucy\.\w[\w.-]*|methode[-.][\w.-]+)\z/i],
|
|
43
|
+
[:json_blob, /\A\{\s*["']?text["']?\s*:/],
|
|
44
|
+
[:slug, /\A\p{Alnum}+(?:[-_]\p{Alnum}+){2,}\z/],
|
|
45
|
+
[:date_prefix, /\A\d{4}(?:[\s.-]+\d{1,2}){2}\b/],
|
|
46
|
+
[:titleized_path, /\A(?:\d+|\p{Lu}[\p{L}\p{M}]*)(?:\s+(?:\d+|\p{Lu}[\p{L}\p{M}]*))*\s+\d{6,}\z/],
|
|
47
|
+
[:video_chrome, /\AClipped\s+From\s+Video\b/i],
|
|
48
|
+
[:video_chrome, /\AVideo\s*[•·]/i],
|
|
49
|
+
[:template, /\ACreated\s+from\s+Template\s+ID\b/i],
|
|
50
|
+
[:template, /(\{\{[^}]+\}\}|%\{\w+\})/]
|
|
51
|
+
].freeze
|
|
52
|
+
private_constant :JUNK_TITLE_RULES
|
|
53
|
+
|
|
54
|
+
# Admitted articles plus reason → count tallies for drops.
|
|
55
|
+
Result = Data.define(:articles, :drop_tallies)
|
|
56
|
+
|
|
19
57
|
class << self
|
|
20
58
|
# @param articles [Array<Article>] extracted article candidates
|
|
21
59
|
# @param url [Html2rss::Url] feed source URL used for same-host filtering
|
|
22
60
|
# @param keep_different_domain [Boolean] whether to keep off-domain entries
|
|
23
|
-
# @
|
|
24
|
-
#
|
|
25
|
-
def call(articles, url:, keep_different_domain:, min_words_title:)
|
|
61
|
+
# @return [Result] cleaned articles and frozen drop tallies
|
|
62
|
+
def call(articles, url:, keep_different_domain: DEFAULT_CONFIG.fetch(:keep_different_domain)) # rubocop:disable Metrics/MethodLength -- ordered reject pipeline
|
|
26
63
|
Log.debug "Cleanup: start with #{articles.size} articles"
|
|
64
|
+
tallies = Hash.new(0)
|
|
27
65
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
66
|
+
reject_invalid!(articles, tallies)
|
|
67
|
+
deduplicate_by_url!(articles, tallies)
|
|
68
|
+
keep_only_http_urls!(articles, tallies)
|
|
69
|
+
reject_self_links!(articles, url, tallies)
|
|
70
|
+
reject_different_domain!(articles, url, tallies) unless keep_different_domain
|
|
71
|
+
reject_excluded_destinations!(articles, tallies)
|
|
72
|
+
reject_low_quality_titles!(articles, tallies)
|
|
35
73
|
|
|
36
74
|
Log.debug "Cleanup: end with #{articles.size} articles"
|
|
37
|
-
articles
|
|
75
|
+
Result.new(articles:, drop_tallies: tallies.freeze)
|
|
38
76
|
end
|
|
39
77
|
|
|
40
|
-
|
|
41
|
-
# Deduplicates articles by a given key.
|
|
78
|
+
# First matching junk reason for a title, or nil when the title is acceptable.
|
|
42
79
|
#
|
|
43
|
-
# @param
|
|
44
|
-
# @
|
|
45
|
-
|
|
46
|
-
|
|
80
|
+
# @param title [String, nil] candidate title text
|
|
81
|
+
# @return [Symbol, nil]
|
|
82
|
+
def junk_reason(title)
|
|
83
|
+
return if title.nil?
|
|
84
|
+
|
|
85
|
+
normalized = normalize_title(title)
|
|
86
|
+
return if normalized.empty?
|
|
87
|
+
|
|
88
|
+
JUNK_TITLE_RULES.find { |_, pattern| pattern.match?(normalized) }&.first
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def reject_invalid!(articles, tallies)
|
|
94
|
+
tally_reject!(articles, tallies, 'invalid') { |article| !article.valid? }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def deduplicate_by_url!(articles, tallies)
|
|
47
98
|
seen = {}
|
|
48
|
-
articles
|
|
49
|
-
|
|
50
|
-
|
|
99
|
+
tally_reject!(articles, tallies, 'duplicate_url') do |article|
|
|
100
|
+
identity = url_identity(article.url)
|
|
101
|
+
identity.nil? || seen.key?(identity).tap { seen[identity] = true }
|
|
51
102
|
end
|
|
52
103
|
end
|
|
53
104
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# @return [Array<Article>] the mutated articles array
|
|
59
|
-
def keep_only_http_urls!(articles)
|
|
60
|
-
articles.select! { |article| VALID_SCHEMES.include?(article.url&.scheme) }
|
|
105
|
+
def keep_only_http_urls!(articles, tallies)
|
|
106
|
+
tally_reject!(articles, tallies, 'bad_scheme') do |article|
|
|
107
|
+
!VALID_SCHEMES.include?(article.url&.scheme)
|
|
108
|
+
end
|
|
61
109
|
end
|
|
62
110
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# @return [Array<Article>] the mutated articles array
|
|
69
|
-
def reject_different_domain!(articles, base_url)
|
|
70
|
-
base_host = base_url.host
|
|
71
|
-
articles.select! { |article| article.url&.host == base_host }
|
|
111
|
+
def reject_self_links!(articles, base_url, tallies)
|
|
112
|
+
source_identity = url_identity(base_url)
|
|
113
|
+
tally_reject!(articles, tallies, 'self_link') do |article|
|
|
114
|
+
url_identity(article.url) == source_identity
|
|
115
|
+
end
|
|
72
116
|
end
|
|
73
117
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# @param min_words_title [Integer] The minimum number of words in the title.
|
|
79
|
-
# @return [Array<Article>] the mutated articles array
|
|
80
|
-
def keep_only_with_min_words_title!(articles, min_words_title:)
|
|
81
|
-
articles.select! do |article|
|
|
82
|
-
article.title ? word_count_at_least?(article.title, min_words_title) : true
|
|
118
|
+
def reject_different_domain!(articles, base_url, tallies)
|
|
119
|
+
base_domain = base_url.domain
|
|
120
|
+
tally_reject!(articles, tallies, 'different_domain') do |article|
|
|
121
|
+
article.url&.domain != base_domain
|
|
83
122
|
end
|
|
84
123
|
end
|
|
85
124
|
|
|
86
|
-
|
|
125
|
+
# Hard-exclude non-article destination classes (commerce/affiliate/utility chrome).
|
|
126
|
+
# PathClassifier owns route facts; Cleanup owns feed-item admission.
|
|
127
|
+
def reject_excluded_destinations!(articles, tallies)
|
|
128
|
+
tally_reject!(articles, tallies, 'excluded_destination') do |article|
|
|
129
|
+
excluded_destination?(article.url)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def excluded_destination?(url)
|
|
134
|
+
return false unless url
|
|
135
|
+
|
|
136
|
+
facts = LinkDestination::DestinationFacts.build(url)
|
|
137
|
+
return true if facts.high_confidence_junk_path || facts.high_confidence_utility_destination
|
|
138
|
+
|
|
139
|
+
facts.utility_path && !facts.content_path && !facts.strong_post_suffix
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Keep missing titles (nil provenance). Drop present junk/unnatural titles —
|
|
143
|
+
# blanking them would hide bad extraction as "unknown" and inflate empty items.
|
|
144
|
+
def reject_low_quality_titles!(articles, tallies) # rubocop:disable Metrics/MethodLength -- junk vs word-count reasons
|
|
145
|
+
articles.reject! do |article|
|
|
146
|
+
title = article.title
|
|
147
|
+
next false if title.nil?
|
|
148
|
+
|
|
149
|
+
reason = junk_reason(title)
|
|
150
|
+
if reason
|
|
151
|
+
tallies[reason.to_s] += 1
|
|
152
|
+
next true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
next false if word_count_at_least?(title, MIN_WORDS)
|
|
156
|
+
|
|
157
|
+
tallies['low_word_count'] += 1
|
|
158
|
+
true
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def tally_reject!(articles, tallies, reason)
|
|
163
|
+
articles.reject! do |article|
|
|
164
|
+
next false unless yield(article)
|
|
165
|
+
|
|
166
|
+
tallies[reason] += 1
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def url_identity(url)
|
|
172
|
+
url&.without_fragment&.to_s
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def normalize_title(title)
|
|
176
|
+
title.to_s.strip.gsub(/\s+/, ' ')
|
|
177
|
+
end
|
|
87
178
|
|
|
88
179
|
def word_count_at_least?(str, min_words)
|
|
89
180
|
count = 0
|
|
@@ -27,6 +27,36 @@ module Html2rss
|
|
|
27
27
|
def json_documents(parsed_body)
|
|
28
28
|
DocumentScanner.json_documents(parsed_body)
|
|
29
29
|
end
|
|
30
|
+
|
|
31
|
+
# Walks a JSON document tree and yields normalized article hashes.
|
|
32
|
+
# Shared with {XhrArticles} so XHR-captured JSON reuses one discovery algorithm.
|
|
33
|
+
#
|
|
34
|
+
# @param document [Hash, Array, Object] parsed JSON document node
|
|
35
|
+
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
36
|
+
# @yield [Hash{Symbol => Object}, nil] normalized article hash
|
|
37
|
+
# @return [void]
|
|
38
|
+
def discover_articles(document, base_url:, &block)
|
|
39
|
+
case document
|
|
40
|
+
when Array then handle_array(document, base_url:, &block)
|
|
41
|
+
when Hash then document.each_value { discover_articles(_1, base_url:, &block) if traversable?(_1) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def handle_array(array, base_url:, &block)
|
|
48
|
+
if CandidateDetector.array_of_articles?(array)
|
|
49
|
+
array.each do |entry|
|
|
50
|
+
yield(ArticleNormalizer.normalise(entry, base_url:))
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
array.each { discover_articles(_1, base_url:, &block) if traversable?(_1) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def traversable?(value)
|
|
58
|
+
value.is_a?(Array) || value.is_a?(Hash)
|
|
59
|
+
end
|
|
30
60
|
end
|
|
31
61
|
|
|
32
62
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
@@ -51,7 +81,7 @@ module Html2rss
|
|
|
51
81
|
return enum_for(:each) unless block_given?
|
|
52
82
|
|
|
53
83
|
json_documents.each do |document|
|
|
54
|
-
discover_articles(document) do |article|
|
|
84
|
+
self.class.discover_articles(document, base_url: url) do |article|
|
|
55
85
|
yield article if article
|
|
56
86
|
end
|
|
57
87
|
end
|
|
@@ -64,27 +94,6 @@ module Html2rss
|
|
|
64
94
|
def json_documents
|
|
65
95
|
self.class.json_documents(parsed_body)
|
|
66
96
|
end
|
|
67
|
-
|
|
68
|
-
def discover_articles(document, &block)
|
|
69
|
-
case document
|
|
70
|
-
when Array then handle_array(document, &block)
|
|
71
|
-
when Hash then document.each_value { discover_articles(_1, &block) if traversable?(_1) }
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def handle_array(array, &block)
|
|
76
|
-
if CandidateDetector.array_of_articles?(array)
|
|
77
|
-
array.each do |entry|
|
|
78
|
-
yield(ArticleNormalizer.normalise(entry, base_url: url))
|
|
79
|
-
end
|
|
80
|
-
else
|
|
81
|
-
array.each { discover_articles(_1, &block) if traversable?(_1) }
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def traversable?(value)
|
|
86
|
-
value.is_a?(Array) || value.is_a?(Hash)
|
|
87
|
-
end
|
|
88
97
|
end
|
|
89
98
|
end
|
|
90
99
|
end
|
|
@@ -31,9 +31,8 @@ module Html2rss
|
|
|
31
31
|
return unless object.is_a?(Hash)
|
|
32
32
|
return unless emit?(object, from_list_item:)
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
article
|
|
34
|
+
# Leave empty titles empty — do not invent from URL path (Cleanup allows nil).
|
|
35
|
+
Thing.new(object, url: base_url || '').call
|
|
37
36
|
end
|
|
38
37
|
|
|
39
38
|
# @param element [Object] raw list entry
|
|
@@ -56,17 +55,6 @@ module Html2rss
|
|
|
56
55
|
|
|
57
56
|
Schema.normalize_types(object[:@type]).intersect?(Thing::SUPPORTED_TYPES)
|
|
58
57
|
end
|
|
59
|
-
|
|
60
|
-
# URL-only ListItem stubs historically used a titleized path as title.
|
|
61
|
-
#
|
|
62
|
-
# @param article [Hash] scraped article hash
|
|
63
|
-
# @return [void]
|
|
64
|
-
def titleize_list_item_stub!(article)
|
|
65
|
-
return unless article[:title].to_s.empty?
|
|
66
|
-
return unless (article_url = article[:url])
|
|
67
|
-
|
|
68
|
-
article[:title] = article_url.titleized
|
|
69
|
-
end
|
|
70
58
|
end
|
|
71
59
|
end
|
|
72
60
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
class AutoSource
|
|
7
|
+
module Scraper
|
|
8
|
+
# Extracts articles from JSON XHR/fetch responses captured during a
|
|
9
|
+
# browser-tier scrape (see RequestService::Response#captured_responses).
|
|
10
|
+
class XhrArticles
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
# @return [Symbol] scraper config key
|
|
14
|
+
def self.options_key = :xhr_articles
|
|
15
|
+
|
|
16
|
+
# @param _opts [Hash] unused scraper options
|
|
17
|
+
# @return [Integer] zero — no additional HTTP requests
|
|
18
|
+
def self.request_slots(_opts = {}) = 0
|
|
19
|
+
|
|
20
|
+
# Not detectable from HTML alone; instance {#extractable?} uses captures.
|
|
21
|
+
#
|
|
22
|
+
# @param _parsed_body [Nokogiri::HTML::Document, nil]
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def self.articles?(_parsed_body) = false
|
|
25
|
+
|
|
26
|
+
# @param _parsed_body [Nokogiri::HTML::Document, nil] unused HTML document
|
|
27
|
+
# @param url [String, Html2rss::Url] page URL used to resolve relative links
|
|
28
|
+
# @param captured_responses [Array<Hash>] JSON bodies from Response#captured_responses
|
|
29
|
+
# @param _opts [Hash] scraper-specific options
|
|
30
|
+
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
31
|
+
def initialize(_parsed_body, url:, captured_responses: [], **_opts)
|
|
32
|
+
@url = url
|
|
33
|
+
@captured_responses = captured_responses
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] true when any captured body contains article-like arrays
|
|
37
|
+
def extractable?
|
|
38
|
+
parsed_bodies.any? { |doc| JsonState::CandidateDetector.candidate_array?(doc) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @yield [Hash{Symbol => Object}] normalized article hash
|
|
42
|
+
# @return [Enumerator, void] article enumerator when no block is given
|
|
43
|
+
def each
|
|
44
|
+
return enum_for(:each) unless block_given?
|
|
45
|
+
|
|
46
|
+
parsed_bodies.each do |doc|
|
|
47
|
+
JsonState.discover_articles(doc, base_url: @url) { |article| yield article if article }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parsed_bodies
|
|
54
|
+
@parsed_bodies ||= @captured_responses.filter_map { |captured| parse(captured) }
|
|
55
|
+
.select { |doc| JsonState::CandidateDetector.candidate_array?(doc) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse(captured)
|
|
59
|
+
body = captured[:body] || captured['body']
|
|
60
|
+
return unless body.is_a?(String)
|
|
61
|
+
|
|
62
|
+
JSON.parse(body, symbolize_names: true)
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|