html2rss 0.26.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 +41 -18
- data/html2rss.gemspec +1 -1
- data/lib/html2rss/auto_source/README.md +57 -0
- data/lib/html2rss/auto_source/cleanup.rb +121 -56
- data/lib/html2rss/auto_source/scraper.rb +13 -0
- data/lib/html2rss/auto_source.rb +34 -8
- data/lib/html2rss/capture/README.md +61 -0
- data/lib/html2rss/capture.rb +120 -117
- data/lib/html2rss/cli.rb +35 -17
- data/lib/html2rss/config/schema.rb +12 -0
- data/lib/html2rss/config/validator.rb +31 -8
- data/lib/html2rss/config.rb +28 -0
- data/lib/html2rss/error.rb +24 -6
- data/lib/html2rss/feed_pipeline/README.md +42 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
- data/lib/html2rss/feed_pipeline.rb +30 -12
- 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 +119 -32
- data/lib/html2rss/link_destination/path_classifier.rb +49 -35
- 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 +253 -409
- data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
- data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
- data/lib/html2rss/request_service/compressed_body.rb +109 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +57 -6
- data/lib/html2rss/request_service.rb +6 -1
- data/lib/html2rss/selectors.rb +2 -1
- data/lib/html2rss/status.rb +27 -11
- data/lib/html2rss/url.rb +20 -0
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +30 -6
- data/schema/html2rss-config.schema.json +26 -15
- metadata +15 -4
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
|
@@ -37,14 +37,14 @@ Config -> Request -> Extraction -> Processing -> Building -> Output
|
|
|
37
37
|
|
|
38
38
|
## Capture API
|
|
39
39
|
|
|
40
|
-
The `Html2rss.capture` method analyzes any URL and produces a reusable feed config hash with
|
|
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
41
|
|
|
42
42
|
```ruby
|
|
43
43
|
config = Html2rss.capture('https://example.com/articles')
|
|
44
|
-
File.write('my-feed.yml',
|
|
44
|
+
File.write('my-feed.yml', Html2rss::Config.to_yaml(config))
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
The CLI alias `html2rss capture` prints the generated config as YAML to stdout. See [`
|
|
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
48
|
|
|
49
49
|
## MCP Server
|
|
50
50
|
|
|
@@ -58,19 +58,41 @@ html2rss mcp
|
|
|
58
58
|
html2rss mcp --transport http --port 8080
|
|
59
59
|
```
|
|
60
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
|
+
|
|
61
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.
|
|
62
64
|
|
|
63
|
-
**Strategy note:** MCP
|
|
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.
|
|
64
86
|
|
|
65
87
|
### Tools
|
|
66
88
|
|
|
67
|
-
| Name | When to use
|
|
68
|
-
| ----------------- |
|
|
69
|
-
| `scrape_url` | One-shot articles now (
|
|
70
|
-
| `inspect_url` |
|
|
71
|
-
| `capture_config` |
|
|
72
|
-
| `validate_config` | Schema-check a config
|
|
73
|
-
| `apply_config` |
|
|
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` |
|
|
74
96
|
|
|
75
97
|
### Resources
|
|
76
98
|
|
|
@@ -78,14 +100,15 @@ HTTP transport needs `rack`, `rackup`, and `webrick` (declared gem dependencies)
|
|
|
78
100
|
| ----------------------- | --------------------------------------------------------------- |
|
|
79
101
|
| `html2rss://schema` | Full JSON Schema for feed configurations |
|
|
80
102
|
| `html2rss://extractors` | Registered extractor **names** (options live in schema `$defs`) |
|
|
81
|
-
| `html2rss://strategies` |
|
|
103
|
+
| `html2rss://strategies` | Published MCP strategies (`auto`, `faraday`, `botasaurus`) |
|
|
104
|
+
| `html2rss://runtime` | `botasaurus_configured` boolean (never the scraper URL) |
|
|
82
105
|
|
|
83
106
|
### Prompts
|
|
84
107
|
|
|
85
|
-
| Name | Description
|
|
86
|
-
| --------------------- |
|
|
87
|
-
| `scrape-webpage` |
|
|
88
|
-
| `capture-feed-config` |
|
|
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 |
|
|
89
112
|
|
|
90
113
|
The MCP module (`Html2rss::MCP`) lazy-loads the `mcp` gem — no cost when the server is not running.
|
|
91
114
|
|
|
@@ -103,11 +126,11 @@ Set `BOTASAURUS_SCRAPER_URL` to `http://127.0.0.1:4010` and use strategy `botasa
|
|
|
103
126
|
|
|
104
127
|
| Strategy | Description |
|
|
105
128
|
| ------------ | ----------------------------------------------------------------------------- |
|
|
106
|
-
| `auto` | Tries `faraday`, falls back to `botasaurus` (default in gem/CLI
|
|
129
|
+
| `auto` | Tries `faraday`, falls back to `botasaurus` (default in gem/CLI/MCP scrape) |
|
|
107
130
|
| `faraday` | Plain HTTP requests via Faraday |
|
|
108
131
|
| `botasaurus` | Puppeteer-backed scraping for JavaScript pages |
|
|
109
132
|
|
|
110
|
-
|
|
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.
|
|
111
134
|
|
|
112
135
|
## License
|
|
113
136
|
|
data/html2rss.gemspec
CHANGED
|
@@ -39,7 +39,7 @@ 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.
|
|
42
|
+
spec.add_dependency 'mcp', '~> 1.2'
|
|
43
43
|
spec.add_dependency 'mime-types', '> 3.0'
|
|
44
44
|
spec.add_dependency 'nokogiri', '>= 1.10', '< 2.0'
|
|
45
45
|
spec.add_dependency 'rack', '~> 3.0'
|
|
@@ -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,7 +6,8 @@ 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
13
|
keep_different_domain: false
|
|
@@ -18,97 +19,161 @@ module Html2rss
|
|
|
18
19
|
# Allowed URL schemes for article filtering.
|
|
19
20
|
VALID_SCHEMES = %w[http https].to_set.freeze
|
|
20
21
|
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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)
|
|
44
56
|
|
|
45
57
|
class << self
|
|
46
58
|
# @param articles [Array<Article>] extracted article candidates
|
|
47
59
|
# @param url [Html2rss::Url] feed source URL used for same-host filtering
|
|
48
60
|
# @param keep_different_domain [Boolean] whether to keep off-domain entries
|
|
49
|
-
# @return [
|
|
50
|
-
def call(articles, url:, keep_different_domain: DEFAULT_CONFIG.fetch(:keep_different_domain))
|
|
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
|
|
51
63
|
Log.debug "Cleanup: start with #{articles.size} articles"
|
|
64
|
+
tallies = Hash.new(0)
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
reject_low_quality_titles!(articles)
|
|
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)
|
|
60
73
|
|
|
61
74
|
Log.debug "Cleanup: end with #{articles.size} articles"
|
|
62
|
-
articles
|
|
75
|
+
Result.new(articles:, drop_tallies: tallies.freeze)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# First matching junk reason for a title, or nil when the title is acceptable.
|
|
79
|
+
#
|
|
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
|
|
63
89
|
end
|
|
64
90
|
|
|
65
91
|
private
|
|
66
92
|
|
|
67
|
-
def
|
|
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)
|
|
68
98
|
seen = {}
|
|
69
|
-
articles
|
|
99
|
+
tally_reject!(articles, tallies, 'duplicate_url') do |article|
|
|
70
100
|
identity = url_identity(article.url)
|
|
71
101
|
identity.nil? || seen.key?(identity).tap { seen[identity] = true }
|
|
72
102
|
end
|
|
73
103
|
end
|
|
74
104
|
|
|
75
|
-
def keep_only_http_urls!(articles)
|
|
76
|
-
articles
|
|
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
|
|
77
109
|
end
|
|
78
110
|
|
|
79
|
-
def reject_self_links!(articles, base_url)
|
|
111
|
+
def reject_self_links!(articles, base_url, tallies)
|
|
80
112
|
source_identity = url_identity(base_url)
|
|
81
|
-
articles
|
|
113
|
+
tally_reject!(articles, tallies, 'self_link') do |article|
|
|
114
|
+
url_identity(article.url) == source_identity
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
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
|
|
122
|
+
end
|
|
82
123
|
end
|
|
83
124
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
87
140
|
end
|
|
88
141
|
|
|
89
142
|
# Keep missing titles (nil provenance). Drop present junk/unnatural titles —
|
|
90
143
|
# blanking them would hide bad extraction as "unknown" and inflate empty items.
|
|
91
|
-
def reject_low_quality_titles!(articles)
|
|
92
|
-
articles.
|
|
144
|
+
def reject_low_quality_titles!(articles, tallies) # rubocop:disable Metrics/MethodLength -- junk vs word-count reasons
|
|
145
|
+
articles.reject! do |article|
|
|
93
146
|
title = article.title
|
|
94
|
-
title.nil?
|
|
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
|
|
95
159
|
end
|
|
96
160
|
end
|
|
97
161
|
|
|
98
|
-
def
|
|
99
|
-
|
|
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
|
|
100
169
|
end
|
|
101
170
|
|
|
102
|
-
def
|
|
103
|
-
|
|
171
|
+
def url_identity(url)
|
|
172
|
+
url&.without_fragment&.to_s
|
|
104
173
|
end
|
|
105
174
|
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
SLUG_TITLE.match?(stripped) ||
|
|
109
|
-
DATE_PREFIX_TITLE.match?(stripped) ||
|
|
110
|
-
TITLEIZED_PATH_TITLE.match?(stripped) ||
|
|
111
|
-
TEMPLATE_TITLE.match?(stripped)
|
|
175
|
+
def normalize_title(title)
|
|
176
|
+
title.to_s.strip.gsub(/\s+/, ' ')
|
|
112
177
|
end
|
|
113
178
|
|
|
114
179
|
def word_count_at_least?(str, min_words)
|
|
@@ -16,6 +16,8 @@ module Html2rss
|
|
|
16
16
|
APP_SHELL_ROOT_SELECTORS = '#app, #root, #__next, [data-reactroot], [ng-app], [id*="app-shell"]'
|
|
17
17
|
# Maximum anchors tolerated before app-shell detection is considered unlikely.
|
|
18
18
|
APP_SHELL_MAX_ANCHORS = 2
|
|
19
|
+
# Minimum same-page anchors suggesting a high-entropy homepage/hub surface.
|
|
20
|
+
HIGH_ENTROPY_MIN_ANCHORS = 20
|
|
19
21
|
# Maximum visible text length tolerated for app-shell classification.
|
|
20
22
|
APP_SHELL_MAX_VISIBLE_TEXT_LENGTH = 220
|
|
21
23
|
|
|
@@ -49,6 +51,9 @@ module Html2rss
|
|
|
49
51
|
app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
|
|
50
52
|
'server-rendered article HTML). Configure BOTASAURUS_SCRAPER_URL or target a direct ' \
|
|
51
53
|
'listing/update URL instead of a homepage or shell entrypoint.',
|
|
54
|
+
high_entropy_surface: 'high-entropy surface (e.g. news homepage) with few or no durable ' \
|
|
55
|
+
'article destinations after admission. Target a section, topic, or ' \
|
|
56
|
+
'listing/update URL instead of the site homepage.',
|
|
52
57
|
unsupported_surface: 'unsupported extraction surface for auto mode. ' \
|
|
53
58
|
'Try a direct listing/changelog/category URL, ' \
|
|
54
59
|
'or use explicit selectors in a feed config.'
|
|
@@ -197,6 +202,7 @@ module Html2rss
|
|
|
197
202
|
def self.classify_no_scraper_surface(parsed_body, body: nil)
|
|
198
203
|
return :blocked_surface if blocked_surface?(parsed_body, body:)
|
|
199
204
|
return :app_shell if app_shell_surface?(parsed_body)
|
|
205
|
+
return :high_entropy_surface if high_entropy_surface?(parsed_body)
|
|
200
206
|
|
|
201
207
|
:unsupported_surface
|
|
202
208
|
end
|
|
@@ -216,6 +222,13 @@ module Html2rss
|
|
|
216
222
|
end
|
|
217
223
|
private_class_method :app_shell_surface?
|
|
218
224
|
|
|
225
|
+
def self.high_entropy_surface?(parsed_body)
|
|
226
|
+
return false if parsed_body.nil?
|
|
227
|
+
|
|
228
|
+
parsed_body.css('body a[href]').size >= HIGH_ENTROPY_MIN_ANCHORS
|
|
229
|
+
end
|
|
230
|
+
private_class_method :high_entropy_surface?
|
|
231
|
+
|
|
219
232
|
def self.sparse_anchor_surface?(parsed_body)
|
|
220
233
|
parsed_body.css('body a[href]').size <= APP_SHELL_MAX_ANCHORS
|
|
221
234
|
end
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -15,8 +15,8 @@ module Html2rss
|
|
|
15
15
|
# @see Html2rss::AutoSource::Scraper::Schema
|
|
16
16
|
# @see Html2rss::AutoSource::Scraper::SemanticHtml
|
|
17
17
|
# @see Html2rss::AutoSource::Scraper::Html
|
|
18
|
-
#
|
|
19
|
-
class AutoSource
|
|
18
|
+
# {include:file:lib/html2rss/auto_source/README.md}
|
|
19
|
+
class AutoSource # rubocop:disable Metrics/ClassLength -- defaults + tiered extract stay on the contributor entry type
|
|
20
20
|
# Default max articles to keep (also the short-circuit floor across scraper tiers).
|
|
21
21
|
DEFAULT_LIMIT = 25
|
|
22
22
|
|
|
@@ -107,15 +107,26 @@ module Html2rss
|
|
|
107
107
|
# Tiers: in-page structured → follow-up IO → SemanticHtml → Html.
|
|
108
108
|
# SST is built only when a heuristic tier runs. Later tiers are skipped once
|
|
109
109
|
# +limit+ articles with url+title remain after Cleanup; the result is capped to +limit+.
|
|
110
|
+
# Html is skipped when earlier tiers already admitted at least one clean article
|
|
111
|
+
# below +limit+ — quality over padding with weaker heuristic junk.
|
|
110
112
|
#
|
|
111
113
|
# @return [Array<Html2rss::Article>] extracted articles
|
|
112
114
|
def articles
|
|
113
115
|
@articles ||= extract_articles
|
|
114
116
|
rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
|
|
115
117
|
Log.warn "#{self.class}: no scraper matched #{url} (#{error.message})"
|
|
118
|
+
@admission_drops = {}.freeze
|
|
116
119
|
[]
|
|
117
120
|
end
|
|
118
121
|
|
|
122
|
+
##
|
|
123
|
+
# Reason → count tallies from the final {Cleanup} pass (empty until {#articles} runs).
|
|
124
|
+
#
|
|
125
|
+
# @return [Hash{String => Integer}]
|
|
126
|
+
def admission_drops
|
|
127
|
+
@admission_drops || {}
|
|
128
|
+
end
|
|
129
|
+
|
|
119
130
|
private
|
|
120
131
|
|
|
121
132
|
attr_reader :url, :parsed_body, :body, :request_session, :captured_responses
|
|
@@ -130,6 +141,7 @@ module Html2rss
|
|
|
130
141
|
|
|
131
142
|
Scraper::SCRAPER_TIERS.each do |tier|
|
|
132
143
|
break if enough_articles?(articles)
|
|
144
|
+
break if skip_html_padding?(tier, articles)
|
|
133
145
|
|
|
134
146
|
if Scraper.heuristic_tier?(tier)
|
|
135
147
|
document ||= Scraper.normalize_sst(parsed_body)
|
|
@@ -160,16 +172,31 @@ module Html2rss
|
|
|
160
172
|
|
|
161
173
|
raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
|
|
162
174
|
|
|
163
|
-
Cleanup.call(articles, url:, **cleanup_options)
|
|
175
|
+
result = Cleanup.call(articles, url:, **cleanup_options)
|
|
176
|
+
@admission_drops = result.drop_tallies
|
|
177
|
+
result.articles.first(article_limit)
|
|
164
178
|
end
|
|
165
179
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
166
180
|
|
|
167
181
|
def enough_articles?(articles)
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
return false if articles.size < article_limit
|
|
183
|
+
|
|
184
|
+
admitted_articles(articles).size >= article_limit
|
|
185
|
+
end
|
|
170
186
|
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
# Prefer fewer clean items over refilling +limit+ from the Html heuristic tier.
|
|
188
|
+
def skip_html_padding?(tier, articles)
|
|
189
|
+
html_only_tier?(tier) && admitted_articles(articles).any?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def html_only_tier?(tier)
|
|
193
|
+
tier == [Scraper::Html]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def admitted_articles(articles)
|
|
197
|
+
Cleanup.call(articles.dup, url:, **cleanup_options).articles.select do |article|
|
|
198
|
+
article.url && !article.title.to_s.empty?
|
|
199
|
+
end
|
|
173
200
|
end
|
|
174
201
|
|
|
175
202
|
def article_limit
|
|
@@ -193,5 +220,4 @@ module Html2rss
|
|
|
193
220
|
@opts.fetch(:cleanup, {})
|
|
194
221
|
end
|
|
195
222
|
end
|
|
196
|
-
# rubocop:enable Metrics/ClassLength
|
|
197
223
|
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Capture
|
|
2
|
+
|
|
3
|
+
`Html2rss.capture` (and CLI `html2rss capture`) analyzes a URL through the feed pipeline and produces a reusable config with **items selector + `enhance: true` only** — no title/url/description attribute-selector soup. At feed-build time, `enhance: true` fills missing article fields via `Html::ArticleExtractor` on each matched item.
|
|
4
|
+
|
|
5
|
+
## When to use it
|
|
6
|
+
|
|
7
|
+
Point `capture` at a listing URL when you want a first-draft YAML config instead of hand-writing selectors. Treat the output as a draft: selector quality depends on page structure.
|
|
8
|
+
|
|
9
|
+
## Gem API
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
config = Html2rss.capture('https://example.com/articles')
|
|
13
|
+
|
|
14
|
+
# {
|
|
15
|
+
# channel: { url: "...", title: "...", time_zone: "UTC" },
|
|
16
|
+
# selectors: {
|
|
17
|
+
# items: { selector: "div.post", enhance: true }
|
|
18
|
+
# }
|
|
19
|
+
# }
|
|
20
|
+
|
|
21
|
+
File.write('my-feed.yml', YAML.dump(Html2rss::HashUtil.deep_stringify_keys(config)))
|
|
22
|
+
feed = Html2rss.feed(config)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`Capture.build` returns a `CaptureResult` with quality meta (`has_selectors`, `segment_strategy`, `admission_drops`, `selected_strategy`). `Html2rss.capture` returns only the config hash.
|
|
26
|
+
|
|
27
|
+
### Options
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
Html2rss.capture('https://spa-site.com', strategy: :botasaurus)
|
|
31
|
+
Html2rss.capture('https://example.com', items_selector: '.article-card')
|
|
32
|
+
Html2rss.capture('https://example.com', strategy: :local_file, local_file_path: './page.html')
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`strategy: :auto` uses the same AutoFallback chain as scrape (`faraday` → `botasaurus`). When AutoFallback selects a concrete strategy (or you pin one), Capture **stamps** `strategy:` into the emitted config so later `Html2rss.feed(config)` replays the same transport.
|
|
36
|
+
|
|
37
|
+
## CLI
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
html2rss capture https://example.com/articles
|
|
41
|
+
html2rss capture https://example.com --strategy botasaurus
|
|
42
|
+
html2rss capture https://example.com/articles > my-feed.yml
|
|
43
|
+
html2rss capture https://example.com --input ./page.html
|
|
44
|
+
html2rss capture https://example.com --explain # quality JSON on stderr; YAML on stdout
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
1. **Request** — `FeedPipeline` (AutoFallback when `:auto`)
|
|
50
|
+
2. **Discover** — AutoSource extracts admitted articles
|
|
51
|
+
3. **Segment** — try SST Segmenter strategies `:list` → `:cluster` → `:semantic`
|
|
52
|
+
4. **Gate** — emit items selector only when ≥ `MIN_SELECTOR_MATCHES` (2) articles match
|
|
53
|
+
5. **Assemble** — `{ items: { selector:, enhance: true } }` plus channel
|
|
54
|
+
|
|
55
|
+
## Constraints
|
|
56
|
+
|
|
57
|
+
- Selector quality depends on page structure; treat output as a first draft.
|
|
58
|
+
- When the quality gate fails, selectors are omitted (`has_selectors: false`) rather than inventing attribute selectors.
|
|
59
|
+
- Botasaurus hops need `BOTASAURUS_SCRAPER_URL`.
|
|
60
|
+
|
|
61
|
+
See also {Html2rss::AutoSource} for article discovery and {Html2rss::FeedPipeline} for the `:auto` request chain.
|