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.
- checksums.yaml +4 -4
- data/README.md +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -22
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +34 -15
- data/lib/html2rss/auto_source/segmenter/list.rb +3 -1
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +6 -7
- data/lib/html2rss/auto_source/segmenter.rb +30 -1
- data/lib/html2rss/auto_source.rb +7 -2
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +64 -5
- data/lib/html2rss/config/auto_source_contract.rb +3 -1
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +18 -27
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +10 -3
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/sst_article_extractor.rb +36 -7
- data/lib/html2rss/link_destination/noise_policy.rb +7 -18
- data/lib/html2rss/link_destination/path_classifier.rb +3 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +45 -2
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/container_assessor.rb +9 -15
- data/lib/html2rss/scoring/engine.rb +49 -5
- data/lib/html2rss/scoring/link_resolver.rb +12 -0
- data/lib/html2rss/scoring/ranked_segment.rb +0 -12
- data/lib/html2rss/scoring/score.rb +1 -20
- data/lib/html2rss/scoring.rb +0 -25
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +13 -0
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -1
- data/schema/html2rss-config.schema.json +408 -85
- metadata +54 -12
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- data/lib/html2rss/request_service/puppet_commander.rb +0 -95
- data/lib/html2rss/scoring/anchor_score.rb +0 -34
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2df4782641361435095b1adbc805a0b5f8bbf47637450a271b212e0905e025a1
|
|
4
|
+
data.tar.gz: 38542dd16fd74c1983be1d20cfc49b64508ce70eeb501f0a312e0b9777c59f07
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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 '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
#
|
|
44
|
+
# Surface diagnostics shared with {Html2rss::NoFeedItemsExtracted} (one string home).
|
|
43
45
|
CATEGORY_MESSAGES = {
|
|
44
|
-
blocked_surface: '
|
|
45
|
-
'
|
|
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: '
|
|
48
|
-
'server-rendered article HTML).
|
|
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: '
|
|
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(
|
|
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?(
|
|
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 =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|