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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/README.md +57 -0
  5. data/lib/html2rss/auto_source/cleanup.rb +140 -49
  6. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  7. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  8. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  9. data/lib/html2rss/auto_source/scraper.rb +47 -15
  10. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  11. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  12. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  13. data/lib/html2rss/auto_source.rb +41 -10
  14. data/lib/html2rss/capture/README.md +61 -0
  15. data/lib/html2rss/capture.rb +318 -0
  16. data/lib/html2rss/cli.rb +94 -17
  17. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  18. data/lib/html2rss/config/schema.rb +79 -26
  19. data/lib/html2rss/config/selectors_validator.rb +60 -21
  20. data/lib/html2rss/config/validator.rb +46 -32
  21. data/lib/html2rss/config.rb +28 -0
  22. data/lib/html2rss/error.rb +31 -1
  23. data/lib/html2rss/feed_pipeline/README.md +42 -0
  24. data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -15
  25. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  26. data/lib/html2rss/feed_pipeline/strategy_plan.rb +11 -0
  27. data/lib/html2rss/feed_pipeline.rb +40 -15
  28. data/lib/html2rss/hash_util.rb +17 -0
  29. data/lib/html2rss/html/article_extractor/category_extractor.rb +36 -22
  30. data/lib/html2rss/html/article_extractor/date_extractor.rb +5 -3
  31. data/lib/html2rss/html/article_extractor.rb +95 -17
  32. data/lib/html2rss/html/article_rules/category.rb +28 -11
  33. data/lib/html2rss/html/article_rules/date.rb +60 -6
  34. data/lib/html2rss/html/article_rules/description.rb +122 -0
  35. data/lib/html2rss/html/card_walk.rb +42 -0
  36. data/lib/html2rss/html/feed_link.rb +34 -0
  37. data/lib/html2rss/html/navigator.rb +18 -0
  38. data/lib/html2rss/html/sst_article_extractor.rb +147 -31
  39. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  40. data/lib/html2rss/link_destination/path_classifier.rb +49 -32
  41. data/lib/html2rss/mcp/config_argument.rb +42 -0
  42. data/lib/html2rss/mcp/contract.rb +173 -0
  43. data/lib/html2rss/mcp/inspect.rb +241 -0
  44. data/lib/html2rss/mcp/outcome.rb +188 -0
  45. data/lib/html2rss/mcp/server.rb +421 -0
  46. data/lib/html2rss/mcp.rb +21 -0
  47. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  48. data/lib/html2rss/request_service/botasaurus_contract.rb +261 -90
  49. data/lib/html2rss/request_service/botasaurus_strategy.rb +18 -9
  50. data/lib/html2rss/request_service/budget.rb +7 -35
  51. data/lib/html2rss/request_service/compressed_body.rb +109 -0
  52. data/lib/html2rss/request_service/context.rb +0 -6
  53. data/lib/html2rss/request_service/faraday_strategy.rb +48 -3
  54. data/lib/html2rss/request_service/policy.rb +2 -2
  55. data/lib/html2rss/request_service/response.rb +72 -7
  56. data/lib/html2rss/request_service/strategy.rb +1 -2
  57. data/lib/html2rss/request_service.rb +10 -10
  58. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  59. data/lib/html2rss/scoring/engine.rb +49 -5
  60. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  61. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  62. data/lib/html2rss/scoring/score.rb +1 -20
  63. data/lib/html2rss/scoring.rb +0 -25
  64. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  65. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  66. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  67. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  68. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  69. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  70. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  71. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  72. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  73. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  74. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  75. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  76. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  77. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  78. data/lib/html2rss/selectors.rb +2 -1
  79. data/lib/html2rss/status.rb +27 -11
  80. data/lib/html2rss/url.rb +31 -7
  81. data/lib/html2rss/version.rb +1 -1
  82. data/lib/html2rss.rb +62 -7
  83. data/schema/html2rss-config.schema.json +428 -94
  84. metadata +65 -12
  85. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  86. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  87. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  88. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  89. data/lib/html2rss/scoring/anchor_score.rb +0 -34
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b6b0ddc57b5cf372cc9bc6405bb21f7dc6d00aa526e234132974ccc6ef609f7
4
- data.tar.gz: 59a60ef044a99686838b5498f0712a455a6c5a832c27619db0c99ba21742f6a8
3
+ metadata.gz: 286a89b05b23997b8cb5ef8b2930a59bd698bdeb07ba5007b3e30313b91324cd
4
+ data.tar.gz: 709147e30bb631551e8927e496fea5d76ef32373a76834b66adb54cbcdc8df16
5
5
  SHA512:
6
- metadata.gz: 8994aa3b90d791ea8cf75df9b576669fffe505fe0895e207745ab0e591f187ab490382d00a95d49fc1073570f886d6940f27f1c1ca80ba7c57817740f48c4bc4
7
- data.tar.gz: dc62483a0ade2b582fb3839778a3a518a7f1a08e781d2105c0e93c70aaa26313e153a4e30121170b8a59e8472517e6cb8d9958b9c06fa3a862358631f469c18a
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 `browserless` explicitly)
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 explicit `browserless`)
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 'puppeteer-ruby'
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
- 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
- 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
- # @param min_words_title [Integer] minimum word count for title filtering
24
- # @return [Array<Article>] cleaned article list
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
- articles.select!(&:valid?)
29
-
30
- deduplicate_by!(articles, :url)
31
-
32
- keep_only_http_urls!(articles)
33
- reject_different_domain!(articles, url) unless keep_different_domain
34
- keep_only_with_min_words_title!(articles, min_words_title:)
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 articles [Array<Article>] The list of articles to process.
44
- # @param key [Symbol] The key to deduplicate by.
45
- # @return [Array<Article>] the mutated articles array
46
- def deduplicate_by!(articles, key)
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.reject! do |article|
49
- value = article.public_send(key)
50
- value.nil? || seen.key?(value).tap { seen[value] = true }
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
- # Keeps only articles with HTTP or HTTPS URLs.
56
- #
57
- # @param articles [Array<Article>] The list of articles to process.
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
- # Rejects articles that have a URL not on the same domain as the source.
65
- #
66
- # @param articles [Array<Article>] The list of articles to process.
67
- # @param base_url [Html2rss::Url] The source URL to compare against.
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
- # Keeps only articles with a title that is present and has at least `min_words_title` words.
76
- #
77
- # @param articles [Array<Article>] The list of articles to process.
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
- private
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
- article = Thing.new(object, url: base_url || '').call
35
- titleize_list_item_stub!(article) if from_list_item
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