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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -18
  3. data/html2rss.gemspec +1 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +121 -56
  6. data/lib/html2rss/auto_source/scraper.rb +13 -0
  7. data/lib/html2rss/auto_source.rb +34 -8
  8. data/lib/html2rss/capture/README.md +61 -0
  9. data/lib/html2rss/capture.rb +120 -117
  10. data/lib/html2rss/cli.rb +35 -17
  11. data/lib/html2rss/config/schema.rb +12 -0
  12. data/lib/html2rss/config/validator.rb +31 -8
  13. data/lib/html2rss/config.rb +28 -0
  14. data/lib/html2rss/error.rb +24 -6
  15. data/lib/html2rss/feed_pipeline/README.md +42 -0
  16. data/lib/html2rss/feed_pipeline/auto_fallback.rb +22 -9
  17. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  18. data/lib/html2rss/feed_pipeline.rb +30 -12
  19. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  20. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  21. data/lib/html2rss/html/article_extractor.rb +95 -17
  22. data/lib/html2rss/html/article_rules/category.rb +28 -11
  23. data/lib/html2rss/html/article_rules/date.rb +60 -6
  24. data/lib/html2rss/html/article_rules/description.rb +122 -0
  25. data/lib/html2rss/html/card_walk.rb +42 -0
  26. data/lib/html2rss/html/feed_link.rb +34 -0
  27. data/lib/html2rss/html/navigator.rb +18 -0
  28. data/lib/html2rss/html/sst_article_extractor.rb +119 -32
  29. data/lib/html2rss/link_destination/path_classifier.rb +49 -35
  30. data/lib/html2rss/mcp/config_argument.rb +42 -0
  31. data/lib/html2rss/mcp/contract.rb +173 -0
  32. data/lib/html2rss/mcp/inspect.rb +241 -0
  33. data/lib/html2rss/mcp/outcome.rb +188 -0
  34. data/lib/html2rss/mcp/server.rb +253 -409
  35. data/lib/html2rss/request_service/botasaurus_contract.rb +193 -102
  36. data/lib/html2rss/request_service/botasaurus_strategy.rb +15 -8
  37. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  38. data/lib/html2rss/request_service/faraday_strategy.rb +3 -1
  39. data/lib/html2rss/request_service/policy.rb +1 -1
  40. data/lib/html2rss/request_service/response.rb +57 -6
  41. data/lib/html2rss/request_service.rb +6 -1
  42. data/lib/html2rss/selectors.rb +2 -1
  43. data/lib/html2rss/status.rb +27 -11
  44. data/lib/html2rss/url.rb +20 -0
  45. data/lib/html2rss/version.rb +1 -1
  46. data/lib/html2rss.rb +30 -6
  47. data/schema/html2rss-config.schema.json +26 -15
  48. metadata +15 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2df4782641361435095b1adbc805a0b5f8bbf47637450a271b212e0905e025a1
4
- data.tar.gz: 38542dd16fd74c1983be1d20cfc49b64508ce70eeb501f0a312e0b9777c59f07
3
+ metadata.gz: 286a89b05b23997b8cb5ef8b2930a59bd698bdeb07ba5007b3e30313b91324cd
4
+ data.tar.gz: 709147e30bb631551e8927e496fea5d76ef32373a76834b66adb54cbcdc8df16
5
5
  SHA512:
6
- metadata.gz: e72f53ac552c7a0c65917d9d736a42e318360f205b7f532d845f89c0ea6bd219c09da0c1784596027566af8946aa2a9de937d21b39899a8838bd9fd70dd163ee
7
- data.tar.gz: 69b705d89b11160fe0c392fb2045e61db8dc25d63a7e81af4d91201fce0face5e6fe3496b4b154af9d71eb41a6969491897d48162df382cd30facc30b3ab8bdc
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 derived CSS selectors. Use it to speed up writing feed configuration files.
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', YAML.dump(Html2rss::HashUtil.deep_stringify_keys(config)))
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 [`docs/capture.md`](docs/capture.md) for detailed documentation.
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 tool `strategy: "auto"` collapses to `faraday` (no FeedPipeline botasaurus fallback). If results are empty or JS-gated, retry with `strategy: "botasaurus"` and `BOTASAURUS_SCRAPER_URL` set.
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 (no saved config) |
70
- | `inspect_url` | Diagnose weak scrape/capture (scrapers/SST/segments) |
71
- | `capture_config` | Derive a durable feed config (+ quality `_meta`) |
72
- | `validate_config` | Schema-check a config before apply (`isError` on failure) |
73
- | `apply_config` | Run a validated config RSS XML |
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` | Registered request strategy names |
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` | Guided scrape inspect/retry with botasaurus if needed |
88
- | `capture-feed-config` | Guided capture → validate → optional apply |
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 FeedPipeline) |
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
- MCP tools intentionally collapse `auto` `faraday` (see MCP section above). 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.
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.0'
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
- class Cleanup
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
- # Credit-agency-only or photo-credit titles (not headlines).
22
- CREDIT_TITLE = %r{
23
- \A(?:AFP|Getty(?:\s+Images)?|Reuters|dpa|Imagn)
24
- (?:\s*/\s*(?:AFP|Getty(?:\s+Images)?|Reuters|dpa|Imagn))*\z
25
- |
26
- \A(?:Image|Photo|Credit)\s*[:|]?\s*
27
- (?:AFP|Getty(?:\s+Images)?|Reuters|dpa|Imagn)\b
28
- }ix
29
-
30
- # Dotted / methode CMS tokens mistaken for titles.
31
- CMS_TOKEN_TITLE = /\A(?:lucy\.\w[\w.-]*|methode[-.][\w.-]+)\z/i
32
-
33
- # Raw URL slug / token clusters (hyphen or underscore, no natural phrasing).
34
- SLUG_TITLE = /\A\p{Alnum}+(?:[-_]\p{Alnum}+){2,}\z/
35
-
36
- # Date-prefix path tokens, raw or titleized ("2026 08 16 …", "2026-08-16-…").
37
- DATE_PREFIX_TITLE = /\A\d{4}(?:[\s.-]+\d{1,2}){2}\b/
38
-
39
- # Titleized path ending in a long numeric CMS id.
40
- TITLEIZED_PATH_TITLE = /\A(?:\d+|\p{Lu}[\p{L}\p{M}]*)(?:\s+(?:\d+|\p{Lu}[\p{L}\p{M}]*))*\s+\d{6,}\z/
41
-
42
- # Template / placeholder tokens mistaken for titles.
43
- TEMPLATE_TITLE = /(\{\{[^}]+\}\}|%\{\w+\})/
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 [Array<Article>] cleaned article list
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
- articles.select!(&:valid?)
54
-
55
- deduplicate_by_url!(articles)
56
- keep_only_http_urls!(articles)
57
- reject_self_links!(articles, url)
58
- reject_different_domain!(articles, url) unless keep_different_domain
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 deduplicate_by_url!(articles)
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.reject! do |article|
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.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
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.reject! { |article| url_identity(article.url) == source_identity }
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
- def reject_different_domain!(articles, base_url)
85
- base_host = base_url.host
86
- articles.select! { |article| article.url&.host == base_host }
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.select! do |article|
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? || (word_count_at_least?(title, MIN_WORDS) && !junk_title?(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
95
159
  end
96
160
  end
97
161
 
98
- def url_identity(url)
99
- url&.without_fragment&.to_s
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 junk_title?(title)
103
- CREDIT_TITLE.match?(title) || CMS_TOKEN_TITLE.match?(title) || unnatural_title?(title)
171
+ def url_identity(url)
172
+ url&.without_fragment&.to_s
104
173
  end
105
174
 
106
- def unnatural_title?(title)
107
- stripped = title.to_s.strip
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
@@ -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
- # rubocop:disable Metrics/ClassLength -- defaults + tiered extract stay on the contributor entry type
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).first(article_limit)
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
- threshold = article_limit
169
- return false if articles.size < threshold
182
+ return false if articles.size < article_limit
183
+
184
+ admitted_articles(articles).size >= article_limit
185
+ end
170
186
 
171
- cleaned = Cleanup.call(articles.dup, url:, **cleanup_options)
172
- cleaned.count { |article| article.url && !article.title.to_s.empty? } >= threshold
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.