html2rss 0.22.2 → 0.23.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 +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +206 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +21 -2
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +118 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/json_feed/item.rb +96 -0
- data/lib/html2rss/feed_builder/json_feed.rb +61 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +23 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +59 -42
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +52 -65
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +0 -4
- data/lib/html2rss/request_service/botasaurus_strategy.rb +9 -27
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +90 -16
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +102 -6
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +25 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +63 -65
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +19 -19
- data/schema/html2rss-config.schema.json +109 -9
- metadata +68 -42
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f53482ae1084593193664ab8301bd451f53f68b4afabc8f0a336d9ae6dcd357c
|
|
4
|
+
data.tar.gz: 65ef2c8261a216040484134f71de9b8579378d6f7aa20100d0c79cf684efcd65
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18c6b5dfb6f23753b1c4885339b16505800b91235e65f397d7195d2301b7c32974c12be0087c35c2e63a5bd08185518ea49c6bd252cc68f22521814c6f6e74f8
|
|
7
|
+
data.tar.gz: 458fff5c3579ea95190e458a9cf5814418467fdfc37cedc1244faf4d7c7eb6582640b9d8465329f68eac3624c33c1691514da25c1ce1a7cb6ddb2c7999c38638
|
data/README.md
CHANGED
|
@@ -13,125 +13,28 @@ 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`, `browserless`)
|
|
17
|
+
- [Selectors & pagination](https://html2rss.github.io/ruby-gem/reference/selectors#paginated-feeds)
|
|
16
18
|
- [Web application](https://html2rss.github.io/web-application)
|
|
17
19
|
- [Feed directory](https://html2rss.github.io/feed-directory)
|
|
18
20
|
- [Contributing guide](https://html2rss.github.io/get-involved/contributing)
|
|
19
21
|
- [GitHub Discussions](https://github.com/orgs/html2rss/discussions)
|
|
20
22
|
- [Sponsor on GitHub](https://github.com/sponsors/gildesmarais)
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new?repo=html2rss/html2rss) (also covered in the [installation guide](https://html2rss.github.io/ruby-gem/installation)).
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
## Architecture
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Please see the [contributing guide](https://html2rss.github.io/get-involved/contributing) for details on how to contribute.
|
|
33
|
-
|
|
34
|
-
## 🏗️ Architecture
|
|
35
|
-
|
|
36
|
-
### Core Components
|
|
37
|
-
|
|
38
|
-
1. **Config** - Loads and validates configuration (YAML/hash)
|
|
39
|
-
2. **RequestService** - Fetches pages using Faraday, Botasaurus, or Browserless
|
|
40
|
-
3. **Selectors** - Extracts content via CSS selectors with extractors/post-processors
|
|
41
|
-
4. **AutoSource** - Auto-detects content using Schema.org, JSON state blobs, semantic HTML, and structural patterns
|
|
42
|
-
5. **RssBuilder** - Assembles Article objects and renders RSS 2.0
|
|
43
|
-
|
|
44
|
-
### Data Flow
|
|
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 `browserless`)
|
|
30
|
+
3. **Selectors** — extracts content via CSS selectors with extractors/post-processors
|
|
31
|
+
4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns)
|
|
32
|
+
5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1)
|
|
45
33
|
|
|
46
34
|
```text
|
|
47
35
|
Config -> Request -> Extraction -> Processing -> Building -> Output
|
|
48
36
|
```
|
|
49
37
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- `auto` (default): pipeline fallback orchestration (`faraday` -> `botasaurus` -> `browserless`) based on extraction outcome and retry policy.
|
|
53
|
-
- `faraday`: direct HTTP fetch.
|
|
54
|
-
- `botasaurus`: delegates fetching to a Botasaurus scrape API. Requires `BOTASAURUS_SCRAPER_URL` (for example `http://localhost:4010`).
|
|
55
|
-
- `browserless`: remote browser rendering via Browserless (`BROWSERLESS_IO_WEBSOCKET_URL` and token as needed).
|
|
56
|
-
|
|
57
|
-
Auto fallback shares one request budget across all strategy attempts. For pagination-heavy or dynamic pages, increase `request.max_requests` (or `--max-requests`) when retries exhaust the budget.
|
|
58
|
-
|
|
59
|
-
Auto fallback decisions are hidden at the default `LOG_LEVEL=warn`; run with `LOG_LEVEL=info` to include them in CLI output.
|
|
60
|
-
|
|
61
|
-
Supported `request.botasaurus` options:
|
|
62
|
-
|
|
63
|
-
- `navigation_mode` (`auto`, `get`, `google_get`, `google_get_bypass`; default `auto`)
|
|
64
|
-
- `max_retries` (`0..3`; default `2`)
|
|
65
|
-
- `wait_for_selector` (string)
|
|
66
|
-
- `wait_timeout_seconds` (integer)
|
|
67
|
-
- `block_images` (boolean)
|
|
68
|
-
- `block_images_and_css` (boolean)
|
|
69
|
-
- `wait_for_complete_page_load` (boolean)
|
|
70
|
-
- `headless` (boolean, default `false`)
|
|
71
|
-
- `proxy` (string)
|
|
72
|
-
- `user_agent` (string)
|
|
73
|
-
- `window_size` (two-item integer array, for example `[1920, 1080]`)
|
|
74
|
-
- `lang` (string, for example `en-US`)
|
|
75
|
-
|
|
76
|
-
Minimal YAML config example:
|
|
77
|
-
|
|
78
|
-
```yaml
|
|
79
|
-
channel:
|
|
80
|
-
url: https://example.com
|
|
81
|
-
strategy: botasaurus
|
|
82
|
-
auto_source: {}
|
|
83
|
-
request:
|
|
84
|
-
botasaurus:
|
|
85
|
-
navigation_mode: auto
|
|
86
|
-
max_retries: 2
|
|
87
|
-
headless: false
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
Example request payload shape:
|
|
91
|
-
|
|
92
|
-
```json
|
|
93
|
-
{
|
|
94
|
-
"url": "https://example.com",
|
|
95
|
-
"navigation_mode": "auto",
|
|
96
|
-
"max_retries": 2,
|
|
97
|
-
"headless": false
|
|
98
|
-
}
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
Example usage:
|
|
102
|
-
|
|
103
|
-
```bash
|
|
104
|
-
BOTASAURUS_SCRAPER_URL=http://localhost:4010 html2rss auto https://example.com --strategy botasaurus
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
Policy note: html2rss still enforces local request policy preflight and timeout budget. Botasaurus handles browser navigation/rendering internals, so some policy details are delegated to upstream execution.
|
|
108
|
-
|
|
109
|
-
### Config schema workflow
|
|
110
|
-
|
|
111
|
-
The config schema is generated from the runtime `dry-validation` contracts and exported for client-side tooling.
|
|
112
|
-
|
|
113
|
-
- Ruby API: `Html2rss::Config.json_schema`
|
|
114
|
-
- CLI: `html2rss schema`
|
|
115
|
-
- CLI options:
|
|
116
|
-
- `html2rss schema --write tmp/html2rss-config.schema.json`
|
|
117
|
-
- `html2rss schema --no-pretty`
|
|
118
|
-
- Runtime validation API: `Html2rss::Config.validate(config_hash)`
|
|
119
|
-
- Runtime validation CLI: `html2rss validate config.yml`
|
|
120
|
-
- Packaged JSON file: `schema/html2rss-config.schema.json`
|
|
121
|
-
|
|
122
|
-
If you are an editor integration, automation script, or AI tool, prefer these stable discovery points:
|
|
123
|
-
|
|
124
|
-
- call `html2rss schema` to read the current exported schema
|
|
125
|
-
- read `schema/html2rss-config.schema.json` when working from the repository or installed gem
|
|
126
|
-
- use `Html2rss::Config.schema_path` if you already have Ruby loaded
|
|
127
|
-
- use `Html2rss::Config.validate` or `html2rss validate config.yml` when you need authoritative runtime validation of selector references
|
|
128
|
-
|
|
129
|
-
Run `bundle exec rake config:schema` before committing to regenerate `schema/html2rss-config.schema.json` and keep the checked-in JSON Schema in sync with the validators. The exported schema covers client-side validation, while runtime validation remains authoritative for dynamic cross-field checks such as selector-key references.
|
|
130
|
-
|
|
131
|
-
## 📄 License
|
|
132
|
-
|
|
133
|
-
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
134
|
-
|
|
135
|
-
## 💖 Sponsoring
|
|
38
|
+
## License
|
|
136
39
|
|
|
137
|
-
|
|
40
|
+
This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details.
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
require 'set' # rubocop:disable Lint/RedundantRequireStatement
|
|
4
4
|
|
|
5
5
|
module Html2rss
|
|
6
|
-
|
|
7
|
-
module Articles
|
|
6
|
+
class Article
|
|
8
7
|
##
|
|
9
8
|
# Deduplicates a list of articles while preserving their original order.
|
|
10
9
|
#
|
|
@@ -15,7 +14,7 @@ module Html2rss
|
|
|
15
14
|
# defaults to the article object's hash to preserve the original entry.
|
|
16
15
|
class Deduplicator
|
|
17
16
|
##
|
|
18
|
-
# @param articles [Array<Html2rss::
|
|
17
|
+
# @param articles [Array<Html2rss::Article>]
|
|
19
18
|
# @raise [ArgumentError] if articles are not provided
|
|
20
19
|
def initialize(articles)
|
|
21
20
|
raise ArgumentError, 'articles must be provided' unless articles
|
|
@@ -26,7 +25,7 @@ module Html2rss
|
|
|
26
25
|
##
|
|
27
26
|
# Returns the list of unique articles, preserving the order of the
|
|
28
27
|
# original collection and keeping the first occurrence of a duplicate.
|
|
29
|
-
# @return [Array<Html2rss::
|
|
28
|
+
# @return [Array<Html2rss::Article>]
|
|
30
29
|
def call
|
|
31
30
|
seen = Set.new
|
|
32
31
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
require 'mime/types'
|
|
4
4
|
|
|
5
5
|
module Html2rss
|
|
6
|
-
class
|
|
6
|
+
class Article
|
|
7
7
|
##
|
|
8
|
-
# Represents an enclosure
|
|
8
|
+
# Represents an enclosure attached to an article (RSS / JSON Feed media).
|
|
9
9
|
class Enclosure
|
|
10
10
|
##
|
|
11
11
|
# Guesses the content type based on the file extension of the URL.
|
|
@@ -24,38 +24,22 @@ module Html2rss
|
|
|
24
24
|
content_type.first&.to_s || 'application/octet-stream'
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# @param enclosure [Html2rss::RssBuilder::Enclosure, nil] built enclosure object for the current RSS item
|
|
28
|
-
# @param maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
|
|
29
|
-
# @return [void]
|
|
30
|
-
def self.add(enclosure, maker)
|
|
31
|
-
return unless enclosure
|
|
32
|
-
|
|
33
|
-
maker.enclosure.tap do |enclosure_maker|
|
|
34
|
-
enclosure_maker.url = enclosure.url.to_s
|
|
35
|
-
enclosure_maker.type = enclosure.type
|
|
36
|
-
enclosure_maker.length = enclosure.bits_length
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
27
|
# @param url [Html2rss::Url] absolute enclosure URL
|
|
41
28
|
# @param type [String, nil] optional enclosure MIME type
|
|
42
|
-
# @param
|
|
43
|
-
def initialize(url:, type: nil,
|
|
29
|
+
# @param bytes_length [Integer] enclosure length in bytes
|
|
30
|
+
def initialize(url:, type: nil, bytes_length: 0)
|
|
44
31
|
raise ArgumentError, 'An Enclosure requires an absolute URL' if !url || !url.absolute?
|
|
45
32
|
|
|
46
33
|
@url = url
|
|
47
34
|
@type = type
|
|
48
|
-
@
|
|
35
|
+
@bytes_length = bytes_length
|
|
49
36
|
end
|
|
50
37
|
|
|
51
38
|
# @return [String] explicit MIME type or one inferred from URL extension
|
|
52
39
|
def type = @type || self.class.guess_content_type_from_url(url)
|
|
53
40
|
|
|
54
41
|
# @return [Integer] enclosure length in bytes
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# @return [Integer] enclosure length in bytes (legacy reader name)
|
|
58
|
-
def bits_length = bytes_length
|
|
42
|
+
attr_reader :bytes_length
|
|
59
43
|
|
|
60
44
|
# @return [Html2rss::Url] absolute enclosure URL
|
|
61
45
|
attr_reader :url
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
require 'sanitize'
|
|
5
|
+
require 'nokogiri'
|
|
6
|
+
|
|
7
|
+
module Html2rss
|
|
8
|
+
##
|
|
9
|
+
# Article is a simple data object representing an article extracted from a page.
|
|
10
|
+
# It is enumerable and responds to all keys specified in PROVIDED_KEYS.
|
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
|
12
|
+
class Article
|
|
13
|
+
include Enumerable
|
|
14
|
+
include Comparable
|
|
15
|
+
|
|
16
|
+
# Allowed article attributes accepted by the value object constructor.
|
|
17
|
+
PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
|
|
18
|
+
# Separator used to build deterministic deduplication fingerprints.
|
|
19
|
+
DEDUP_FINGERPRINT_SEPARATOR = '#!/'
|
|
20
|
+
# Sentinel object used to pre-initialize instance variables in the constructor.
|
|
21
|
+
# This ensures all Article instances share the exact same object shape (Ruby 3.3+ optimization),
|
|
22
|
+
# preventing performance warnings and slower instance variable access due to shape transitions
|
|
23
|
+
# when attributes are lazily/conditionally accessed in different sequences.
|
|
24
|
+
NOT_SET = Object.new.freeze
|
|
25
|
+
|
|
26
|
+
# @param options [Hash{Symbol => String}]
|
|
27
|
+
# @option options [String] :id stable article identifier
|
|
28
|
+
# @option options [String] :title article title
|
|
29
|
+
# @option options [String] :description article description/content
|
|
30
|
+
# @option options [String, Html2rss::Url] :url canonical article URL
|
|
31
|
+
# @option options [String, Html2rss::Url] :image image URL for fallback enclosure rendering
|
|
32
|
+
# @option options [String] :author author name
|
|
33
|
+
# @option options [String] :guid explicit GUID override
|
|
34
|
+
# @option options [String, Time, DateTime] :published_at publication timestamp
|
|
35
|
+
# @option options [Array<Hash{Symbol => Object}>] :enclosures enclosure attribute hashes
|
|
36
|
+
# @option options [Array<String>] :categories category labels
|
|
37
|
+
# @option options [Class] :scraper scraper class that produced the article
|
|
38
|
+
def initialize(**options)
|
|
39
|
+
@to_h = options.each_with_object({}) { |(k, v), h| h[k] = v.freeze if v }.freeze
|
|
40
|
+
|
|
41
|
+
@description = @url = @image = @guid = @enclosures = @enclosure = @categories = @published_at = NOT_SET
|
|
42
|
+
|
|
43
|
+
return unless (unknown_keys = options.keys - PROVIDED_KEYS).any?
|
|
44
|
+
|
|
45
|
+
Log.warn "Article: unknown keys found: #{unknown_keys.join(', ')}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Checks if the article is valid based on the presence of URL, ID, and either title or description.
|
|
49
|
+
# @return [Boolean] True if the article is valid, otherwise false.
|
|
50
|
+
def valid?
|
|
51
|
+
!url.to_s.empty? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @yield [key, value]
|
|
55
|
+
# @return [Enumerator] if no block is given
|
|
56
|
+
def each
|
|
57
|
+
return enum_for(:each) unless block_given?
|
|
58
|
+
|
|
59
|
+
PROVIDED_KEYS.each { |key| yield(key, public_send(key)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [String, nil] stable article identifier
|
|
63
|
+
def id = blank_string_to_nil(@to_h[:id])
|
|
64
|
+
|
|
65
|
+
# @return [String, nil] article title
|
|
66
|
+
def title = blank_string_to_nil(@to_h[:title])
|
|
67
|
+
|
|
68
|
+
# @return [String] rendered article description
|
|
69
|
+
def description
|
|
70
|
+
return @description unless @description == NOT_SET
|
|
71
|
+
|
|
72
|
+
@description = Html::Rendering::DescriptionBuilder.new(
|
|
73
|
+
base: @to_h[:description],
|
|
74
|
+
title:,
|
|
75
|
+
url:,
|
|
76
|
+
enclosures:,
|
|
77
|
+
image:
|
|
78
|
+
).call
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Url, nil]
|
|
82
|
+
def url
|
|
83
|
+
return @url unless @url == NOT_SET
|
|
84
|
+
|
|
85
|
+
@url = Url.sanitize(@to_h[:url])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Url, nil]
|
|
89
|
+
def image
|
|
90
|
+
return @image unless @image == NOT_SET
|
|
91
|
+
|
|
92
|
+
@image = Url.sanitize(@to_h[:image])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [String, nil]
|
|
96
|
+
def author = blank_string_to_nil(@to_h[:author])
|
|
97
|
+
|
|
98
|
+
# Generates a unique identifier based on the URL and ID using CRC32.
|
|
99
|
+
# @return [String]
|
|
100
|
+
def guid
|
|
101
|
+
return @guid unless @guid == NOT_SET
|
|
102
|
+
|
|
103
|
+
@guid = Zlib.crc32(fetch_guid).to_s(36).encode('utf-8')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
##
|
|
107
|
+
# Returns a deterministic fingerprint used to detect duplicate articles.
|
|
108
|
+
#
|
|
109
|
+
# @return [String, Integer]
|
|
110
|
+
def deduplication_fingerprint
|
|
111
|
+
dedup_from_url || dedup_from_id || dedup_from_guid || hash
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Array<Html2rss::Article::Enclosure>] normalized enclosure objects
|
|
115
|
+
def enclosures
|
|
116
|
+
return @enclosures unless @enclosures == NOT_SET
|
|
117
|
+
|
|
118
|
+
@enclosures = Array(@to_h[:enclosures])
|
|
119
|
+
.map { |enclosure| Enclosure.new(**enclosure) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Html2rss::Article::Enclosure, nil]
|
|
123
|
+
def enclosure
|
|
124
|
+
return @enclosure unless @enclosure == NOT_SET
|
|
125
|
+
|
|
126
|
+
@enclosure = case (object = @to_h[:enclosures]&.first)
|
|
127
|
+
when Hash
|
|
128
|
+
Enclosure.new(**object)
|
|
129
|
+
when nil
|
|
130
|
+
Enclosure.new(url: image) if image
|
|
131
|
+
else
|
|
132
|
+
Log.warn "Article: unknown enclosure type: #{object.class}"
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [Array<String>] normalized, unique category names
|
|
138
|
+
def categories
|
|
139
|
+
return @categories unless @categories == NOT_SET
|
|
140
|
+
|
|
141
|
+
@categories = @to_h[:categories].dup.to_a.tap do |categories|
|
|
142
|
+
categories.map! { |category| category.to_s.strip }
|
|
143
|
+
categories.reject!(&:empty?)
|
|
144
|
+
categories.uniq!
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Parses and returns the published_at time.
|
|
149
|
+
# @return [DateTime, nil]
|
|
150
|
+
def published_at
|
|
151
|
+
return @published_at unless @published_at == NOT_SET
|
|
152
|
+
|
|
153
|
+
string = @to_h[:published_at].to_s.strip
|
|
154
|
+
@published_at = string.empty? ? nil : DateTime.parse(string)
|
|
155
|
+
rescue ArgumentError
|
|
156
|
+
@published_at = nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Class, nil] scraper class that produced this article
|
|
160
|
+
def scraper
|
|
161
|
+
@to_h[:scraper]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @param other [Object] value compared against this article
|
|
165
|
+
# @return [Integer, nil] comparison result for compatible Article values
|
|
166
|
+
def <=>(other)
|
|
167
|
+
return nil unless other.is_a?(Article)
|
|
168
|
+
|
|
169
|
+
0 if other.all? { |key, value| value == public_send(key) ? public_send(key) <=> value : false }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def dedup_from_url
|
|
175
|
+
return unless (value = url)
|
|
176
|
+
|
|
177
|
+
[value.to_s, id].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def dedup_from_id
|
|
181
|
+
return if id.to_s.empty?
|
|
182
|
+
|
|
183
|
+
id
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def dedup_from_guid
|
|
187
|
+
value = guid
|
|
188
|
+
return if value.to_s.empty?
|
|
189
|
+
|
|
190
|
+
[value, title, description].compact.join(DEDUP_FINGERPRINT_SEPARATOR)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def fetch_guid
|
|
194
|
+
guid = @to_h[:guid].map { |s| s.to_s.strip }.reject(&:empty?).join if @to_h[:guid].is_a?(Array)
|
|
195
|
+
|
|
196
|
+
guid || [url, id].join('#!/')
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def blank_string_to_nil(value)
|
|
200
|
+
return if value.is_a?(String) && value.strip.empty?
|
|
201
|
+
|
|
202
|
+
value
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
# rubocop:enable Metrics/ClassLength
|
|
206
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
module Discovery
|
|
6
|
+
class DomClustering
|
|
7
|
+
##
|
|
8
|
+
# Scores candidate DOM groups using heading, time, date, and word-count signals.
|
|
9
|
+
class GroupScorer
|
|
10
|
+
def initialize
|
|
11
|
+
@text_words = {}.compare_by_identity
|
|
12
|
+
@has_date = {}.compare_by_identity
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param groups [Hash{String => Array<Nokogiri::XML::Node>}] candidate DOM groups
|
|
16
|
+
# @return [Array<Nokogiri::XML::Node>] nodes from the highest-scoring group
|
|
17
|
+
def select_best_group(groups)
|
|
18
|
+
best_nodes = []
|
|
19
|
+
best_score = -1
|
|
20
|
+
|
|
21
|
+
groups.each_value do |nodes|
|
|
22
|
+
score = score_group(nodes)
|
|
23
|
+
next if score.negative?
|
|
24
|
+
|
|
25
|
+
(best_nodes = nodes) && (best_score = score) if score > best_score
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
best_nodes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param nodes [Array<Nokogiri::XML::Node>]
|
|
32
|
+
# @return [Float] average visible word count across nodes
|
|
33
|
+
def avg_words(nodes)
|
|
34
|
+
nodes.sum { |n| text_words(n) } / nodes.size.to_f
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def score_group(nodes)
|
|
40
|
+
avg_w = avg_words(nodes)
|
|
41
|
+
return -1 if avg_w < 5
|
|
42
|
+
|
|
43
|
+
score = nodes.size + (avg_w / 5.0)
|
|
44
|
+
score += 20 if nodes_heading?(nodes)
|
|
45
|
+
score += 20 if nodes_time?(nodes)
|
|
46
|
+
score += 40 if nodes_date?(nodes)
|
|
47
|
+
score
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def nodes_heading?(nodes)
|
|
51
|
+
nodes.any? do |n|
|
|
52
|
+
n.at_css(Html2rss::Html::Navigator::HEADING_TAGS.join(',')) ||
|
|
53
|
+
n.at_css('.font-bold, .font-semibold')
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def nodes_time?(nodes)
|
|
58
|
+
nodes.any? { |n| n.at_css('time, [datetime]') }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def nodes_date?(nodes)
|
|
62
|
+
nodes.any? { |n| date?(n) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def text_words(node)
|
|
66
|
+
@text_words[node] ||= Html2rss::Html::Navigator.extract_visible_text(node).to_s.scan(/\p{Alnum}+/).size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def date?(node)
|
|
70
|
+
@has_date[node] ||= begin
|
|
71
|
+
text = Html2rss::Html::Navigator.extract_visible_text(node).to_s
|
|
72
|
+
text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
|
|
73
|
+
text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
module Discovery
|
|
6
|
+
class DomClustering
|
|
7
|
+
##
|
|
8
|
+
# Filters layout wrapper groups and resolves 1-to-1 nested card wrappers.
|
|
9
|
+
class OverlapResolver
|
|
10
|
+
# @param layout_tags [Set<String>] tags treated as layout containers
|
|
11
|
+
# @param word_counter [#avg_words] averages visible word counts for nodes
|
|
12
|
+
def initialize(layout_tags:, word_counter:)
|
|
13
|
+
@layout_tags = layout_tags
|
|
14
|
+
@word_counter = word_counter
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Discard group A if any node of A contains > 1 node of another group B
|
|
18
|
+
#
|
|
19
|
+
# @param groups [Hash{String => Array<Nokogiri::XML::Node>}] candidate groups
|
|
20
|
+
# @return [Hash{String => Array<Nokogiri::XML::Node>}] groups that are not layout wrappers
|
|
21
|
+
def filter_containers(groups)
|
|
22
|
+
groups.reject do |cls_a, nodes_a|
|
|
23
|
+
groups.any? { |cls_b, nodes_b| cls_a != cls_b && container_of?(nodes_a, nodes_b) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# If group A contains group B with the same size, keep the real content card.
|
|
28
|
+
#
|
|
29
|
+
# @param groups [Hash{String => Array<Nokogiri::XML::Node>}] candidate groups
|
|
30
|
+
# @return [Hash{String => Array<Nokogiri::XML::Node>}] groups after 1-to-1 resolution
|
|
31
|
+
def filter_1_to_1_overlap(groups)
|
|
32
|
+
discarded = Set.new
|
|
33
|
+
groups.each_key do |cls_a|
|
|
34
|
+
groups.each_key do |cls_b|
|
|
35
|
+
next if cls_a == cls_b || discarded.include?(cls_a) || discarded.include?(cls_b)
|
|
36
|
+
|
|
37
|
+
resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
groups.except(*discarded)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :layout_tags, :word_counter
|
|
47
|
+
|
|
48
|
+
# rubocop:disable Metrics/MethodLength
|
|
49
|
+
def container_of?(nodes_a, nodes_b)
|
|
50
|
+
return false unless layout_tags.include?(nodes_b.first.name)
|
|
51
|
+
|
|
52
|
+
nodes_a.any? do |node_a|
|
|
53
|
+
count = 0
|
|
54
|
+
nodes_b.each do |node_b|
|
|
55
|
+
next if node_a == node_b
|
|
56
|
+
|
|
57
|
+
if Html2rss::Html::Navigator.descendant_of?(node_b, node_a)
|
|
58
|
+
count += 1
|
|
59
|
+
break if count > 1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
count > 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
# rubocop:enable Metrics/MethodLength
|
|
66
|
+
|
|
67
|
+
def resolve_1_to_1_overlap(cls_a, cls_b, groups, discarded)
|
|
68
|
+
nodes_a = groups[cls_a]
|
|
69
|
+
nodes_b = groups[cls_b]
|
|
70
|
+
return if nodes_a.size != nodes_b.size
|
|
71
|
+
|
|
72
|
+
nested = nodes_a.zip(nodes_b).all? { |a, b| a != b && Html2rss::Html::Navigator.descendant_of?(b, a) }
|
|
73
|
+
return unless nested
|
|
74
|
+
|
|
75
|
+
discarded << (keep_descendant?(nodes_a, nodes_b) ? cls_a : cls_b)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def keep_descendant?(nodes_a, nodes_b)
|
|
79
|
+
word_counter.avg_words(nodes_b) >= 0.8 * word_counter.avg_words(nodes_a) &&
|
|
80
|
+
layout_tags.include?(nodes_b.first.name)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|