html2rss 0.24.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/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- 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 +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -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 +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- 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/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- 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 +23 -1
- 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/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- 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
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
|
|
@@ -1,21 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'nokogiri'
|
|
4
|
-
|
|
5
3
|
module Html2rss
|
|
6
4
|
class AutoSource
|
|
7
5
|
module Scraper
|
|
8
6
|
##
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# The approach is intentionally heuristic:
|
|
13
|
-
# 1. collect repeated anchor paths
|
|
14
|
-
# 2. walk upward to a shared container shape
|
|
15
|
-
# 3. extract the best anchor found inside each container
|
|
16
|
-
#
|
|
17
|
-
# This scraper is broader and noisier than `SemanticHtml`, so it acts as a
|
|
18
|
-
# fallback for pages without stronger semantic signals.
|
|
7
|
+
# Fallback HTML list/cluster scraper via SST pipeline
|
|
8
|
+
# (Normalizer → Segmenter → Scoring::Engine → Html::SstArticleExtractor).
|
|
19
9
|
class Html
|
|
20
10
|
include Enumerable
|
|
21
11
|
|
|
@@ -25,53 +15,44 @@ module Html2rss
|
|
|
25
15
|
DEFAULT_MINIMUM_SELECTOR_FREQUENCY = 2
|
|
26
16
|
# Number of most frequent selectors kept for container extraction.
|
|
27
17
|
DEFAULT_USE_TOP_SELECTORS = 5
|
|
18
|
+
# Maximum articles materialized after eligibility filtering.
|
|
19
|
+
TOP_K = Scoring::Engine::TOP_K
|
|
28
20
|
|
|
29
21
|
##
|
|
30
|
-
# @return [Symbol]
|
|
22
|
+
# @return [Symbol]
|
|
31
23
|
def self.options_key = :html
|
|
32
24
|
|
|
33
25
|
##
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# containers.
|
|
37
|
-
#
|
|
38
|
-
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
39
|
-
# @return [Boolean] true when the scraper can likely extract articles
|
|
26
|
+
# @param parsed_body [Nokogiri::HTML::Document]
|
|
27
|
+
# @return [Boolean]
|
|
40
28
|
def self.articles?(parsed_body)
|
|
41
|
-
|
|
42
|
-
end
|
|
29
|
+
return false unless parsed_body
|
|
43
30
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
#
|
|
48
|
-
# @param xpath [String] original XPath
|
|
49
|
-
# @return [String] XPath without positional indexes
|
|
50
|
-
def self.simplify_xpath(xpath)
|
|
51
|
-
Discovery::ListCandidates.simplify_xpath(xpath)
|
|
31
|
+
new(parsed_body, url: DETECTION_BASE_URL).extractable?
|
|
32
|
+
rescue ArgumentError
|
|
33
|
+
false
|
|
52
34
|
end
|
|
53
35
|
|
|
54
|
-
# @param parsed_body [Nokogiri::HTML::Document]
|
|
55
|
-
# @param url [String]
|
|
56
|
-
# @param
|
|
57
|
-
# @param
|
|
58
|
-
# @
|
|
59
|
-
# @option opts [
|
|
60
|
-
|
|
36
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
|
|
37
|
+
# @param url [String, Html2rss::Url]
|
|
38
|
+
# @param document [SST::Document, nil] memoized SST document from AutoSource
|
|
39
|
+
# @param link_resolver [Scoring::LinkResolver, nil] shared page-scoped resolver
|
|
40
|
+
# @param opts [Hash]
|
|
41
|
+
# @option opts [Boolean] :fallback_anchorless keep anchorless cluster cards
|
|
42
|
+
# @option opts [Integer] :minimum_selector_frequency list frequency floor
|
|
43
|
+
# @option opts [Integer] :use_top_selectors list selector budget
|
|
44
|
+
def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil, **opts)
|
|
61
45
|
@parsed_body = parsed_body
|
|
46
|
+
@provided_document = document
|
|
47
|
+
@provided_link_resolver = link_resolver
|
|
62
48
|
@url = url
|
|
63
|
-
@extractor = extractor
|
|
64
49
|
@opts = opts
|
|
65
50
|
@fallback_anchorless = opts.fetch(:fallback_anchorless, false)
|
|
66
|
-
@link_heuristics = LinkHeuristics.new(url)
|
|
67
|
-
@ignored_cache = {}.compare_by_identity
|
|
68
51
|
end
|
|
69
52
|
|
|
70
|
-
attr_reader :parsed_body
|
|
71
|
-
|
|
72
53
|
##
|
|
73
|
-
# @yieldparam [
|
|
74
|
-
# @return [Enumerator]
|
|
54
|
+
# @yieldparam article [Html2rss::Article]
|
|
55
|
+
# @return [Enumerator]
|
|
75
56
|
def each
|
|
76
57
|
return enum_for(:each) unless block_given?
|
|
77
58
|
|
|
@@ -79,135 +60,77 @@ module Html2rss
|
|
|
79
60
|
end
|
|
80
61
|
|
|
81
62
|
##
|
|
82
|
-
# @return [Boolean]
|
|
63
|
+
# @return [Boolean]
|
|
83
64
|
def extractable?
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
##
|
|
88
|
-
# Decides whether a traversed node has reached a useful article-like
|
|
89
|
-
# boundary for the generic HTML scraper.
|
|
90
|
-
#
|
|
91
|
-
# The predicate prefers containers that add surrounding link context,
|
|
92
|
-
# which helps the scraper move from a leaf anchor toward a repeated
|
|
93
|
-
# teaser/card wrapper.
|
|
94
|
-
#
|
|
95
|
-
# @param node [Nokogiri::XML::Node] candidate boundary node
|
|
96
|
-
# @return [Boolean] true when the node is a good extraction boundary
|
|
97
|
-
def article_tag_condition?(node)
|
|
98
|
-
# Ignore tags that are below ignored DOM chrome.
|
|
99
|
-
return false if Html2rss::Html::Navigator.ignored_container_path?(node, @ignored_cache)
|
|
100
|
-
return true if %w[body html].include?(node.name)
|
|
101
|
-
return false unless (parent = node.parent)
|
|
102
|
-
|
|
103
|
-
anchor_count(parent) > anchor_count(node)
|
|
65
|
+
ranked_segments.any?
|
|
66
|
+
rescue ArgumentError
|
|
67
|
+
false
|
|
104
68
|
end
|
|
105
69
|
|
|
106
70
|
private
|
|
107
71
|
|
|
108
72
|
def articles
|
|
109
73
|
@articles ||= begin
|
|
110
|
-
|
|
111
|
-
|
|
74
|
+
ranked = list_ranked
|
|
75
|
+
if ranked.empty? && @fallback_anchorless
|
|
76
|
+
materialize(cluster_ranked, fallback_anchorless: true)
|
|
77
|
+
else
|
|
78
|
+
materialize(ranked)
|
|
112
79
|
end
|
|
113
|
-
|
|
114
|
-
extracted += find_anchorless_articles if @fallback_anchorless
|
|
115
|
-
extracted
|
|
116
80
|
end
|
|
81
|
+
rescue ArgumentError
|
|
82
|
+
[]
|
|
117
83
|
end
|
|
118
84
|
|
|
119
|
-
def
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
@extractor.new(node, base_url: @url, selected_anchor: nil, fallback_anchorless: true).call
|
|
85
|
+
def ranked_segments
|
|
86
|
+
@ranked_segments ||= begin
|
|
87
|
+
ranked = list_ranked
|
|
88
|
+
ranked = cluster_ranked if @fallback_anchorless && ranked.empty?
|
|
89
|
+
ranked
|
|
125
90
|
end
|
|
126
91
|
end
|
|
127
92
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def minimum_selector_frequency = @opts[:minimum_selector_frequency] || DEFAULT_MINIMUM_SELECTOR_FREQUENCY
|
|
131
|
-
|
|
132
|
-
##
|
|
133
|
-
# @return [Boolean]
|
|
134
|
-
def use_top_selectors = @opts[:use_top_selectors] || DEFAULT_USE_TOP_SELECTORS
|
|
135
|
-
|
|
136
|
-
##
|
|
137
|
-
# @param node [Nokogiri::XML::Node]
|
|
138
|
-
# @return [Integer]
|
|
139
|
-
def anchor_count(node)
|
|
140
|
-
(@anchor_counts ||= {}.compare_by_identity)[node] ||= node.name == 'a' ? 1 : node.css('a').size
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
##
|
|
144
|
-
# @param node [Nokogiri::XML::Node]
|
|
145
|
-
# @return [Boolean]
|
|
146
|
-
def relevant_anchor?(node)
|
|
147
|
-
destination_facts = @link_heuristics.destination_facts(node)
|
|
148
|
-
return false unless destination_facts
|
|
149
|
-
|
|
150
|
-
!noise_anchor?(node, destination_facts:)
|
|
93
|
+
def list_ranked
|
|
94
|
+
@list_ranked ||= rank_strategy(:list, permit_unanchored: false)
|
|
151
95
|
end
|
|
152
96
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
# @yieldparam article_tag [Nokogiri::XML::Node]
|
|
156
|
-
# @yieldparam selected_anchor [Nokogiri::XML::Node]
|
|
157
|
-
# @return [Enumerator, nil]
|
|
158
|
-
def each_article_tag(&block)
|
|
159
|
-
return enum_for(:each_article_tag) unless block
|
|
160
|
-
|
|
161
|
-
anchor_filter = ->(node) { relevant_anchor?(node) }
|
|
162
|
-
boundary_condition = ->(node) { article_tag_condition?(node) }
|
|
163
|
-
|
|
164
|
-
list_candidates.each_article_tag(anchor_filter:, boundary_condition:, &block)
|
|
97
|
+
def cluster_ranked
|
|
98
|
+
@cluster_ranked ||= rank_strategy(:cluster, permit_unanchored: true)
|
|
165
99
|
end
|
|
166
100
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
destination_facts: @link_heuristics.destination_facts(selected_anchor),
|
|
177
|
-
container: article_tag
|
|
101
|
+
def rank_strategy(strategy, permit_unanchored:)
|
|
102
|
+
segments = Segmenter.call(
|
|
103
|
+
document,
|
|
104
|
+
base_url: @url,
|
|
105
|
+
strategy:,
|
|
106
|
+
permit_unanchored:,
|
|
107
|
+
minimum_selector_frequency:,
|
|
108
|
+
use_top_selectors:,
|
|
109
|
+
link_resolver:
|
|
178
110
|
)
|
|
179
|
-
|
|
180
|
-
@extractor.call(article_tag, base_url: @url, selected_anchor:)
|
|
111
|
+
engine.select_eligible(segments, limit: TOP_K)
|
|
181
112
|
end
|
|
182
113
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
# @return [Boolean]
|
|
187
|
-
def noise_anchor?(anchor, destination_facts:, container: nil)
|
|
188
|
-
(@noise_anchors ||= {}.compare_by_identity)[anchor] ||= begin
|
|
189
|
-
text = Html2rss::Html::Navigator.extract_visible_text(anchor).to_s.strip
|
|
190
|
-
@link_heuristics.noise_anchor?(text:, destination_facts:, anchor:, container:)
|
|
114
|
+
def materialize(ranked, fallback_anchorless: false)
|
|
115
|
+
ranked.filter_map do |entry|
|
|
116
|
+
::Html2rss::Html::SstArticleExtractor.call(entry, base_url: @url, scraper: self.class, fallback_anchorless:)
|
|
191
117
|
end
|
|
192
118
|
end
|
|
193
119
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
# @return [Nokogiri::XML::Node, nil]
|
|
197
|
-
def preferred_anchor_for(article_tag)
|
|
198
|
-
article_tag.css(Html2rss::Html::Navigator::MAIN_ANCHOR_SELECTOR).find { relevant_anchor?(_1) } ||
|
|
199
|
-
Html2rss::Html::Navigator.main_anchor_for(article_tag)
|
|
120
|
+
def engine
|
|
121
|
+
@engine ||= Scoring::Engine.new(link_resolver:)
|
|
200
122
|
end
|
|
201
123
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def list_candidates
|
|
205
|
-
Discovery::ListCandidates.new(
|
|
206
|
-
parsed_body,
|
|
207
|
-
minimum_selector_frequency:,
|
|
208
|
-
use_top_selectors:
|
|
209
|
-
)
|
|
124
|
+
def link_resolver
|
|
125
|
+
@link_resolver ||= @provided_link_resolver || Scoring::LinkResolver.new(@url)
|
|
210
126
|
end
|
|
127
|
+
|
|
128
|
+
def document
|
|
129
|
+
@document ||= @provided_document || SST::Normalizer.call(@parsed_body)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def minimum_selector_frequency = @opts[:minimum_selector_frequency] || DEFAULT_MINIMUM_SELECTOR_FREQUENCY
|
|
133
|
+
def use_top_selectors = @opts[:use_top_selectors] || DEFAULT_USE_TOP_SELECTORS
|
|
211
134
|
end
|
|
212
135
|
end
|
|
213
136
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
module Scraper
|
|
6
|
+
class JsonState
|
|
7
|
+
# Shapes raw entries into the structure required downstream.
|
|
8
|
+
module ArticleNormalizer
|
|
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
|
+
# Preferred keys when extracting description-like values from state payloads.
|
|
14
|
+
DESCRIPTION_KEYS = %i[description summary excerpt dek subheading].freeze
|
|
15
|
+
# Preferred keys when extracting image-like values from state payloads.
|
|
16
|
+
IMAGE_KEYS = %i[image imageUrl thumbnailUrl thumbnail src featuredImage coverImage heroImage].freeze
|
|
17
|
+
# Preferred keys when extracting publication timestamps from state payloads.
|
|
18
|
+
PUBLISHED_AT_KEYS = %i[published_at publishedAt datePublished date publicationDate pubDate updatedAt
|
|
19
|
+
updated_at createdAt created_at].freeze
|
|
20
|
+
# Preferred keys when extracting category-like values from state payloads.
|
|
21
|
+
CATEGORY_KEYS = %i[categories tags section sections topic topics channel].freeze
|
|
22
|
+
# Preferred keys when extracting identifier-like values from state payloads.
|
|
23
|
+
ID_KEYS = %i[id guid uuid slug key].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# rubocop:disable Metrics/MethodLength
|
|
28
|
+
# @param entry [Hash] raw article entry candidate
|
|
29
|
+
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
30
|
+
# @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
|
|
31
|
+
def normalise(entry, base_url:)
|
|
32
|
+
return unless entry.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
title = string(ValueFinder.fetch(entry, TITLE_KEYS))
|
|
35
|
+
description = string(ValueFinder.fetch(entry, DESCRIPTION_KEYS))
|
|
36
|
+
article_url = resolve_link(entry, keys: URL_KEYS, base_url:,
|
|
37
|
+
log_key: 'JsonState: invalid URL encountered')
|
|
38
|
+
return unless article_url
|
|
39
|
+
return if title.nil? && description.nil?
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
title:,
|
|
43
|
+
description:,
|
|
44
|
+
url: article_url,
|
|
45
|
+
image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
|
|
46
|
+
log_key: 'JsonState: invalid image URL encountered'),
|
|
47
|
+
published_at: string(ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)),
|
|
48
|
+
categories: categories(entry),
|
|
49
|
+
id: identifier(entry, article_url)
|
|
50
|
+
}.compact
|
|
51
|
+
end
|
|
52
|
+
# rubocop:enable Metrics/MethodLength
|
|
53
|
+
|
|
54
|
+
# @param value [Object] candidate scalar value
|
|
55
|
+
# @return [String, nil] normalized non-empty string value
|
|
56
|
+
def string(value)
|
|
57
|
+
trimmed = value.to_s.strip
|
|
58
|
+
trimmed unless trimmed.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param entry [Hash] raw article entry candidate
|
|
62
|
+
# @param keys [Array<String>] preferred link keys
|
|
63
|
+
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
64
|
+
# @param log_key [String] structured log message key
|
|
65
|
+
# @return [Html2rss::Url, nil] resolved absolute URL
|
|
66
|
+
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)
|
|
69
|
+
string = string(value)
|
|
70
|
+
return unless string
|
|
71
|
+
|
|
72
|
+
Url.from_relative(string, base_url)
|
|
73
|
+
rescue ArgumentError
|
|
74
|
+
Log.debug(log_key, url: string)
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# rubocop:disable Metrics/MethodLength
|
|
79
|
+
# @param entry [Hash] raw article entry candidate
|
|
80
|
+
# @return [Array<String>, nil] normalized unique categories
|
|
81
|
+
def categories(entry)
|
|
82
|
+
raw = ValueFinder.fetch(entry, CATEGORY_KEYS)
|
|
83
|
+
names = case raw
|
|
84
|
+
when Array then raw
|
|
85
|
+
when Hash then raw.values
|
|
86
|
+
when String then [raw]
|
|
87
|
+
else []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
result = names.flat_map do |value|
|
|
91
|
+
case value
|
|
92
|
+
when Hash
|
|
93
|
+
string(ValueFinder.fetch(value, %i[name title label]))
|
|
94
|
+
else
|
|
95
|
+
string(value)
|
|
96
|
+
end
|
|
97
|
+
end.compact
|
|
98
|
+
|
|
99
|
+
result.uniq!
|
|
100
|
+
result unless result.empty?
|
|
101
|
+
end
|
|
102
|
+
# rubocop:enable Metrics/MethodLength
|
|
103
|
+
|
|
104
|
+
# @param entry [Hash] raw article entry candidate
|
|
105
|
+
# @param article_url [Html2rss::Url] resolved article URL
|
|
106
|
+
# @return [String] stable article identifier fallbacking to resolved URL
|
|
107
|
+
def identifier(entry, article_url)
|
|
108
|
+
value = ValueFinder.fetch(entry, ID_KEYS)
|
|
109
|
+
value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
|
|
110
|
+
string(value) || article_url.to_s
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|