html2rss 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3450f1c6b664e4f9c7377220b65640a9e4bd640782d4f8831538c65a172cec3
4
- data.tar.gz: 1f5543f86a3389a4f2abb9a76dec44ba41a4ffecadcf8d3d674ff084c459aaec
3
+ metadata.gz: 4841672d87f3828928fa35646176d878469ade0afce9f9e6a119ccd1cf3251ae
4
+ data.tar.gz: 618382810f59ff95713064d91a2ccb6df4c0dd2cf6287f31127cddd5848e8dbd
5
5
  SHA512:
6
- metadata.gz: 6b2d0fc1afbd44a13405eb21ad89ea32dbdf8d3c0dd1dd0f6430f877398a2cb5275781de7d65386fbde71f43db34e35a54c1fb5d13a73a265c8a8a274f496f87
7
- data.tar.gz: 6402bc203d89b23fc388872b310eb4c6aaa6b9423025a40ec6eaa606ecf5c78a64c09823a103b263344b72acae4fa385e11526f59571b699eaa54ec27379cf66
6
+ metadata.gz: 7abf8800a5943fca86ed9fa19058a1957ed101d52db70fd75042bda91abc2a9070d3bb5e6b0129e18103ebf407de2ea97a5e1a826cec3b5a7c0f21c12d0dfd61
7
+ data.tar.gz: 7831958f9d9b2af2db632b341cd8c902b45ed78feaf12dc21ee4f9c80c72237d56acba58e1b2aa7b0c842fc6dd3626beb48fd0e08ef932730920e33c23eb2f4b
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- ![html2rss logo](https://github.com/html2rss/html2rss/raw/master/support/logo.png)
1
+ ![html2rss logo](https://html2rss.github.io/assets/logo.png)
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/html2rss.svg)](http://rubygems.org/gems/html2rss) [![Yard Docs](http://img.shields.io/badge/yard-docs-blue.svg)](https://www.rubydoc.info/gems/html2rss) ![Retro Badge: valid RSS](https://validator.w3.org/feed/images/valid-rss-rogers.png) [![CI](https://github.com/html2rss/html2rss/workflows/lint%20and%20test/badge.svg)](https://github.com/html2rss/html2rss/actions)
4
4
 
@@ -62,6 +62,9 @@ html2rss capture https://example.com/news | html2rss test -
62
62
  # One-shot articles now
63
63
  html2rss scrape https://example.com/news
64
64
  html2rss scrape https://example.com/news --format jsonfeed --explain
65
+ # Off-domain item URLs are dropped by default. --keep-different-domain opts in
66
+ # and lets the remote source control published links to unrelated domains.
67
+ html2rss scrape https://example.com/app-list.json --keep-different-domain
65
68
 
66
69
  # Durable config workflow
67
70
  html2rss capture https://example.com/news --write feed.yml
@@ -10,8 +10,9 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
10
10
 
11
11
  ## Live flow
12
12
 
13
- 1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
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:
13
+ 1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML, or parsed JSON for JSON media types). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
14
+ 2. **JSON document body** — When the response is a JSON media type (`Response#json_response?`), AutoSource runs `JsonDocument` only (no HTML tiers). Scalar JSON is `unsupported_surface`. Same-domain Cleanup stays on by default (`keep_different_domain: false`). If the scraper yielded items and Cleanup kept none, `Log.warn` emits `auto_source.admission_emptied` with drop tallies; a `different_domain` tally hints at `auto_source.cleanup.keep_different_domain: true` / `--keep-different-domain`, which lets the remote source control off-domain link targets (no affiliation or safety check). Guidance stays in help/docs and that structured warning — stdout feed output stays composable.
15
+ 3. **Scraper tiers** (HTML only) — 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
 
16
17
  0. Native feed: NativeFeed (head alternates + path discovery → RSS/Atom parse)
17
18
  1. In-page structured: Schema, Microdata, Microformats2, JsonState, XhrArticles
@@ -19,14 +20,14 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
19
20
  3. Heuristic: SemanticHtml
20
21
  4. Heuristic: Html (skipped when earlier tiers already admitted at least one clean article)
21
22
 
22
- 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.
23
- 4. **Heuristic scrapers** — `SemanticHtml` and `Html` normalize once into an `SST::Document`, then:
23
+ 4. **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. `JsonDocument` walks a JSON response body (JSON Feed mapping or `JsonState.discover_articles`).
24
+ 5. **Heuristic scrapers** — `SemanticHtml` and `Html` normalize once into an `SST::Document`, then:
24
25
 
25
26
  `SST::Normalizer` → `AutoSource::Segmenter` → `Scoring::Engine` → extractor / article materialization.
26
27
 
27
- 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.
28
+ 6. **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.
28
29
 
29
- 6. **Entry URL resolution** (pipeline, not this class) — when AutoFallback sees a weak homepage extract, `FeedResolution` may rewrite the scrape URL to a listing/feed before escalating strategies. See {Html2rss::FeedPipeline} and {Html2rss::FeedResolution}.
30
+ 7. **Entry URL resolution** (pipeline, not this class) — when AutoFallback sees a weak homepage extract, `FeedResolution` may rewrite the scrape URL to a listing/feed before escalating strategies. See {Html2rss::FeedPipeline} and {Html2rss::FeedResolution}.
30
31
 
31
32
  Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (cards grouped by parent; `main` is a filter), `:cluster` (class/structure grids for anchorless cards). Scoring ranks and demotes; `LinkDestination::NoisePolicy` owns content-anchor eligibility. Cleanup owns feed-item admission.
32
33
 
@@ -34,7 +35,8 @@ Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (car
34
35
 
35
36
  | Surface | Owns DOM |
36
37
  | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
37
- | `Response#parsed_body` | Single HTML parse for the page |
38
+ | `Response#parsed_body` | HTML document, or Hash/Array for JSON bodies |
39
+ | `JsonDocument` | Parsed JSON Hash/Array (no Nokogiri) |
38
40
  | Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection | Nokogiri |
39
41
  | Sitemap detection (CSS/XPath) | Nokogiri; URL list parsing uses raw `response.body` (XML string) |
40
42
  | Selectors path / Sanitize transformers | Nokogiri (unchanged) |
@@ -51,7 +53,8 @@ Production heuristic scrapers should reuse one `SST::Document` memoized from `pa
51
53
 
52
54
  ## Non-goals
53
55
 
54
- - Replacing Selectors, Schema, Microdata, MF2, JsonState, or XhrArticles with SST.
56
+ - Replacing Selectors, Schema, Microdata, MF2, JsonState, JsonDocument, or XhrArticles with SST.
57
+ - Turning `keep_different_domain` on automatically for JSON documents (operators must opt in explicitly; the flag trusts the source for any published link domain).
55
58
  - A second HTML parser beside Nokogiri.
56
59
  - Dual Response payload (Nokogiri + SST always).
57
60
  - App-shell classification on SST.
@@ -63,10 +63,28 @@ module Html2rss
63
63
  # Read-only ship-quality audit on RSS/article-like items (no mutation).
64
64
  AuditResult = Data.define(:warnings, :metrics, :violations)
65
65
 
66
+ # Config key / CLI flag named when different_domain drops emptied admission.
67
+ # Opt-in trusts the remote source for off-domain link targets (no affiliation check).
68
+ DIFFERENT_DOMAIN_HINT =
69
+ 'auto_source.cleanup.keep_different_domain: true (or --keep-different-domain); ' \
70
+ 'allows published items to link to any domain supplied by the source'
71
+
66
72
  class << self
73
+ # Mitigating config hint when admission drops emptied a non-empty capture.
74
+ # Other drop reasons remain counts only.
75
+ #
76
+ # @param drop_tallies [Hash{String => Integer}]
77
+ # @return [String, nil]
78
+ def admission_hint(drop_tallies)
79
+ return unless drop_tallies.fetch('different_domain', 0).positive?
80
+
81
+ DIFFERENT_DOMAIN_HINT
82
+ end
83
+
67
84
  # @param articles [Array<Article>] extracted article candidates
68
85
  # @param url [Html2rss::Url] feed source URL used for same-host filtering
69
- # @param keep_different_domain [Boolean] whether to keep off-domain entries
86
+ # @param keep_different_domain [Boolean] when true, keep items whose URLs are on a
87
+ # different domain than +url+ (source-controlled off-domain links; default false)
70
88
  # @return [Result] cleaned articles and frozen drop tallies
71
89
  def call(articles, url:, keep_different_domain: DEFAULT_CONFIG.fetch(:keep_different_domain)) # rubocop:disable Metrics/MethodLength -- ordered reject pipeline
72
90
  Log.debug "Cleanup: start with #{articles.size} articles"
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ module Scraper
6
+ # Extracts articles from a parsed JSON response body (Hash or Array).
7
+ # JSON Feed documents map item fields directly; every other document walks
8
+ # via {JsonState.discover_articles}. Does not scan HTML.
9
+ class JsonDocument
10
+ include Enumerable
11
+
12
+ # Exact JSON Feed 1.0 / 1.1 +version+ URLs (no substring host matching).
13
+ JSON_FEED_VERSIONS = Set[
14
+ 'https://jsonfeed.org/version/1',
15
+ 'https://jsonfeed.org/version/1.1'
16
+ ].freeze
17
+
18
+ # @return [Symbol] scraper config key
19
+ def self.options_key = :json_document
20
+
21
+ # @param _opts [Hash] unused scraper options
22
+ # @return [Integer] zero — no additional HTTP requests
23
+ def self.request_slots(_opts = {}) = 0
24
+
25
+ # Not detectable from HTML alone; instance {#extractable?} requires a
26
+ # Hash or Array body (wired by AutoSource for JSON responses).
27
+ #
28
+ # @param _parsed_body [Object] unused
29
+ # @return [Boolean]
30
+ def self.articles?(_parsed_body) = false
31
+
32
+ # @param parsed_body [Hash, Array, Object] parsed JSON document (or HTML when misrouted)
33
+ # @param url [String, Html2rss::Url] base URL for relative link resolution
34
+ # @param _opts [Hash] scraper-specific options
35
+ # @option _opts [Object] :_reserved reserved for future scraper-specific options
36
+ def initialize(parsed_body, url:, **_opts)
37
+ @parsed_body = parsed_body
38
+ @url = url
39
+ end
40
+
41
+ # @return [Boolean] true when +parsed_body+ is a Hash or Array
42
+ def extractable?
43
+ @parsed_body.is_a?(Hash) || @parsed_body.is_a?(Array)
44
+ end
45
+
46
+ # @yield [Hash{Symbol => Object}] normalized article hash
47
+ # @return [Enumerator, void] article enumerator when no block is given
48
+ def each(&block)
49
+ return enum_for(:each) unless block
50
+ return unless extractable?
51
+
52
+ if json_feed?
53
+ each_json_feed_item(&block)
54
+ else
55
+ JsonState.discover_articles(@parsed_body, base_url: @url) do |article|
56
+ yield(article) if article
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def json_feed?
64
+ return false unless @parsed_body.is_a?(Hash)
65
+
66
+ JSON_FEED_VERSIONS.include?(@parsed_body[:version].to_s) && @parsed_body[:items].is_a?(Array)
67
+ end
68
+
69
+ def each_json_feed_item
70
+ @parsed_body[:items].each do |item|
71
+ article = map_json_feed_item(item)
72
+ yield article if article
73
+ end
74
+ end
75
+
76
+ def map_json_feed_item(item)
77
+ return unless item.is_a?(Hash)
78
+
79
+ article_url = resolve_json_feed_url(item)
80
+ return unless article_url
81
+
82
+ json_feed_article_hash(item, article_url)
83
+ end
84
+
85
+ def json_feed_article_hash(item, article_url)
86
+ {
87
+ title: string(item[:title]),
88
+ description: json_feed_description(item),
89
+ url: article_url,
90
+ image: resolve_optional_url(item[:image]),
91
+ published_at: string(item[:date_published]),
92
+ categories: json_feed_tags(item[:tags]),
93
+ id: string(item[:id]) || article_url.to_s
94
+ }.compact
95
+ end
96
+
97
+ def resolve_json_feed_url(item)
98
+ resolve_optional_url(item[:url]) || absolute_url(item[:id])
99
+ end
100
+
101
+ def resolve_optional_url(value)
102
+ str = string(value)
103
+ return unless str
104
+
105
+ Url.from_relative(str, @url)
106
+ rescue ArgumentError
107
+ nil
108
+ end
109
+
110
+ def absolute_url(value)
111
+ str = string(value)
112
+ return unless str
113
+
114
+ Url.from_absolute(str)
115
+ rescue ArgumentError
116
+ nil
117
+ end
118
+
119
+ def json_feed_description(item)
120
+ string(item[:content_text]) || string(item[:content_html])
121
+ end
122
+
123
+ def json_feed_tags(tags)
124
+ return unless tags.is_a?(Array)
125
+
126
+ names = tags.filter_map { |tag| string(tag) }
127
+ names.uniq!
128
+ names unless names.empty?
129
+ end
130
+
131
+ def string(value) = JsonState::ArticleNormalizer.string(value)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -5,15 +5,15 @@ module Html2rss
5
5
  module Scraper
6
6
  class JsonState
7
7
  # Shapes raw entries into the structure required downstream.
8
- module ArticleNormalizer
8
+ module ArticleNormalizer # rubocop:disable Metrics/ModuleLength -- field keys + URL/scalar unwrap stay co-located
9
9
  # Preferred keys when extracting title-like values from state payloads.
10
10
  TITLE_KEYS = %i[title headline name text].freeze
11
11
  # Preferred keys when extracting URL-like values from state payloads.
12
- URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
12
+ URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl alias self].freeze
13
13
  # Preferred keys when extracting description-like values from state payloads.
14
- DESCRIPTION_KEYS = %i[description summary excerpt dek subheading].freeze
14
+ DESCRIPTION_KEYS = %i[description summary excerpt dek subheading abstract].freeze
15
15
  # Preferred keys when extracting image-like values from state payloads.
16
- IMAGE_KEYS = %i[image imageUrl thumbnailUrl thumbnail src featuredImage coverImage heroImage].freeze
16
+ IMAGE_KEYS = %i[image imageUrl thumbnailUrl thumbnail src featuredImage coverImage heroImage icon].freeze
17
17
  # Preferred keys when extracting publication timestamps from state payloads.
18
18
  PUBLISHED_AT_KEYS = %i[published_at publishedAt datePublished date publicationDate pubDate updatedAt
19
19
  updated_at createdAt created_at].freeze
@@ -21,6 +21,12 @@ module Html2rss
21
21
  CATEGORY_KEYS = %i[categories tags section sections topic topics channel].freeze
22
22
  # Preferred keys when extracting identifier-like values from state payloads.
23
23
  ID_KEYS = %i[id guid uuid slug key].freeze
24
+ # Max times to unwrap a Hash URL value (e.g. path.alias, links.self.href).
25
+ MAX_URL_UNWRAP = 3
26
+ # Nested title/description keys (WordPress +rendered+, CMS +value+/+text+).
27
+ SCALAR_KEYS = %i[rendered value text].freeze
28
+ # Snake `_at`/`_on` or camel `At`/`On` suffix after a closed published-at miss.
29
+ DATE_KEY_SUFFIX = /_at\z|_on\z|[a-z]At\z|[a-z]On\z/
24
30
 
25
31
  module_function
26
32
 
@@ -44,37 +50,94 @@ module Html2rss
44
50
  url: article_url,
45
51
  image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
46
52
  log_key: 'JsonState: invalid image URL encountered'),
47
- published_at: string(ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)),
53
+ published_at: published_at(entry),
48
54
  categories: categories(entry),
49
55
  id: identifier(entry, article_url)
50
56
  }.compact
51
57
  end
52
58
  # rubocop:enable Metrics/MethodLength
53
59
 
54
- # @param value [Object] candidate scalar value
55
- # @return [String, nil] normalized non-empty string value
60
+ # @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
61
+ # @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
56
62
  def string(value)
63
+ value = unwrap_scalar(value)
64
+ return unless value.is_a?(String) || value.is_a?(Numeric)
65
+
57
66
  trimmed = value.to_s.strip
58
67
  trimmed unless trimmed.empty?
59
68
  end
60
69
 
70
+ # @param value [Object] candidate scalar or nested Hash
71
+ # @return [Object, nil] String/Numeric after at most {MAX_URL_UNWRAP} Hash unwraps
72
+ def unwrap_scalar(value)
73
+ depth = 0
74
+ while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
75
+ nested = SCALAR_KEYS.find { |key| value.key?(key) }
76
+ break unless nested
77
+
78
+ value = value[nested]
79
+ depth += 1
80
+ end
81
+ value unless value.is_a?(Hash) || value.is_a?(Array)
82
+ end
83
+
61
84
  # @param entry [Hash] raw article entry candidate
62
- # @param keys [Array<String>] preferred link keys
85
+ # @param keys [Array<Symbol>] preferred link keys
63
86
  # @param base_url [String, Html2rss::Url] base URL for relative link resolution
64
87
  # @param log_key [String] structured log message key
65
88
  # @return [Html2rss::Url, nil] resolved absolute URL
66
89
  def resolve_link(entry, keys:, base_url:, log_key:)
67
- value = ValueFinder.fetch(entry, keys)
68
- value = ValueFinder.fetch(value, keys) if value.is_a?(Hash)
90
+ value = unwrap_url(ValueFinder.fetch(entry, keys), keys) ||
91
+ unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
69
92
  string = string(value)
70
93
  return unless string
71
94
 
72
95
  Url.from_relative(string, base_url)
73
96
  rescue ArgumentError
74
- Log.debug(log_key, url: string)
97
+ Log.debug("#{log_key}: url=#{string}")
75
98
  nil
76
99
  end
77
100
 
101
+ # @param value [Object] candidate URL value, possibly a nested Hash
102
+ # @param keys [Array<Symbol>] preferred link keys
103
+ # @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} Hash unwraps
104
+ def unwrap_url(value, keys)
105
+ depth = 0
106
+ while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
107
+ value = ValueFinder.fetch(value, keys)
108
+ depth += 1
109
+ end
110
+ value unless value.is_a?(Hash)
111
+ end
112
+
113
+ # @param entry [Hash] raw article entry candidate
114
+ # @return [String, nil] publication timestamp string or unix timestamp digits
115
+ def published_at(entry)
116
+ value = ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)
117
+ string(value) || string(suffix_date_value(entry))
118
+ end
119
+
120
+ # @param hash [Hash, nil] entry or nested attributes/data hash
121
+ # @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
122
+ def suffix_date_value(hash)
123
+ return unless hash.is_a?(Hash)
124
+
125
+ hash.each do |key, value|
126
+ next unless date_suffix_key?(key)
127
+ next unless value.is_a?(String) || value.is_a?(Numeric)
128
+
129
+ return value
130
+ end
131
+
132
+ suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
133
+ end
134
+
135
+ # @param key [Symbol, String] candidate published-at key
136
+ # @return [Boolean] whether the key ends in a date suffix (_at/_on/At/On)
137
+ def date_suffix_key?(key)
138
+ key.to_s.match?(DATE_KEY_SUFFIX)
139
+ end
140
+
78
141
  # rubocop:disable Metrics/MethodLength
79
142
  # @param entry [Hash] raw article entry candidate
80
143
  # @return [Array<String>, nil] normalized unique categories
@@ -6,11 +6,6 @@ module Html2rss
6
6
  class JsonState
7
7
  # Identifies arrays that look like collections of article hashes.
8
8
  module CandidateDetector
9
- # Preferred keys when extracting title-like values from state payloads.
10
- TITLE_KEYS = %i[title headline name text].freeze
11
- # Preferred keys when extracting URL-like values from state payloads.
12
- URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
13
-
14
9
  module_function
15
10
 
16
11
  # @param document [Hash, Array, Object] candidate document node
@@ -48,13 +43,14 @@ module Html2rss
48
43
  # @param object [Hash] article candidate object
49
44
  # @return [Object, nil] detected title-like value
50
45
  def title_from(object)
51
- ValueFinder.fetch(object, TITLE_KEYS)
46
+ ValueFinder.fetch(object, ArticleNormalizer::TITLE_KEYS)
52
47
  end
53
48
 
54
49
  # @param object [Hash] article candidate object
55
- # @return [Object, nil] detected URL-like value
50
+ # @return [Object, nil] detected URL-like value (including links.self)
56
51
  def url_from(object)
57
- ValueFinder.fetch(object, URL_KEYS)
52
+ ValueFinder.fetch(object, ArticleNormalizer::URL_KEYS) ||
53
+ ValueFinder.fetch(object[:links], ArticleNormalizer::URL_KEYS)
58
54
  end
59
55
  end
60
56
  end
@@ -32,8 +32,11 @@ module Html2rss
32
32
  [Html].freeze
33
33
  ].freeze
34
34
 
35
+ # JSON response body scrapers (routed by AutoSource before SCRAPER_TIERS).
36
+ DOCUMENT_SCRAPERS = [JsonDocument].freeze
37
+
35
38
  # Flat ordered list (request slot accounting, detection helpers).
36
- SCRAPERS = SCRAPER_TIERS.flatten.freeze
39
+ SCRAPERS = (DOCUMENT_SCRAPERS + SCRAPER_TIERS.flatten).freeze
37
40
 
38
41
  # Heuristic scrapers that share one memoized SST::Document per page.
39
42
  HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
@@ -51,8 +54,8 @@ module Html2rss
51
54
  'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
52
55
  'or run from an environment that can complete anti-bot checks.',
53
56
  app_shell: 'app-shell surface detected (client-rendered page with little or no ' \
54
- 'server-rendered article HTML). Configure BOTASAURUS_SCRAPER_URL or target a direct ' \
55
- 'listing/update URL instead of a homepage or shell entrypoint.',
57
+ 'server-rendered article HTML). Target a direct listing/update URL instead of a ' \
58
+ 'homepage or shell entrypoint; a browser strategy does not add extractable markup here.',
56
59
  high_entropy_surface: 'high-entropy surface (e.g. news homepage) with few or no durable ' \
57
60
  'article destinations after admission. Target a section, topic, or ' \
58
61
  'listing/update URL instead of the site homepage.',
@@ -91,6 +94,7 @@ module Html2rss
91
94
  # @option opts [Hash] :microdata scraper toggle and configuration
92
95
  # @option opts [Hash] :microformats2 scraper toggle and configuration
93
96
  # @option opts [Hash] :json_state scraper toggle and configuration
97
+ # @option opts [Hash] :json_document scraper toggle and configuration
94
98
  # @option opts [Hash] :xhr_articles scraper toggle and configuration
95
99
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
96
100
  # @option opts [Hash] :semantic_html scraper toggle and configuration
@@ -139,6 +143,7 @@ module Html2rss
139
143
  # @option opts [Hash] :microdata scraper toggle and configuration
140
144
  # @option opts [Hash] :microformats2 scraper toggle and configuration
141
145
  # @option opts [Hash] :json_state scraper toggle and configuration
146
+ # @option opts [Hash] :json_document scraper toggle and configuration
142
147
  # @option opts [Hash] :xhr_articles scraper toggle and configuration
143
148
  # @option opts [Hash] :meta_oembed scraper toggle and configuration
144
149
  # @option opts [Hash] :semantic_html scraper toggle and configuration
@@ -61,6 +61,9 @@ module Html2rss
61
61
  json_state: {
62
62
  enabled: true
63
63
  },
64
+ json_document: {
65
+ enabled: true
66
+ },
64
67
  xhr_articles: {
65
68
  enabled: true
66
69
  },
@@ -113,6 +116,7 @@ module Html2rss
113
116
  @parsed_body = response.parsed_body
114
117
  @body = response.body
115
118
  @url = response.url
119
+ @json_response = response.json_response?
116
120
  @captured_responses = response.captured_responses
117
121
  @opts = opts
118
122
  @request_session = request_session
@@ -131,7 +135,12 @@ module Html2rss
131
135
  def articles
132
136
  @articles ||= extract_articles
133
137
  rescue Html2rss::AutoSource::Scraper::NoScraperFound => error
134
- Log.warn "#{self.class}: no scraper matched #{url} (#{error.message})"
138
+ Log.warn({
139
+ event_name: 'auto_source.no_scraper',
140
+ host: url.host,
141
+ category: error.category,
142
+ message: "#{self.class}: no scraper matched #{url} (#{error.message})"
143
+ }.to_json)
135
144
  @admission_drops = {}.freeze
136
145
  []
137
146
  end
@@ -150,6 +159,8 @@ module Html2rss
150
159
 
151
160
  # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
152
161
  def extract_articles
162
+ return extract_json_document_articles if json_response?
163
+
153
164
  articles = []
154
165
  matched = false
155
166
  document = nil
@@ -189,11 +200,55 @@ module Html2rss
189
200
 
190
201
  raise Scraper.no_scraper_found_for(parsed_body, body:) unless matched
191
202
 
203
+ finish_articles(articles)
204
+ end
205
+
206
+ def json_response?
207
+ @json_response
208
+ end
209
+
210
+ def extract_json_document_articles
211
+ instance = json_document_instance
212
+ raise Scraper::NoScraperFound.new(category: :unsupported_surface) unless instance&.extractable?
213
+
214
+ articles = run_scraper(instance)
215
+ raise Scraper::NoScraperFound.new(category: :unsupported_surface) if articles.empty?
216
+
217
+ finish_articles(articles)
218
+ end
219
+
220
+ def json_document_instance
221
+ Scraper.build_instance(
222
+ Scraper::JsonDocument,
223
+ parsed_body,
224
+ opts: @opts.fetch(:scraper, {}),
225
+ url:,
226
+ request_session:,
227
+ body:,
228
+ captured_responses:
229
+ )
230
+ end
231
+
232
+ def finish_articles(articles)
233
+ captured_count = articles.size
192
234
  result = Cleanup.call(articles, url:, **cleanup_options)
193
235
  @admission_drops = result.drop_tallies
236
+ log_admission_emptied(captured_count, result) if captured_count.positive? && result.articles.empty?
194
237
  result.articles.first(article_limit)
195
238
  end
196
239
 
240
+ def log_admission_emptied(captured_count, result)
241
+ payload = {
242
+ event_name: 'auto_source.admission_emptied',
243
+ host: url.host,
244
+ captured_count:,
245
+ drop_tallies: result.drop_tallies
246
+ }
247
+ hint = Cleanup.admission_hint(result.drop_tallies)
248
+ payload[:hint] = hint if hint
249
+ Log.warn(payload.to_json)
250
+ end
251
+
197
252
  def enough_articles?(articles)
198
253
  return false if articles.size < article_limit
199
254
 
data/lib/html2rss/cli.rb CHANGED
@@ -213,6 +213,9 @@ module Html2rss
213
213
  desc 'scrape [URL]', 'Automatically source an RSS feed from a URL (one-shot auto-source)'
214
214
  method_option :items_selector, type: :string, desc: 'CSS selector hint for items'
215
215
  method_option :limit, type: :numeric, desc: 'Max articles (auto-source)'
216
+ method_option :keep_different_domain, type: :boolean, default: false,
217
+ desc: 'Allow published items to link to any domain ' \
218
+ 'supplied by the source (no affiliation/safety check)'
216
219
  # @param url [String, nil]
217
220
  # @return [void]
218
221
  def scrape(url = nil)
@@ -430,7 +433,8 @@ module Html2rss
430
433
  max_redirects: options[:max_redirects],
431
434
  max_requests: options[:max_requests],
432
435
  local_file_path:,
433
- limit: options[:limit]&.to_i
436
+ limit: options[:limit]&.to_i,
437
+ keep_different_domain: options[:keep_different_domain]
434
438
  )
435
439
  end
436
440
 
@@ -36,6 +36,9 @@ module Html2rss
36
36
  optional(:json_state).hash do
37
37
  optional(:enabled).filled(:bool)
38
38
  end
39
+ optional(:json_document).hash do
40
+ optional(:enabled).filled(:bool)
41
+ end
39
42
  optional(:xhr_articles).hash do
40
43
  optional(:enabled).filled(:bool)
41
44
  end
@@ -125,10 +125,18 @@ module Html2rss
125
125
  # @param items_selector [String, nil] optional selector hint for item extraction
126
126
  # @param request_controls [Html2rss::Config::RequestControls, nil] explicit request controls to write
127
127
  # @param limit [Integer, nil] max articles to keep in the auto-sourced feed
128
+ # @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
128
129
  # @return [Hash{Symbol => Object}] feed config hash ready for {from_hash}
129
- def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil)
130
+ # rubocop:disable-next Metrics/MethodLength -- keep_different_domain override stays co-located
131
+ def auto_source_config(url:, items_selector: nil, request_controls: nil, limit: nil,
132
+ keep_different_domain: nil)
130
133
  auto_source = AutoSource::DEFAULT_CONFIG
131
134
  auto_source = auto_source.merge(limit:) unless limit.nil?
135
+ unless keep_different_domain.nil?
136
+ auto_source = auto_source.merge(
137
+ cleanup: auto_source[:cleanup].merge(keep_different_domain:)
138
+ )
139
+ end
132
140
 
133
141
  config = {
134
142
  channel: default_config[:channel].merge(url:),
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Html2rss
4
6
  class FeedPipeline
5
7
  # Retries feed extraction across concrete request strategies for the :auto plan.
@@ -248,8 +250,15 @@ module Html2rss
248
250
  Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
249
251
  "after timeout=#{error.class} #{detail}")
250
252
  else
251
- Log.warn("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
252
- "after error=#{error.class} #{detail}")
253
+ Log.warn({
254
+ event_name: 'auto_fallback.error',
255
+ host:,
256
+ strategy: strategy.to_s,
257
+ next_strategy: next_strategy.to_s,
258
+ error_class: error.class.name,
259
+ message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
260
+ "after error=#{error.class} #{detail}"
261
+ }.to_json)
253
262
  end
254
263
  Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
255
264
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Html2rss
4
6
  ##
5
7
  # Entry URL tournament for weak homepage/hub extracts.
@@ -120,12 +122,12 @@ module Html2rss
120
122
 
121
123
  nil
122
124
  rescue RequestService::RequestBudgetExceeded
123
- Log.warn('FeedResolution: entry resolution skipped (request budget exhausted)')
125
+ log_budget_exhausted
124
126
  nil
125
127
  rescue *NON_FALLBACK_ERRORS
126
128
  raise
127
129
  rescue StandardError => error
128
- Log.warn("FeedResolution: entry resolution retry failed (#{error.class})")
130
+ log_retry_failed(error)
129
131
  nil
130
132
  end
131
133
 
@@ -134,6 +136,32 @@ module Html2rss
134
136
  attr_reader :pipeline, :config, :response, :session, :strategy, :resources, :articles,
135
137
  :scrape_target, :state, :budget, :assessment
136
138
 
139
+ def log_budget_exhausted
140
+ Log.warn({
141
+ event_name: 'feed_resolution.budget_exhausted',
142
+ host: channel_host,
143
+ message: 'FeedResolution: entry resolution skipped (request budget exhausted)'
144
+ }.to_json)
145
+ end
146
+
147
+ def log_retry_failed(error)
148
+ Log.warn({
149
+ event_name: 'feed_resolution.retry_failed',
150
+ host: channel_host,
151
+ error_class: error.class.name,
152
+ message: "FeedResolution: entry resolution retry failed (#{error.class.name})"
153
+ }.to_json)
154
+ end
155
+
156
+ # +Config#url+ is a String; parse once for structured logs.
157
+ #
158
+ # @return [String, nil]
159
+ def channel_host
160
+ Url.from_absolute(config.url.to_s).host
161
+ rescue ArgumentError
162
+ nil
163
+ end
164
+
137
165
  def eligible?
138
166
  return false if state.resolution_tried?
139
167
  return false if response.feed_response?
@@ -11,7 +11,7 @@ module Html2rss
11
11
  # Published MCP request strategies (excludes +local_file+).
12
12
  STRATEGIES = %w[auto default httpx botasaurus].freeze
13
13
  # Accepted migration strategies retained for backwards compatibility.
14
- MIGRATION_STRATEGIES = %w[faraday].freeze
14
+ MIGRATION_STRATEGIES = %w[faraday browserless].freeze
15
15
  # Complete set of strategies accepted by {.assert_published_request!}.
16
16
  ALL_ACCEPTED_STRATEGIES = (STRATEGIES + MIGRATION_STRATEGIES).freeze
17
17
 
@@ -71,8 +71,11 @@ module Html2rss
71
71
  # @return [String] normalized content type header value
72
72
  def content_type = header('content-type').to_s
73
73
 
74
- # @return [Boolean] whether response content is JSON
75
- def json_response? = content_type.include?('application/json')
74
+ # @return [Boolean] whether the media type is JSON (`*/json` or `*+json`)
75
+ def json_response?
76
+ media_type = content_type.split(';', 2).first.to_s.strip.downcase
77
+ media_type.end_with?('/json', '+json')
78
+ end
76
79
 
77
80
  # @return [Boolean] whether response content is HTML (header or sniffed body, never JSON)
78
81
  def html_response?
@@ -61,9 +61,17 @@ module Html2rss
61
61
  httpx: HttpxStrategy,
62
62
  faraday: HttpxStrategy,
63
63
  botasaurus: BotasaurusStrategy,
64
+ browserless: BotasaurusStrategy,
64
65
  local_file: LocalFileStrategy
65
66
  }.freeze
66
67
 
68
+ # Deprecated strategy aliases and the preferred replacement name for warnings.
69
+ # @return [Hash{Symbol => Symbol}]
70
+ MIGRATION_REPLACEMENTS = {
71
+ faraday: :default,
72
+ browserless: :botasaurus
73
+ }.freeze
74
+
67
75
  # Canonical default strategy symbol.
68
76
  # @return [Symbol]
69
77
  DEFAULT_STRATEGY_NAME = :default
@@ -96,15 +104,16 @@ module Html2rss
96
104
  "The strategy '#{strategy}' is not known. Available strategies: #{strategy_names.join(', ')}"
97
105
  end
98
106
 
99
- warn_migration_strategy(strategy_sym) if strategy_sym == :faraday
107
+ warn_migration_strategy(strategy_sym) if MIGRATION_REPLACEMENTS.key?(strategy_sym)
100
108
  strategy_class.new(ctx).execute
101
109
  end
102
110
 
103
111
  private
104
112
 
105
113
  def warn_migration_strategy(strategy)
114
+ replacement = MIGRATION_REPLACEMENTS.fetch(strategy)
106
115
  message = "RequestService: strategy ':#{strategy}' is deprecated for migration and will be removed " \
107
- "in a future release. Use ':default' instead."
116
+ "in a future release. Use ':#{replacement}' instead."
108
117
  warn(message, category: :deprecated)
109
118
  Log.warn(message)
110
119
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Html2rss
4
4
  # Current application version.
5
- VERSION = '0.31.0'
5
+ VERSION = '0.32.0'
6
6
  public_constant :VERSION
7
7
  end
data/lib/html2rss.rb CHANGED
@@ -134,6 +134,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
134
134
  # @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
135
135
  # @param local_file_path [String, nil] optional local HTML file path
136
136
  # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
137
+ # @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
137
138
  # @return [Html2rss::FeedResult]
138
139
  def self.scrape(url,
139
140
  strategy: :auto,
@@ -141,9 +142,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
141
142
  max_redirects: nil,
142
143
  max_requests: 4,
143
144
  local_file_path: nil,
144
- limit: nil)
145
+ limit: nil,
146
+ keep_different_domain: nil)
145
147
  auto_feed_result(url, strategy:, items_selector:, max_redirects:, max_requests:,
146
- local_file_path:, limit:)
148
+ local_file_path:, limit:, keep_different_domain:)
147
149
  end
148
150
 
149
151
  ##
@@ -246,6 +248,7 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
246
248
  # @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
247
249
  # @param local_file_path [String, nil] optional local HTML file path
248
250
  # @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
251
+ # @param keep_different_domain [Boolean, nil] when set, overrides cleanup.keep_different_domain
249
252
  # @return [Html2rss::FeedResult]
250
253
  def self.auto_feed_result(url,
251
254
  strategy: :auto,
@@ -253,9 +256,10 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
253
256
  max_redirects: nil,
254
257
  max_requests: 4,
255
258
  local_file_path: nil,
256
- limit: nil)
259
+ limit: nil,
260
+ keep_different_domain: nil)
257
261
  feed_result(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
258
- local_file_path:, limit:))
262
+ local_file_path:, limit:, keep_different_domain:))
259
263
  end
260
264
 
261
265
  ##
@@ -355,12 +359,13 @@ module Html2rss # rubocop:disable Metrics/ModuleLength
355
359
 
356
360
  # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength
357
361
  def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
358
- local_file_path: nil, limit: nil)
362
+ local_file_path: nil, limit: nil, keep_different_domain: nil)
359
363
  config = Config.auto_source_config(
360
364
  url:,
361
365
  items_selector:,
362
366
  request_controls: Config::RequestControls.from_shortcut(strategy:, max_redirects:, max_requests:),
363
- limit:
367
+ limit:,
368
+ keep_different_domain:
364
369
  )
365
370
  if local_file_path
366
371
  config[:request] ||= {}
@@ -281,6 +281,18 @@
281
281
  },
282
282
  "required": []
283
283
  },
284
+ "json_document": {
285
+ "type": "object",
286
+ "properties": {
287
+ "enabled": {
288
+ "type": "boolean",
289
+ "not": {
290
+ "type": "null"
291
+ }
292
+ }
293
+ },
294
+ "required": []
295
+ },
284
296
  "xhr_articles": {
285
297
  "type": "object",
286
298
  "properties": {
@@ -402,6 +414,9 @@
402
414
  "json_state": {
403
415
  "enabled": true
404
416
  },
417
+ "json_document": {
418
+ "enabled": true
419
+ },
405
420
  "xhr_articles": {
406
421
  "enabled": true
407
422
  },
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: html2rss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.0
4
+ version: 0.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gil Desmarais
@@ -275,6 +275,7 @@ files:
275
275
  - lib/html2rss/auto_source/cleanup.rb
276
276
  - lib/html2rss/auto_source/scraper.rb
277
277
  - lib/html2rss/auto_source/scraper/html.rb
278
+ - lib/html2rss/auto_source/scraper/json_document.rb
278
279
  - lib/html2rss/auto_source/scraper/json_state.rb
279
280
  - lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb
280
281
  - lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb
@@ -477,7 +478,7 @@ licenses:
477
478
  - MIT
478
479
  metadata:
479
480
  allowed_push_host: https://rubygems.org
480
- changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.31.0
481
+ changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.0
481
482
  rubygems_mfa_required: 'true'
482
483
  rdoc_options: []
483
484
  require_paths: