html2rss 0.25.0 → 0.26.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
  6. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  7. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  8. data/lib/html2rss/auto_source/scraper.rb +34 -15
  9. data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
  10. data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
  11. data/lib/html2rss/auto_source/segmenter.rb +30 -1
  12. data/lib/html2rss/auto_source.rb +7 -2
  13. data/lib/html2rss/capture.rb +315 -0
  14. data/lib/html2rss/cli.rb +64 -5
  15. data/lib/html2rss/config/auto_source_contract.rb +3 -1
  16. data/lib/html2rss/config/schema.rb +67 -26
  17. data/lib/html2rss/config/selectors_validator.rb +60 -21
  18. data/lib/html2rss/config/validator.rb +18 -27
  19. data/lib/html2rss/error.rb +15 -3
  20. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  21. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  22. data/lib/html2rss/feed_pipeline.rb +10 -3
  23. data/lib/html2rss/hash_util.rb +17 -0
  24. data/lib/html2rss/html/sst_article_extractor.rb +36 -7
  25. data/lib/html2rss/link_destination/noise_policy.rb +7 -18
  26. data/lib/html2rss/link_destination/path_classifier.rb +3 -0
  27. data/lib/html2rss/mcp/server.rb +577 -0
  28. data/lib/html2rss/mcp.rb +21 -0
  29. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  30. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  31. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  32. data/lib/html2rss/request_service/budget.rb +7 -35
  33. data/lib/html2rss/request_service/context.rb +0 -6
  34. data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
  35. data/lib/html2rss/request_service/policy.rb +1 -1
  36. data/lib/html2rss/request_service/response.rb +15 -1
  37. data/lib/html2rss/request_service/strategy.rb +1 -2
  38. data/lib/html2rss/request_service.rb +4 -9
  39. data/lib/html2rss/scoring/container_assessor.rb +9 -15
  40. data/lib/html2rss/scoring/engine.rb +49 -5
  41. data/lib/html2rss/scoring/link_resolver.rb +12 -0
  42. data/lib/html2rss/scoring/ranked_segment.rb +0 -12
  43. data/lib/html2rss/scoring/score.rb +1 -20
  44. data/lib/html2rss/scoring.rb +0 -25
  45. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  46. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  47. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  48. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  49. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  50. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  51. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  52. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  53. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  54. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  55. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
  56. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  57. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  58. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  59. data/lib/html2rss/url.rb +11 -7
  60. data/lib/html2rss/version.rb +1 -1
  61. data/lib/html2rss.rb +32 -1
  62. data/schema/html2rss-config.schema.json +408 -85
  63. metadata +54 -12
  64. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  65. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  66. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  67. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
  68. 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: 2df4782641361435095b1adbc805a0b5f8bbf47637450a271b212e0905e025a1
4
+ data.tar.gz: 38542dd16fd74c1983be1d20cfc49b64508ce70eeb501f0a312e0b9777c59f07
5
5
  SHA512:
6
- metadata.gz: 8994aa3b90d791ea8cf75df9b576669fffe505fe0895e207745ab0e591f187ab490382d00a95d49fc1073570f886d6940f27f1c1ca80ba7c57817740f48c4bc4
7
- data.tar.gz: dc62483a0ade2b582fb3839778a3a518a7f1a08e781d2105c0e93c70aaa26313e153a4e30121170b8a59e8472517e6cb8d9958b9c06fa3a862358631f469c18a
6
+ metadata.gz: e72f53ac552c7a0c65917d9d736a42e318360f205b7f532d845f89c0ea6bd219c09da0c1784596027566af8946aa2a9de937d21b39899a8838bd9fd70dd163ee
7
+ data.tar.gz: 69b705d89b11160fe0c392fb2045e61db8dc25d63a7e81af4d91201fce0face5e6fe3496b4b154af9d71eb41a6969491897d48162df382cd30facc30b3ab8bdc
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,80 @@ 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 derived CSS selectors. 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', YAML.dump(Html2rss::HashUtil.deep_stringify_keys(config)))
45
+ ```
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.
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
+ 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
+
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.
64
+
65
+ ### Tools
66
+
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 |
74
+
75
+ ### Resources
76
+
77
+ | URI | Description |
78
+ | ----------------------- | --------------------------------------------------------------- |
79
+ | `html2rss://schema` | Full JSON Schema for feed configurations |
80
+ | `html2rss://extractors` | Registered extractor **names** (options live in schema `$defs`) |
81
+ | `html2rss://strategies` | Registered request strategy names |
82
+
83
+ ### Prompts
84
+
85
+ | Name | Description |
86
+ | --------------------- | ------------------------------------------------------- |
87
+ | `scrape-webpage` | Guided scrape → inspect/retry with botasaurus if needed |
88
+ | `capture-feed-config` | Guided capture → validate → optional apply |
89
+
90
+ The MCP module (`Html2rss::MCP`) lazy-loads the `mcp` gem — no cost when the server is not running.
91
+
92
+ ## Botasaurus scrape API (Docker)
93
+
94
+ Start the Botasaurus scrape API for JavaScript-rendered pages (this compose file is **not** the MCP server):
95
+
96
+ ```bash
97
+ docker compose -f docker-compose.botasaurus.yml up -d
98
+ ```
99
+
100
+ Set `BOTASAURUS_SCRAPER_URL` to `http://127.0.0.1:4010` and use strategy `botasaurus` in MCP tools, Capture, or the CLI.
101
+
102
+ ## Request Strategies
103
+
104
+ | Strategy | Description |
105
+ | ------------ | ----------------------------------------------------------------------------- |
106
+ | `auto` | Tries `faraday`, falls back to `botasaurus` (default in gem/CLI FeedPipeline) |
107
+ | `faraday` | Plain HTTP requests via Faraday |
108
+ | `botasaurus` | Puppeteer-backed scraping for JavaScript pages |
109
+
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.
111
+
38
112
  ## License
39
113
 
40
114
  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.0'
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
@@ -9,81 +9,107 @@ module Html2rss
9
9
  class Cleanup
10
10
  # Default cleanup behavior for auto-sourced article lists.
11
11
  DEFAULT_CONFIG = {
12
- keep_different_domain: false,
13
- min_words_title: 3
12
+ keep_different_domain: false
14
13
  }.freeze
15
14
 
15
+ # Minimum alphanumeric word count for present titles.
16
+ MIN_WORDS = 3
17
+
16
18
  # Allowed URL schemes for article filtering.
17
19
  VALID_SCHEMES = %w[http https].to_set.freeze
18
20
 
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+\})/
44
+
19
45
  class << self
20
46
  # @param articles [Array<Article>] extracted article candidates
21
47
  # @param url [Html2rss::Url] feed source URL used for same-host filtering
22
48
  # @param keep_different_domain [Boolean] whether to keep off-domain entries
23
- # @param min_words_title [Integer] minimum word count for title filtering
24
49
  # @return [Array<Article>] cleaned article list
25
- def call(articles, url:, keep_different_domain:, min_words_title:)
50
+ def call(articles, url:, keep_different_domain: DEFAULT_CONFIG.fetch(:keep_different_domain))
26
51
  Log.debug "Cleanup: start with #{articles.size} articles"
27
52
 
28
53
  articles.select!(&:valid?)
29
54
 
30
- deduplicate_by!(articles, :url)
31
-
55
+ deduplicate_by_url!(articles)
32
56
  keep_only_http_urls!(articles)
57
+ reject_self_links!(articles, url)
33
58
  reject_different_domain!(articles, url) unless keep_different_domain
34
- keep_only_with_min_words_title!(articles, min_words_title:)
59
+ reject_low_quality_titles!(articles)
35
60
 
36
61
  Log.debug "Cleanup: end with #{articles.size} articles"
37
62
  articles
38
63
  end
39
64
 
40
- ##
41
- # Deduplicates articles by a given key.
42
- #
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)
65
+ private
66
+
67
+ def deduplicate_by_url!(articles)
47
68
  seen = {}
48
69
  articles.reject! do |article|
49
- value = article.public_send(key)
50
- value.nil? || seen.key?(value).tap { seen[value] = true }
70
+ identity = url_identity(article.url)
71
+ identity.nil? || seen.key?(identity).tap { seen[identity] = true }
51
72
  end
52
73
  end
53
74
 
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
75
  def keep_only_http_urls!(articles)
60
76
  articles.select! { |article| VALID_SCHEMES.include?(article.url&.scheme) }
61
77
  end
62
78
 
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
79
+ def reject_self_links!(articles, base_url)
80
+ source_identity = url_identity(base_url)
81
+ articles.reject! { |article| url_identity(article.url) == source_identity }
82
+ end
83
+
69
84
  def reject_different_domain!(articles, base_url)
70
85
  base_host = base_url.host
71
86
  articles.select! { |article| article.url&.host == base_host }
72
87
  end
73
88
 
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:)
89
+ # Keep missing titles (nil provenance). Drop present junk/unnatural titles —
90
+ # blanking them would hide bad extraction as "unknown" and inflate empty items.
91
+ def reject_low_quality_titles!(articles)
81
92
  articles.select! do |article|
82
- article.title ? word_count_at_least?(article.title, min_words_title) : true
93
+ title = article.title
94
+ title.nil? || (word_count_at_least?(title, MIN_WORDS) && !junk_title?(title))
83
95
  end
84
96
  end
85
97
 
86
- private
98
+ def url_identity(url)
99
+ url&.without_fragment&.to_s
100
+ end
101
+
102
+ def junk_title?(title)
103
+ CREDIT_TITLE.match?(title) || CMS_TOKEN_TITLE.match?(title) || unnatural_title?(title)
104
+ end
105
+
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)
112
+ end
87
113
 
88
114
  def word_count_at_least?(str, min_words)
89
115
  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
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # Detection is intentionally shallow for most scrapers, but instance-based
12
12
  # matching is available for scrapers that need to carry expensive selection
13
13
  # state forward into extraction.
14
- module Scraper
14
+ module Scraper # rubocop:disable Metrics/ModuleLength -- tier registry + construction helpers
15
15
  # Root markers indicating likely app-shell/client-rendered surfaces.
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.
@@ -22,7 +22,7 @@ module Html2rss
22
22
  # Extraction tiers: merge within a tier, then stop when AutoSource has enough articles.
23
23
  # Heuristic scrapers are separate tiers so SemanticHtml can satisfy before Html runs.
24
24
  SCRAPER_TIERS = [
25
- [Schema, Microdata, Microformats2, JsonState].freeze,
25
+ [Schema, Microdata, Microformats2, JsonState, XhrArticles].freeze,
26
26
  [WordpressApi, Sitemap, MetaOembed].freeze,
27
27
  [SemanticHtml].freeze,
28
28
  [Html].freeze
@@ -35,19 +35,21 @@ module Html2rss
35
35
  HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
36
36
  # Scrapers that accept a shared follow-up +request_session+.
37
37
  REQUEST_SESSION_SCRAPERS = [WordpressApi, Sitemap, MetaOembed].freeze
38
+ # Scrapers that consume browser-captured XHR/fetch JSON bodies.
39
+ CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
38
40
 
39
41
  ##
40
42
  # Error raised when no suitable scraper is found.
41
43
  class NoScraperFound < Html2rss::Error
42
- # User-facing messages grouped by no-scraper surface category.
44
+ # Surface diagnostics shared with {Html2rss::NoFeedItemsExtracted} (one string home).
43
45
  CATEGORY_MESSAGES = {
44
- blocked_surface: 'No scrapers found: blocked surface likely (anti-bot or interstitial). ' \
45
- 'Retry with --strategy browserless, try a more specific public listing URL, ' \
46
+ blocked_surface: 'blocked surface likely (anti-bot or interstitial). ' \
47
+ 'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
46
48
  'or run from an environment that can complete anti-bot checks.',
47
- app_shell: 'No scrapers found: app-shell surface detected (client-rendered page with little or no ' \
48
- 'server-rendered article HTML). Retry with --strategy browserless, or target a direct ' \
49
+ app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
50
+ 'server-rendered article HTML). Configure BOTASAURUS_SCRAPER_URL or target a direct ' \
49
51
  'listing/update URL instead of a homepage or shell entrypoint.',
50
- unsupported_surface: 'No scrapers found: unsupported extraction surface for auto mode. ' \
52
+ unsupported_surface: 'unsupported extraction surface for auto mode. ' \
51
53
  'Try a direct listing/changelog/category URL, ' \
52
54
  'or use explicit selectors in a feed config.'
53
55
  }.freeze
@@ -57,7 +59,7 @@ module Html2rss
57
59
  def initialize(message = nil, category: :unsupported_surface)
58
60
  validate_category!(category)
59
61
  @category = category
60
- super(message || CATEGORY_MESSAGES.fetch(@category))
62
+ super(message || "No scrapers found: #{CATEGORY_MESSAGES.fetch(@category)}")
61
63
  end
62
64
 
63
65
  attr_reader :category
@@ -81,6 +83,7 @@ module Html2rss
81
83
  # @option opts [Hash] :microdata scraper toggle and configuration
82
84
  # @option opts [Hash] :microformats2 scraper toggle and configuration
83
85
  # @option opts [Hash] :json_state scraper toggle and configuration
86
+ # @option opts [Hash] :xhr_articles scraper toggle and configuration
84
87
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
85
88
  # @option opts [Hash] :semantic_html scraper toggle and configuration
86
89
  # @option opts [Hash] :html scraper toggle and configuration
@@ -122,27 +125,36 @@ module Html2rss
122
125
  # @param body [String, nil]
123
126
  # @param document [SST::Document, nil]
124
127
  # @param link_resolver [Scoring::LinkResolver, nil]
128
+ # @param captured_responses [Array<Hash>] browser-captured JSON XHR/fetch bodies
125
129
  # @option opts [Hash] :wordpress_api scraper toggle and configuration
126
130
  # @option opts [Hash] :schema scraper toggle and configuration
127
131
  # @option opts [Hash] :microdata scraper toggle and configuration
128
132
  # @option opts [Hash] :microformats2 scraper toggle and configuration
129
133
  # @option opts [Hash] :json_state scraper toggle and configuration
134
+ # @option opts [Hash] :xhr_articles scraper toggle and configuration
130
135
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
131
136
  # @option opts [Hash] :semantic_html scraper toggle and configuration
132
137
  # @option opts [Hash] :html scraper toggle and configuration
133
138
  # @option opts [Hash] :sitemap scraper toggle and configuration
134
139
  # @return [Object, nil]
135
- # rubocop:disable Metrics/ParameterLists -- construction context for structured and heuristic scrapers
140
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- construction context for structured and heuristic scrapers
136
141
  def self.build_instance(scraper, parsed_body, opts:, url:, request_session: nil, body: nil, document: nil,
137
- link_resolver: nil)
142
+ link_resolver: nil, captured_responses: [])
138
143
  return unless opts.dig(scraper.options_key, :enabled)
139
144
  return if HEURISTIC_SCRAPERS.include?(scraper) && document.nil?
140
145
 
141
146
  scraper_opts = opts.fetch(scraper.options_key, {}).except(:enabled)
142
- kwargs = construction_kwargs(scraper, request_session:, body:, document:, link_resolver:)
147
+ kwargs = construction_kwargs(
148
+ scraper,
149
+ request_session:,
150
+ body:,
151
+ document:,
152
+ link_resolver:,
153
+ captured_responses:
154
+ )
143
155
  scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
144
156
  end
145
- # rubocop:enable Metrics/ParameterLists
157
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
146
158
 
147
159
  ##
148
160
  # @param instance [Object]
@@ -162,25 +174,32 @@ module Html2rss
162
174
  NoScraperFound.new(category: classify_no_scraper_surface(parsed_body, body:))
163
175
  end
164
176
 
165
- def self.construction_kwargs(scraper, request_session:, body:, document:, link_resolver:)
177
+ def self.construction_kwargs(scraper, request_session:, body:, document:, link_resolver:, # rubocop:disable Metrics/ParameterLists -- scraper construction bag
178
+ captured_responses:)
166
179
  if HEURISTIC_SCRAPERS.include?(scraper)
167
180
  { document:, link_resolver: }.compact
168
181
  else
169
182
  {}.tap do |kwargs|
170
183
  kwargs[:request_session] = request_session if REQUEST_SESSION_SCRAPERS.include?(scraper)
171
184
  kwargs[:body] = body if scraper == Sitemap
185
+ kwargs[:captured_responses] = captured_responses if CAPTURED_RESPONSE_SCRAPERS.include?(scraper)
172
186
  end
173
187
  end
174
188
  end
175
189
  private_class_method :construction_kwargs
176
190
 
191
+ ##
192
+ # Classifies why scrapers could not extract from a parsed page.
193
+ #
194
+ # @param parsed_body [Nokogiri::HTML::Document]
195
+ # @param body [String, nil] raw body for blocked-surface detection
196
+ # @return [Symbol] one of {NoScraperFound::CATEGORY_MESSAGES} keys
177
197
  def self.classify_no_scraper_surface(parsed_body, body: nil)
178
198
  return :blocked_surface if blocked_surface?(parsed_body, body:)
179
199
  return :app_shell if app_shell_surface?(parsed_body)
180
200
 
181
201
  :unsupported_surface
182
202
  end
183
- private_class_method :classify_no_scraper_surface
184
203
 
185
204
  def self.blocked_surface?(parsed_body, body: nil)
186
205
  Html2rss::RequestService::BlockedSurface.interstitial?(body || parsed_body.to_html)
@@ -70,7 +70,9 @@ module Html2rss
70
70
  return false unless facts
71
71
 
72
72
  text = node.visible_text.to_s.strip
73
- !segmenter.noise_policy.noise_anchor?(text:, destination_facts: facts, anchor: node)
73
+ !segmenter.noise_policy.noise_anchor?(
74
+ text:, destination_facts: facts, anchor: node, utility_landmark_ancestor: false
75
+ )
74
76
  end
75
77
  module_function :relevant_anchor?
76
78
  private_class_method :relevant_anchor?
@@ -51,16 +51,15 @@ module Html2rss
51
51
  heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
52
52
 
53
53
  return if @noise_policy.noise_anchor?(
54
- text:, destination_facts: destination, anchor:, container:, heading_anchor:
54
+ text:, destination_facts: destination, anchor:, container:, heading_anchor:,
55
+ utility_landmark_ancestor: @segmenter.landmark_ancestor?(anchor, container)
55
56
  )
56
57
  return unless meaningful || content_like || heading_anchor
57
58
 
58
- score = Scoring::AnchorScore.score(
59
- heading_anchor:,
60
- heading_text_match: heading_match,
61
- meaningful_text: meaningful,
62
- content_like_destination: content_like
63
- )
59
+ score = (heading_anchor ? 100 : 0) +
60
+ (heading_match ? 20 : 0) +
61
+ (meaningful ? 10 : 0) +
62
+ (content_like ? 10 : 0)
64
63
 
65
64
  { anchor:, destination: destination.destination, score: }
66
65
  end