html2rss 0.31.0 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -1
- data/lib/html2rss/auto_source/README.md +11 -8
- data/lib/html2rss/auto_source/cleanup.rb +19 -1
- data/lib/html2rss/auto_source/scraper/json_document.rb +135 -0
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +74 -11
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +4 -8
- data/lib/html2rss/auto_source/scraper.rb +8 -3
- data/lib/html2rss/auto_source.rb +56 -1
- data/lib/html2rss/cli.rb +5 -1
- data/lib/html2rss/config/auto_source_contract.rb +3 -0
- data/lib/html2rss/config.rb +9 -1
- data/lib/html2rss/error.rb +7 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +38 -8
- data/lib/html2rss/feed_resolution.rb +30 -2
- data/lib/html2rss/mcp/contract.rb +1 -1
- data/lib/html2rss/request_service/blocked_surface.rb +11 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +5 -0
- data/lib/html2rss/request_service/botasaurus_strategy.rb +10 -8
- data/lib/html2rss/request_service/httpx_strategy.rb +11 -1
- data/lib/html2rss/request_service/response.rb +5 -2
- data/lib/html2rss/request_service.rb +41 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +11 -6
- data/schema/html2rss-config.schema.json +15 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1d74d19e935fcf05c7bd0893f2113dfac1e6639de926ef8844159118ad13ff5
|
|
4
|
+
data.tar.gz: 3999a5e55251f993b6721d9d3f8c03b8c7588fa0834499a53c808789bb7a3ded
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c7bea03d8553b2e8ec76c8216b57e0f07b20157cb1c1710c31b4730281b410c95b61541fae56bc4d3853cedf5e62854e0255b920607e28cfa902e941e779953
|
|
7
|
+
data.tar.gz: 6911fdce7df3238bbe27b28b6a8f747077b12a5cd949b12e4cc0bddadff1433f0ef56662cc361f7d80c90924fe38813ac6584ef0ec08a6b39b0859bc4067d2d4
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|
|
|
3
3
|
[](http://rubygems.org/gems/html2rss) [](https://www.rubydoc.info/gems/html2rss)  [](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. **
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
|
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]
|
|
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:
|
|
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
|
|
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<
|
|
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
|
-
|
|
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
|
|
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).
|
|
55
|
-
'
|
|
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
|
data/lib/html2rss/auto_source.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/html2rss/config.rb
CHANGED
|
@@ -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
|
-
|
|
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:),
|
data/lib/html2rss/error.rb
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
# The Html2rss::Error base class.
|
|
5
|
-
class Error < StandardError
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# @return [Array<Hash{Symbol => Object}>, nil] tier attempt diagnostics when set by AutoFallback
|
|
7
|
+
attr_accessor :attempts
|
|
8
|
+
|
|
9
|
+
# @return [Hash{String => Object}, nil] allowlisted upstream transport telemetry when attached
|
|
10
|
+
attr_accessor :transport_meta
|
|
11
|
+
end
|
|
6
12
|
|
|
7
13
|
# Raised when auto fallback exhausts all concrete tiers and extractors find no feed items.
|
|
8
14
|
class NoFeedItemsExtracted < Error
|
|
@@ -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.
|
|
@@ -28,12 +30,13 @@ module Html2rss
|
|
|
28
30
|
##
|
|
29
31
|
# Mutable run state for one auto-fallback chain: attempts plus selected result.
|
|
30
32
|
class AttemptState
|
|
31
|
-
attr_reader :attempts, :result, :last_response, :entry_resolution
|
|
33
|
+
attr_reader :attempts, :result, :last_response, :last_error, :entry_resolution
|
|
32
34
|
|
|
33
35
|
def initialize
|
|
34
36
|
@attempts = []
|
|
35
37
|
@result = nil
|
|
36
38
|
@last_response = nil
|
|
39
|
+
@last_error = nil
|
|
37
40
|
@entry_resolution = nil
|
|
38
41
|
@resolution_tried = false
|
|
39
42
|
@aborted = false
|
|
@@ -68,7 +71,11 @@ module Html2rss
|
|
|
68
71
|
# @param error [Exception] caught error
|
|
69
72
|
# @return [void]
|
|
70
73
|
def record_error(strategy:, error:)
|
|
71
|
-
|
|
74
|
+
attempt = { strategy:, items_count: nil, error_class: error.class.name }
|
|
75
|
+
meta = error.is_a?(Html2rss::Error) ? error.transport_meta : nil
|
|
76
|
+
attempt[:transport_meta] = meta if meta&.any?
|
|
77
|
+
@attempts << attempt
|
|
78
|
+
@last_error = error
|
|
72
79
|
end
|
|
73
80
|
|
|
74
81
|
# @param strategy [Symbol] strategy that returned a response
|
|
@@ -76,6 +83,7 @@ module Html2rss
|
|
|
76
83
|
# @param transport_meta [Hash, nil] optional allowlisted upstream telemetry
|
|
77
84
|
# @return [void]
|
|
78
85
|
def record_items(strategy:, items_count:, transport_meta: nil)
|
|
86
|
+
@last_error = nil
|
|
79
87
|
attempt = { strategy:, items_count:, error_class: nil }
|
|
80
88
|
attempt[:transport_meta] = transport_meta if transport_meta && !transport_meta.empty?
|
|
81
89
|
@attempts << attempt
|
|
@@ -134,7 +142,7 @@ module Html2rss
|
|
|
134
142
|
state = run_attempts
|
|
135
143
|
return state.result if state.succeeded?
|
|
136
144
|
|
|
137
|
-
finalize_failure(
|
|
145
|
+
finalize_failure(state)
|
|
138
146
|
end
|
|
139
147
|
|
|
140
148
|
private
|
|
@@ -228,9 +236,24 @@ module Html2rss
|
|
|
228
236
|
"budget_remaining=#{budget_remaining_label}")
|
|
229
237
|
end
|
|
230
238
|
|
|
231
|
-
def finalize_failure(
|
|
232
|
-
|
|
233
|
-
|
|
239
|
+
def finalize_failure(state)
|
|
240
|
+
if raise_last_error?(state)
|
|
241
|
+
error = state.last_error
|
|
242
|
+
error.attempts = state.attempts if error.is_a?(Html2rss::Error)
|
|
243
|
+
raise error
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
raise NoFeedItemsExtracted.new(
|
|
247
|
+
attempts: state.attempts,
|
|
248
|
+
surface_category: surface_category_for(state.last_response)
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def raise_last_error?(state)
|
|
253
|
+
error = state.last_error
|
|
254
|
+
return false if error.nil? || error.is_a?(RequestService::BotasaurusConfigurationError)
|
|
255
|
+
|
|
256
|
+
true
|
|
234
257
|
end
|
|
235
258
|
|
|
236
259
|
def surface_category_for(response)
|
|
@@ -248,8 +271,15 @@ module Html2rss
|
|
|
248
271
|
Log.info("#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
249
272
|
"after timeout=#{error.class} #{detail}")
|
|
250
273
|
else
|
|
251
|
-
Log.warn(
|
|
252
|
-
|
|
274
|
+
Log.warn({
|
|
275
|
+
event_name: 'auto_fallback.error',
|
|
276
|
+
host:,
|
|
277
|
+
strategy: strategy.to_s,
|
|
278
|
+
next_strategy: next_strategy.to_s,
|
|
279
|
+
error_class: error.class.name,
|
|
280
|
+
message: "#{self.class}: auto fallback #{strategy} -> #{next_strategy} " \
|
|
281
|
+
"after error=#{error.class} #{detail}"
|
|
282
|
+
}.to_json)
|
|
253
283
|
end
|
|
254
284
|
Log.debug("#{self.class}: strategy=#{strategy} error=#{error.class}: #{error.message} #{detail}")
|
|
255
285
|
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
|
-
|
|
125
|
+
log_budget_exhausted
|
|
124
126
|
nil
|
|
125
127
|
rescue *NON_FALLBACK_ERRORS
|
|
126
128
|
raise
|
|
127
129
|
rescue StandardError => error
|
|
128
|
-
|
|
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
|
|
|
@@ -47,6 +47,17 @@ module Html2rss
|
|
|
47
47
|
message: 'Blocked surface detected: Vercel Security Checkpoint. ' \
|
|
48
48
|
'This site is a JS-rendered SPA behind Vercel edge protection. ' \
|
|
49
49
|
'Configure BOTASAURUS_SCRAPER_URL or target a direct listing URL.'
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
key: :http_403_forbidden,
|
|
53
|
+
min_matches: 2,
|
|
54
|
+
patterns: [
|
|
55
|
+
%r{<title>\s*403\s+forbidden\s*</title>}i,
|
|
56
|
+
%r{<h1>\s*403\s+forbidden\s*</h1>}i
|
|
57
|
+
],
|
|
58
|
+
message: 'Blocked surface detected: HTTP 403 Forbidden access block page. ' \
|
|
59
|
+
'Target a direct listing URL, configure BOTASAURUS_SCRAPER_URL, ' \
|
|
60
|
+
'or run from an environment that is not IP-blocked.'
|
|
50
61
|
}
|
|
51
62
|
].freeze
|
|
52
63
|
|
|
@@ -198,6 +198,11 @@ module Html2rss
|
|
|
198
198
|
"Botasaurus scrape failed (#{details.join(', ')})."
|
|
199
199
|
end
|
|
200
200
|
|
|
201
|
+
# @return [Hash{String => Object}] allowlisted diagnostics plus error_category (frozen)
|
|
202
|
+
def transport_meta
|
|
203
|
+
diagnostics.merge('error_category' => error_category).freeze
|
|
204
|
+
end
|
|
205
|
+
|
|
201
206
|
private
|
|
202
207
|
|
|
203
208
|
attr_reader :payload, :transport_status
|
|
@@ -49,14 +49,16 @@ module Html2rss
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def raise_from_error!(error)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
err = if error.challenge_block?
|
|
53
|
+
BlockedSurfaceDetected.new("Blocked surface detected: #{error.challenge_message}")
|
|
54
|
+
elsif error.timeout?
|
|
55
|
+
log_timeout!(reason: 'botasaurus_upstream')
|
|
56
|
+
RequestTimedOut.new(error.failure_message, timeout_phase: error.timeout_phase)
|
|
57
|
+
else
|
|
58
|
+
BotasaurusServiceError.new(error.failure_message)
|
|
59
|
+
end
|
|
60
|
+
err.transport_meta = error.transport_meta
|
|
61
|
+
raise err
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
def response_url(final_url)
|
|
@@ -60,8 +60,18 @@ module Html2rss
|
|
|
60
60
|
|
|
61
61
|
private
|
|
62
62
|
|
|
63
|
+
# Caps one HTTPX execute (including internal MAX_RETRIES) so a hanging
|
|
64
|
+
# :default tier cannot consume the full shared auto-fallback budget.
|
|
65
|
+
#
|
|
66
|
+
# @return [Float] monotonic deadline
|
|
63
67
|
def request_deadline
|
|
64
|
-
|
|
68
|
+
budget_timeout = ctx.budget.effective_timeout_seconds(fallback: ctx.policy.total_timeout_seconds)
|
|
69
|
+
monotonic_now + [budget_timeout, attempt_timeout_ceiling].min
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Float] connect + read ceiling for a single attempt
|
|
73
|
+
def attempt_timeout_ceiling
|
|
74
|
+
(ctx.policy.connect_timeout_seconds + ctx.policy.read_timeout_seconds).to_f
|
|
65
75
|
end
|
|
66
76
|
|
|
67
77
|
def reset_redirect_tracking!
|
|
@@ -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
|
|
75
|
-
def json_response?
|
|
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
|
|
@@ -90,23 +98,49 @@ module Html2rss
|
|
|
90
98
|
# @return [Response] the response from the executed strategy.
|
|
91
99
|
# @raise [UnknownStrategy] if the strategy is not registered.
|
|
92
100
|
def execute(ctx, strategy: default_strategy_name)
|
|
93
|
-
strategy_sym = strategy.
|
|
101
|
+
strategy_sym = resolve_strategy_name(strategy, relation: ctx.relation, host: ctx.url.host)
|
|
94
102
|
strategy_class = STRATEGIES.fetch(strategy_sym) do
|
|
95
103
|
raise UnknownStrategy,
|
|
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
|
|
100
107
|
strategy_class.new(ctx).execute
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
private
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
##
|
|
113
|
+
# Resolves deprecated migration aliases to their replacement strategy name.
|
|
114
|
+
# @param strategy [Symbol, String] requested strategy name
|
|
115
|
+
# @param relation [Symbol] request relation (+ :initial + or follow-up)
|
|
116
|
+
# @param host [String, nil] request host for structured deprecation telemetry
|
|
117
|
+
# @return [Symbol] canonical strategy name after migration remapping
|
|
118
|
+
def resolve_strategy_name(strategy, relation:, host:)
|
|
119
|
+
strategy_sym = strategy.to_sym
|
|
120
|
+
replacement = MIGRATION_REPLACEMENTS[strategy_sym]
|
|
121
|
+
return strategy_sym unless replacement
|
|
122
|
+
|
|
123
|
+
warn_migration_strategy(strategy_sym, replacement, relation:, host:)
|
|
124
|
+
replacement
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
##
|
|
128
|
+
# Emits a structured +strategy_deprecated+ warning once per session (+ :initial + only).
|
|
129
|
+
# @param strategy [Symbol] deprecated strategy alias
|
|
130
|
+
# @param replacement [Symbol] preferred strategy name
|
|
131
|
+
# @param relation [Symbol] request relation
|
|
132
|
+
# @param host [String, nil] request host
|
|
133
|
+
# @return [void]
|
|
134
|
+
def warn_migration_strategy(strategy, replacement, relation:, host:)
|
|
135
|
+
return unless relation == :initial
|
|
136
|
+
|
|
137
|
+
Log.warn({
|
|
138
|
+
event_name: 'strategy_deprecated',
|
|
139
|
+
strategy: strategy.to_s,
|
|
140
|
+
replacement: replacement.to_s,
|
|
141
|
+
host:,
|
|
142
|
+
message: "#{name}: strategy=#{strategy.inspect} is deprecated; using #{replacement.inspect} instead."
|
|
143
|
+
}.to_json)
|
|
110
144
|
end
|
|
111
145
|
end
|
|
112
146
|
end
|
data/lib/html2rss/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.32.1
|
|
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.
|
|
481
|
+
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.32.1
|
|
481
482
|
rubygems_mfa_required: 'true'
|
|
482
483
|
rdoc_options: []
|
|
483
484
|
require_paths:
|