html2rss 0.29.1 → 0.31.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 +8 -8
- data/html2rss.gemspec +1 -3
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +1 -1
- data/lib/html2rss/auto_source/cleanup.rb +15 -6
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
- data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
- data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
- data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
- data/lib/html2rss/auto_source/scraper.rb +4 -14
- data/lib/html2rss/auto_source/segment.rb +7 -4
- data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
- data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
- data/lib/html2rss/auto_source/segmenter.rb +5 -5
- data/lib/html2rss/auto_source.rb +2 -2
- data/lib/html2rss/batch.rb +6 -4
- data/lib/html2rss/capture/README.md +7 -5
- data/lib/html2rss/capture.rb +68 -36
- data/lib/html2rss/channel.rb +3 -3
- data/lib/html2rss/cli/probe_view.rb +5 -3
- data/lib/html2rss/cli/render.rb +6 -5
- data/lib/html2rss/cli/validate.rb +28 -27
- data/lib/html2rss/cli.rb +1 -1
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +6 -4
- data/lib/html2rss/config/schema.rb +16 -1
- data/lib/html2rss/config/selectors_validator.rb +92 -70
- data/lib/html2rss/config/validation_issue.rb +60 -0
- data/lib/html2rss/config/validation_report.rb +55 -0
- data/lib/html2rss/config/validator.rb +10 -6
- data/lib/html2rss/config.rb +35 -101
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +19 -9
- data/lib/html2rss/doctor.rb +8 -0
- data/lib/html2rss/error.rb +1 -1
- data/lib/html2rss/feed_builder/rss.rb +9 -14
- data/lib/html2rss/feed_pipeline/README.md +8 -8
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +1 -1
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +7 -3
- data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
- data/lib/html2rss/feed_resolution/diag.rb +3 -1
- data/lib/html2rss/feed_resolution/options.rb +3 -1
- data/lib/html2rss/feed_resolution/policy.rb +2 -2
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor.rb +2 -4
- data/lib/html2rss/html/feed_link.rb +3 -1
- data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
- data/lib/html2rss/html/navigator.rb +10 -10
- data/lib/html2rss/html/rendering/description_builder.rb +1 -1
- data/lib/html2rss/html/sst_article_extractor.rb +8 -5
- data/lib/html2rss/link_destination/destination_facts.rb +4 -2
- data/lib/html2rss/link_destination/noise_policy.rb +10 -1
- data/lib/html2rss/link_destination/path_classifier.rb +45 -38
- data/lib/html2rss/mcp/README.md +5 -3
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +15 -25
- data/lib/html2rss/mcp/outcome/playbook.rb +7 -7
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +112 -132
- data/lib/html2rss/mcp/server.rb +27 -19
- data/lib/html2rss/page_recon/README.md +5 -5
- data/lib/html2rss/page_recon/diagnostics.rb +71 -81
- data/lib/html2rss/page_recon.rb +9 -5
- data/lib/html2rss/recon.rb +20 -31
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +32 -28
- data/lib/html2rss/request_service/compressed_body.rb +17 -9
- data/lib/html2rss/request_service/httpx_strategy.rb +239 -0
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +1 -2
- data/lib/html2rss/request_service/response_guard.rb +0 -16
- data/lib/html2rss/request_service/strategy.rb +22 -11
- data/lib/html2rss/request_service.rb +46 -85
- data/lib/html2rss/scoring/container_assessor.rb +3 -1
- data/lib/html2rss/scoring/engine.rb +8 -8
- data/lib/html2rss/scoring/observation.rb +5 -3
- data/lib/html2rss/scoring/ranked_segment.rb +3 -1
- data/lib/html2rss/scrape_target.rb +3 -1
- data/lib/html2rss/selector_candidates.rb +234 -0
- data/lib/html2rss/selectors/README.md +64 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
- data/lib/html2rss/selectors/extractors/href.rb +15 -16
- data/lib/html2rss/selectors/extractors/html.rb +8 -9
- data/lib/html2rss/selectors/extractors/static.rb +12 -14
- data/lib/html2rss/selectors/extractors/text.rb +8 -9
- data/lib/html2rss/selectors/extractors.rb +33 -20
- data/lib/html2rss/selectors/item_env.rb +31 -0
- data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
- data/lib/html2rss/selectors/option_spec.rb +140 -0
- data/lib/html2rss/selectors/post_processors/base.rb +79 -31
- data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
- data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
- data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
- data/lib/html2rss/selectors/post_processors/template.rb +23 -19
- data/lib/html2rss/selectors/post_processors.rb +6 -5
- data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
- data/lib/html2rss/selectors/step_env.rb +24 -0
- data/lib/html2rss/selectors.rb +231 -142
- data/lib/html2rss/sst/attrs.rb +5 -3
- data/lib/html2rss/sst/document.rb +3 -1
- data/lib/html2rss/sst/index.rb +3 -1
- data/lib/html2rss/sst/node.rb +29 -10
- data/lib/html2rss/sst/normalizer.rb +12 -16
- data/lib/html2rss/sst/tags.rb +7 -7
- data/lib/html2rss/status.rb +13 -8
- data/lib/html2rss/surface_category.rb +14 -32
- data/lib/html2rss/syndication/parser.rb +2 -2
- data/lib/html2rss/test/enhance_audit.rb +1 -3
- data/lib/html2rss/test/policy.rb +106 -0
- data/lib/html2rss/test.rb +126 -172
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +27 -19
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +11 -8
- metadata +19 -44
- data/lib/html2rss/request_service/faraday_strategy.rb +0 -233
- data/lib/html2rss/selectors/item_scope.rb +0 -30
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e3450f1c6b664e4f9c7377220b65640a9e4bd640782d4f8831538c65a172cec3
|
|
4
|
+
data.tar.gz: 1f5543f86a3389a4f2abb9a76dec44ba41a4ffecadcf8d3d674ff084c459aaec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b2d0fc1afbd44a13405eb21ad89ea32dbdf8d3c0dd1dd0f6430f877398a2cb5275781de7d65386fbde71f43db34e35a54c1fb5d13a73a265c8a8a274f496f87
|
|
7
|
+
data.tar.gz: 6402bc203d89b23fc388872b310eb4c6aaa6b9423025a40ec6eaa606ecf5c78a64c09823a103b263344b72acae4fa385e11526f59571b699eaa54ec27379cf66
|
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` = `
|
|
16
|
+
- [Request strategies](https://html2rss.github.io/ruby-gem/reference/strategy) (`auto` = `default` → `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 (`
|
|
29
|
+
2. **RequestService** — fetches pages (`default` (HTTPX), `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)
|
|
@@ -82,7 +82,7 @@ Historic CLI aliases: `feed` → `apply`, `auto` → `scrape`.
|
|
|
82
82
|
|
|
83
83
|
Inspect follows redirects and reports the landing URL in `final_url`. CLI text shows a `Final:` line **only when** the landing URL differs from what you typed — that line means the redirect succeeded, not that inspect stopped early.
|
|
84
84
|
|
|
85
|
-
Cross-host redirects (e.g. `https://apex.example/` → `https://www.example/`) set `Host` per hop via
|
|
85
|
+
Cross-host redirects (e.g. `https://apex.example/` → `https://www.example/`) set `Host` per hop via HTTPX; html2rss does not pin the entry hostname. When `final_url` differs and status is 4xx, retry on `final_url` or pass the site's canonical hostname. Details: [`lib/html2rss/page_recon/README.md`](lib/html2rss/page_recon/README.md).
|
|
86
86
|
|
|
87
87
|
## Capture API
|
|
88
88
|
|
|
@@ -112,7 +112,7 @@ stdio uses stdout for JSON-RPC, so the daemon logs to **stderr**. It defaults to
|
|
|
112
112
|
|
|
113
113
|
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.
|
|
114
114
|
|
|
115
|
-
**Strategy note:** MCP `scrape` / `capture` with `strategy: "auto"` run
|
|
115
|
+
**Strategy note:** MCP `scrape` / `capture` with `strategy: "auto"` run default (HTTPX) → Botasaurus AutoFallback. `inspect` uses default (HTTPX) when `auto` (cheap diagnostic); pin `botasaurus` when you need browser rendering for inspect.
|
|
116
116
|
|
|
117
117
|
**Tool-call budget:** `scrape` is 1 call (auto already hops). Durable config is `capture` → `test` → `apply` (or `validate` → `test` → `apply` when you already have YAML). Call `inspect` only when scrape/capture is weak or you need recon (final URL, status, https→http, native RSS/Atom).
|
|
118
118
|
|
|
@@ -157,7 +157,7 @@ Module guide: [`lib/html2rss/mcp/README.md`](lib/html2rss/mcp/README.md).
|
|
|
157
157
|
| ----------------------- | --------------------------------------------------------------- |
|
|
158
158
|
| `html2rss://schema` | Full JSON Schema for feed configurations |
|
|
159
159
|
| `html2rss://extractors` | Registered extractor **names** (options live in schema `$defs`) |
|
|
160
|
-
| `html2rss://strategies` | Published MCP strategies (`auto`, `
|
|
160
|
+
| `html2rss://strategies` | Published MCP strategies (`auto`, `default`, `httpx`, `botasaurus`) |
|
|
161
161
|
| `html2rss://runtime` | `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, `botasaurus_configured` (never the scraper URL) |
|
|
162
162
|
|
|
163
163
|
### Prompts
|
|
@@ -183,11 +183,11 @@ Set `BOTASAURUS_SCRAPER_URL` to `http://127.0.0.1:4010` and use strategy `botasa
|
|
|
183
183
|
|
|
184
184
|
| Strategy | Description |
|
|
185
185
|
| ------------ | --------------------------------------------------------------------------- |
|
|
186
|
-
| `auto` | Tries `
|
|
187
|
-
| `
|
|
186
|
+
| `auto` | Tries `default`, falls back to `botasaurus` (default in gem/CLI/MCP scrape) |
|
|
187
|
+
| `default` | Plain HTTP requests via HTTPX (alias: `httpx`; legacy: `faraday`) |
|
|
188
188
|
| `botasaurus` | Puppeteer-backed scraping for JavaScript pages |
|
|
189
189
|
|
|
190
|
-
`inspect` keeps
|
|
190
|
+
`inspect` keeps `default` when `auto` for cheap diagnostics. Elsewhere, strategy can be set via CLI (`--strategy`), gem API keyword argument, or feed config `strategy`. See the [request strategies docs](https://html2rss.github.io/ruby-gem/reference/strategy) for more details.
|
|
191
191
|
|
|
192
192
|
## License
|
|
193
193
|
|
data/html2rss.gemspec
CHANGED
|
@@ -35,9 +35,7 @@ Gem::Specification.new do |spec|
|
|
|
35
35
|
spec.add_dependency 'addressable', '~> 2.7'
|
|
36
36
|
spec.add_dependency 'brotli'
|
|
37
37
|
spec.add_dependency 'dry-validation'
|
|
38
|
-
spec.add_dependency '
|
|
39
|
-
spec.add_dependency 'faraday-follow_redirects'
|
|
40
|
-
spec.add_dependency 'faraday-gzip', '~> 3'
|
|
38
|
+
spec.add_dependency 'httpx', '~> 1.8'
|
|
41
39
|
spec.add_dependency 'kramdown'
|
|
42
40
|
spec.add_dependency 'mcp', '~> 1.2'
|
|
43
41
|
spec.add_dependency 'mime-types', '> 3.0'
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'set' # rubocop:disable Lint/RedundantRequireStatement
|
|
4
|
-
|
|
5
3
|
module Html2rss
|
|
6
4
|
class Article
|
|
7
5
|
##
|
|
@@ -40,9 +38,9 @@ module Html2rss
|
|
|
40
38
|
attr_reader :articles
|
|
41
39
|
|
|
42
40
|
def deduplication_fingerprint_for(article)
|
|
43
|
-
return unless article.respond_to?(:deduplication_fingerprint)
|
|
44
|
-
|
|
45
41
|
article.deduplication_fingerprint
|
|
42
|
+
rescue NoMethodError
|
|
43
|
+
nil
|
|
46
44
|
end
|
|
47
45
|
end
|
|
48
46
|
end
|
data/lib/html2rss/article.rb
CHANGED
|
@@ -5,14 +5,9 @@ require 'zlib'
|
|
|
5
5
|
module Html2rss
|
|
6
6
|
##
|
|
7
7
|
# Article is a simple data object representing an article extracted from a page.
|
|
8
|
-
# It is enumerable and responds to all keys specified in PROVIDED_KEYS.
|
|
9
8
|
#
|
|
10
9
|
# Description and enclosure wire presentation live in {FeedBuilder::ItemPresentation}.
|
|
11
|
-
# rubocop:disable-next Metrics/ClassLength -- value object retains Marshal + defensive freeze helpers
|
|
12
10
|
class Article
|
|
13
|
-
include Enumerable
|
|
14
|
-
include Comparable
|
|
15
|
-
|
|
16
11
|
# Allowed article attributes accepted by the value object constructor.
|
|
17
12
|
PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
|
|
18
13
|
# Separator used to build deterministic deduplication fingerprints.
|
|
@@ -23,26 +18,24 @@ module Html2rss
|
|
|
23
18
|
# when attributes are lazily/conditionally accessed in different sequences.
|
|
24
19
|
NOT_SET = Object.new.freeze
|
|
25
20
|
|
|
26
|
-
# @param
|
|
27
|
-
# @
|
|
28
|
-
# @
|
|
29
|
-
# @
|
|
30
|
-
# @
|
|
31
|
-
# @
|
|
32
|
-
# @
|
|
33
|
-
# @
|
|
34
|
-
# @
|
|
35
|
-
# @
|
|
36
|
-
# @
|
|
37
|
-
#
|
|
38
|
-
def initialize(
|
|
39
|
-
|
|
21
|
+
# @param id [String, nil] stable article identifier
|
|
22
|
+
# @param title [String, nil] article title
|
|
23
|
+
# @param description [String, nil] raw extracted description/content (not feed-rendered HTML)
|
|
24
|
+
# @param url [String, Html2rss::Url, nil] canonical article URL
|
|
25
|
+
# @param image [String, Html2rss::Url, nil] image URL for description / JSON Feed +image+
|
|
26
|
+
# @param author [String, nil] author name
|
|
27
|
+
# @param guid [String, Array, nil] explicit GUID override
|
|
28
|
+
# @param published_at [String, Time, DateTime, nil] publication timestamp
|
|
29
|
+
# @param enclosures [Array<Hash{Symbol => Object}>, nil] enclosure attribute hashes
|
|
30
|
+
# @param categories [Array<String>, nil] category labels
|
|
31
|
+
# @param scraper [Class, nil] scraper class that produced the article
|
|
32
|
+
# rubocop:disable-next Metrics/ParameterLists -- eleven article keys stay explicit
|
|
33
|
+
def initialize(id: nil, title: nil, description: nil, url: nil, image: nil, author: nil, guid: nil,
|
|
34
|
+
published_at: nil, enclosures: nil, categories: nil, scraper: nil)
|
|
35
|
+
@to_h = { id:, title:, description:, url:, image:, author:, guid:, published_at:, enclosures:, categories:,
|
|
36
|
+
scraper: }.compact.transform_values { freeze_option(_1) }.freeze
|
|
40
37
|
|
|
41
38
|
@url = @image = @guid = @enclosures = @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
39
|
end
|
|
47
40
|
|
|
48
41
|
# Checks if the article is valid based on the presence of URL, ID, and either title or description.
|
|
@@ -51,14 +44,6 @@ module Html2rss
|
|
|
51
44
|
!url.to_s.empty? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
|
|
52
45
|
end
|
|
53
46
|
|
|
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
47
|
# @return [String, nil] stable article identifier
|
|
63
48
|
def id = blank_string_to_nil(@to_h[:id])
|
|
64
49
|
|
|
@@ -139,14 +124,6 @@ module Html2rss
|
|
|
139
124
|
@to_h[:scraper]
|
|
140
125
|
end
|
|
141
126
|
|
|
142
|
-
# @param other [Object] value compared against this article
|
|
143
|
-
# @return [Integer, nil] comparison result for compatible Article values
|
|
144
|
-
def <=>(other)
|
|
145
|
-
return nil unless other.is_a?(Article)
|
|
146
|
-
|
|
147
|
-
0 if other.all? { |key, value| value == public_send(key) ? public_send(key) <=> value : false }
|
|
148
|
-
end
|
|
149
|
-
|
|
150
127
|
private
|
|
151
128
|
|
|
152
129
|
##
|
|
@@ -28,7 +28,7 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
|
|
|
28
28
|
|
|
29
29
|
6. **Entry URL resolution** (pipeline, not this class) — when AutoFallback sees a weak homepage extract, `FeedResolution` may rewrite the scrape URL to a listing/feed before escalating strategies. See {Html2rss::FeedPipeline} and {Html2rss::FeedResolution}.
|
|
30
30
|
|
|
31
|
-
Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (
|
|
31
|
+
Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (cards grouped by parent; `main` is a filter), `:cluster` (class/structure grids for anchorless cards). Scoring ranks and demotes; `LinkDestination::NoisePolicy` owns content-anchor eligibility. Cleanup owns feed-item admission.
|
|
32
32
|
|
|
33
33
|
## Nokogiri vs SST boundaries
|
|
34
34
|
|
|
@@ -17,7 +17,7 @@ module Html2rss
|
|
|
17
17
|
MIN_WORDS = 3
|
|
18
18
|
|
|
19
19
|
# Allowed URL schemes for article filtering.
|
|
20
|
-
VALID_SCHEMES =
|
|
20
|
+
VALID_SCHEMES = Set['http', 'https'].freeze
|
|
21
21
|
|
|
22
22
|
# Photo-credit agencies (single list → junk title regexes).
|
|
23
23
|
CREDIT_AGENCIES = [
|
|
@@ -97,13 +97,14 @@ module Html2rss
|
|
|
97
97
|
JUNK_TITLE_RULES.find { |_, pattern| pattern.match?(normalized) }&.first
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
# @param items [Array
|
|
100
|
+
# @param items [Array<Html2rss::Article, RSS::Rss::Channel::Item>]
|
|
101
101
|
# @return [AuditResult]
|
|
102
102
|
def audit_feed_items(items)
|
|
103
|
-
|
|
103
|
+
articles = items.map { |item| feed_item_as_article(item) }
|
|
104
|
+
metrics = initial_audit_metrics(articles.size)
|
|
104
105
|
violations = Hash.new(0)
|
|
105
|
-
warnings = audit_url_diversity(
|
|
106
|
-
audit_item_titles(
|
|
106
|
+
warnings = audit_url_diversity(articles, metrics, violations)
|
|
107
|
+
audit_item_titles(articles, metrics, violations)
|
|
107
108
|
warnings = finalize_audit_warnings(warnings, metrics)
|
|
108
109
|
log_audit(metrics, warnings, violations)
|
|
109
110
|
AuditResult.new(warnings:, metrics: metrics.freeze, violations: violations.freeze)
|
|
@@ -256,8 +257,16 @@ module Html2rss
|
|
|
256
257
|
url&.without_fragment&.to_s
|
|
257
258
|
end
|
|
258
259
|
|
|
260
|
+
def feed_item_as_article(item)
|
|
261
|
+
case item
|
|
262
|
+
when Article then item
|
|
263
|
+
else
|
|
264
|
+
Article.new(title: item.title, url: item.link)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
259
268
|
def item_url(item)
|
|
260
|
-
raw = item.
|
|
269
|
+
raw = item.url
|
|
261
270
|
raw.nil? || raw.to_s.empty? ? nil : Html2rss::Url.from_absolute(raw.to_s)
|
|
262
271
|
rescue ArgumentError
|
|
263
272
|
nil
|
|
@@ -22,6 +22,11 @@ module Html2rss
|
|
|
22
22
|
# @return [Symbol]
|
|
23
23
|
def self.options_key = :html
|
|
24
24
|
|
|
25
|
+
##
|
|
26
|
+
# @param _opts [Hash] unused options
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
def self.request_slots(_opts = {}) = 0
|
|
29
|
+
|
|
25
30
|
##
|
|
26
31
|
# @param parsed_body [Nokogiri::HTML::Document]
|
|
27
32
|
# @return [Boolean]
|
|
@@ -14,6 +14,11 @@ module Html2rss
|
|
|
14
14
|
# @return [Symbol] scraper config key
|
|
15
15
|
def self.options_key = :json_state
|
|
16
16
|
|
|
17
|
+
##
|
|
18
|
+
# @param _opts [Hash] unused options
|
|
19
|
+
# @return [Integer]
|
|
20
|
+
def self.request_slots(_opts = {}) = 0
|
|
21
|
+
|
|
17
22
|
class << self
|
|
18
23
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
19
24
|
def articles?(parsed_body)
|
|
@@ -8,7 +8,7 @@ module Html2rss
|
|
|
8
8
|
module Scraper
|
|
9
9
|
##
|
|
10
10
|
# Scrapes OpenGraph meta tags and oEmbed JSON descriptors from HTML pages.
|
|
11
|
-
class MetaOembed
|
|
11
|
+
class MetaOembed # rubocop:disable Metrics/ClassLength -- request_slots + extractable? stay on the scraper
|
|
12
12
|
include Enumerable
|
|
13
13
|
|
|
14
14
|
# Selector for OpenGraph meta tags.
|
|
@@ -30,6 +30,10 @@ module Html2rss
|
|
|
30
30
|
# @return [Symbol] scraper config key
|
|
31
31
|
def self.options_key = :meta_oembed
|
|
32
32
|
|
|
33
|
+
# @param _opts [Hash] unused options
|
|
34
|
+
# @return [Integer]
|
|
35
|
+
def self.request_slots(_opts = {}) = 0
|
|
36
|
+
|
|
33
37
|
class << self
|
|
34
38
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
35
39
|
# @return [Boolean] whether OpenGraph tags or oEmbed link exist
|
|
@@ -57,6 +61,9 @@ module Html2rss
|
|
|
57
61
|
@request_session = request_session
|
|
58
62
|
end
|
|
59
63
|
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def extractable? = self.class.articles?(parsed_body)
|
|
66
|
+
|
|
60
67
|
##
|
|
61
68
|
# Yields normalized article hash extracted from OpenGraph meta tags and optional oEmbed data.
|
|
62
69
|
#
|
|
@@ -17,6 +17,11 @@ module Html2rss
|
|
|
17
17
|
# @return [Symbol] scraper config key
|
|
18
18
|
def self.options_key = :microdata
|
|
19
19
|
|
|
20
|
+
##
|
|
21
|
+
# @param _opts [Hash] unused options
|
|
22
|
+
# @return [Integer]
|
|
23
|
+
def self.request_slots(_opts = {}) = 0
|
|
24
|
+
|
|
20
25
|
class << self
|
|
21
26
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
22
27
|
def articles?(parsed_body)
|
|
@@ -81,6 +86,9 @@ module Html2rss
|
|
|
81
86
|
@url = url
|
|
82
87
|
end
|
|
83
88
|
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def extractable? = self.class.articles?(parsed_body)
|
|
91
|
+
|
|
84
92
|
##
|
|
85
93
|
# Iterates over normalized article hashes extracted from supported Microdata roots.
|
|
86
94
|
#
|
|
@@ -18,6 +18,11 @@ module Html2rss
|
|
|
18
18
|
# @return [Symbol] scraper config key
|
|
19
19
|
def self.options_key = :microformats2
|
|
20
20
|
|
|
21
|
+
##
|
|
22
|
+
# @param _opts [Hash] unused options
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
def self.request_slots(_opts = {}) = 0
|
|
25
|
+
|
|
21
26
|
class << self
|
|
22
27
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
23
28
|
# @return [Boolean] whether Microformats2 h-entry elements exist
|
|
@@ -38,6 +43,9 @@ module Html2rss
|
|
|
38
43
|
@url = Html2rss::Url.from_absolute(url)
|
|
39
44
|
end
|
|
40
45
|
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def extractable? = self.class.articles?(parsed_body)
|
|
48
|
+
|
|
41
49
|
##
|
|
42
50
|
# Yields normalized article hashes extracted from h-entry elements.
|
|
43
51
|
#
|
|
@@ -19,9 +19,6 @@ module Html2rss
|
|
|
19
19
|
ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
|
|
20
20
|
].to_set.freeze
|
|
21
21
|
|
|
22
|
-
# Attributes exposed by `#call` in generated article hashes.
|
|
23
|
-
DEFAULT_ATTRIBUTES = %i[id title description author url image published_at categories].freeze
|
|
24
|
-
|
|
25
22
|
# @param schema_object [Hash{Symbol => Object}] parsed schema.org object
|
|
26
23
|
# @param url [String, Html2rss::Url, nil] base URL used for relative normalization
|
|
27
24
|
def initialize(schema_object, url:)
|
|
@@ -29,8 +26,10 @@ module Html2rss
|
|
|
29
26
|
@base_url = normalized_base_url(url)
|
|
30
27
|
end
|
|
31
28
|
|
|
32
|
-
# @return [Hash]
|
|
33
|
-
def call
|
|
29
|
+
# @return [Hash{Symbol => Object}] scraped article attributes
|
|
30
|
+
def call
|
|
31
|
+
{ id:, title:, description:, author:, url:, image:, published_at:, categories: }
|
|
32
|
+
end
|
|
34
33
|
|
|
35
34
|
# @return [String, nil] stable schema object identifier
|
|
36
35
|
def id
|
|
@@ -46,6 +46,11 @@ module Html2rss
|
|
|
46
46
|
# @return [Symbol] scraper config key
|
|
47
47
|
def self.options_key = :schema
|
|
48
48
|
|
|
49
|
+
##
|
|
50
|
+
# @param _opts [Hash] unused options
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
def self.request_slots(_opts = {}) = 0
|
|
53
|
+
|
|
49
54
|
class << self
|
|
50
55
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
51
56
|
# @return [Boolean] whether the page includes supported schema types
|
|
@@ -166,6 +171,9 @@ module Html2rss
|
|
|
166
171
|
@opts = opts
|
|
167
172
|
end
|
|
168
173
|
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def extractable? = self.class.articles?(parsed_body)
|
|
176
|
+
|
|
169
177
|
##
|
|
170
178
|
# @yield [Hash] Each scraped article_hash
|
|
171
179
|
# @return [Array<Hash>] the scraped article_hashes
|
|
@@ -15,6 +15,11 @@ module Html2rss
|
|
|
15
15
|
# @return [Symbol] config key used to enable or configure this scraper
|
|
16
16
|
def self.options_key = :semantic_html
|
|
17
17
|
|
|
18
|
+
##
|
|
19
|
+
# @param _opts [Hash] unused options
|
|
20
|
+
# @return [Integer]
|
|
21
|
+
def self.request_slots(_opts = {}) = 0
|
|
22
|
+
|
|
18
23
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
19
24
|
# @return [Boolean] true when at least one semantic container has an eligible anchor
|
|
20
25
|
def self.articles?(parsed_body)
|
|
@@ -36,11 +36,11 @@ module Html2rss
|
|
|
36
36
|
SCRAPERS = SCRAPER_TIERS.flatten.freeze
|
|
37
37
|
|
|
38
38
|
# Heuristic scrapers that share one memoized SST::Document per page.
|
|
39
|
-
HEURISTIC_SCRAPERS = [SemanticHtml, Html].freeze
|
|
39
|
+
HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
|
|
40
40
|
# Scrapers that accept a shared follow-up +request_session+.
|
|
41
|
-
REQUEST_SESSION_SCRAPERS = [NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
|
|
41
|
+
REQUEST_SESSION_SCRAPERS = Set[NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
|
|
42
42
|
# Scrapers that consume browser-captured XHR/fetch JSON bodies.
|
|
43
|
-
CAPTURED_RESPONSE_SCRAPERS = [XhrArticles].freeze
|
|
43
|
+
CAPTURED_RESPONSE_SCRAPERS = Set[XhrArticles].freeze
|
|
44
44
|
|
|
45
45
|
##
|
|
46
46
|
# Error raised when no suitable scraper is found.
|
|
@@ -110,7 +110,7 @@ module Html2rss
|
|
|
110
110
|
# @param tier [Array<Class>]
|
|
111
111
|
# @return [Boolean]
|
|
112
112
|
def self.heuristic_tier?(tier)
|
|
113
|
-
|
|
113
|
+
HEURISTIC_SCRAPERS.intersect?(tier)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
##
|
|
@@ -163,16 +163,6 @@ module Html2rss
|
|
|
163
163
|
scraper.new(parsed_body, url:, **kwargs, **scraper_opts)
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
-
##
|
|
167
|
-
# @param instance [Object]
|
|
168
|
-
# @param parsed_body [Nokogiri::HTML::Document]
|
|
169
|
-
# @return [Boolean]
|
|
170
|
-
def self.extractable_instance?(instance, parsed_body)
|
|
171
|
-
return instance.extractable? if instance.respond_to?(:extractable?)
|
|
172
|
-
|
|
173
|
-
instance.class.articles?(parsed_body)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
166
|
##
|
|
177
167
|
# @param parsed_body [Nokogiri::HTML::Document]
|
|
178
168
|
# @param body [String, nil] raw response body (preferred for blocked-surface checks)
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
class AutoSource
|
|
5
|
+
Segment = Data.define(:root_node, :primary_link, :strategy, :position)
|
|
6
|
+
|
|
5
7
|
##
|
|
6
8
|
# One candidate content block discovered by {Segmenter}.
|
|
7
|
-
Segment
|
|
9
|
+
class Segment
|
|
10
|
+
# Allowed Segmenter strategies.
|
|
11
|
+
STRATEGIES = Set[:semantic, :list, :cluster].freeze
|
|
12
|
+
|
|
8
13
|
##
|
|
9
14
|
# @param root_node [SST::Node]
|
|
10
15
|
# @param primary_link [SST::Node, nil]
|
|
@@ -17,13 +22,11 @@ module Html2rss
|
|
|
17
22
|
unless primary_link.nil? || primary_link.is_a?(SST::Node)
|
|
18
23
|
raise ArgumentError, 'primary_link must be SST::Node or nil'
|
|
19
24
|
end
|
|
20
|
-
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless
|
|
25
|
+
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless STRATEGIES.include?(strategy)
|
|
21
26
|
raise ArgumentError, 'position must be Integer' unless position.is_a?(Integer)
|
|
22
27
|
|
|
23
28
|
new(root_node:, primary_link:, strategy:, position:)
|
|
24
29
|
end
|
|
25
30
|
end
|
|
26
|
-
# Allowed Segmenter strategies.
|
|
27
|
-
Segment::STRATEGIES = %i[semantic list cluster].to_set.freeze
|
|
28
31
|
end
|
|
29
32
|
end
|
|
@@ -13,15 +13,18 @@ module Html2rss
|
|
|
13
13
|
# @param segmenter [Segmenter]
|
|
14
14
|
# @return [Array<Segment>]
|
|
15
15
|
def call(segmenter)
|
|
16
|
+
primary = PrimaryLink.new(segmenter)
|
|
16
17
|
nodes = best_group_nodes(segmenter)
|
|
17
|
-
nodes.each_with_index.
|
|
18
|
-
|
|
18
|
+
nodes.each_with_index.filter_map do |node, position|
|
|
19
|
+
link = primary.select(node)
|
|
20
|
+
next unless link || segmenter.permit_unanchored
|
|
21
|
+
|
|
22
|
+
Segment.build(root_node: node, primary_link: link, strategy: :cluster, position:)
|
|
19
23
|
end
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
def best_group_nodes(segmenter)
|
|
23
|
-
|
|
24
|
-
groups = class_groups.empty? ? collect_structure_groups(segmenter) : class_groups
|
|
27
|
+
groups = collect_groups(segmenter)
|
|
25
28
|
return [] if groups.empty?
|
|
26
29
|
|
|
27
30
|
resolver = OverlapResolver.new(index: segmenter.index)
|
|
@@ -33,33 +36,29 @@ module Html2rss
|
|
|
33
36
|
module_function :best_group_nodes
|
|
34
37
|
private_class_method :best_group_nodes
|
|
35
38
|
|
|
36
|
-
def
|
|
37
|
-
|
|
39
|
+
def collect_groups(segmenter) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
40
|
+
class_groups = Hash.new { |hash, key| hash[key] = [] }
|
|
41
|
+
structure_groups = Hash.new { |hash, key| hash[key] = [] }
|
|
42
|
+
min = segmenter.minimum_selector_frequency
|
|
43
|
+
|
|
38
44
|
segmenter.index.each_node do |node|
|
|
39
45
|
next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
|
|
40
46
|
next if segmenter.index.ignored_chrome?(node)
|
|
41
47
|
|
|
42
48
|
cls = normalize_class(node.attrs.class_names)
|
|
43
|
-
|
|
44
|
-
end
|
|
45
|
-
groups.select { |_, nodes| nodes.size >= segmenter.minimum_selector_frequency }
|
|
46
|
-
end
|
|
47
|
-
module_function :collect_class_groups
|
|
48
|
-
private_class_method :collect_class_groups
|
|
49
|
-
|
|
50
|
-
def collect_structure_groups(segmenter) # rubocop:disable Metrics/AbcSize
|
|
51
|
-
groups = Hash.new { |h, k| h[k] = [] }
|
|
52
|
-
segmenter.index.each_node do |node|
|
|
53
|
-
next if SST::Tags::CLUSTER_EXCLUDED_NAMES.include?(node.name)
|
|
54
|
-
next if segmenter.index.ignored_chrome?(node)
|
|
49
|
+
class_groups[cls] << node unless cls.empty?
|
|
55
50
|
|
|
56
51
|
sig = structure_signature(node)
|
|
57
|
-
|
|
52
|
+
structure_groups[sig] << node unless sig.empty?
|
|
58
53
|
end
|
|
59
|
-
|
|
54
|
+
|
|
55
|
+
frequent_class = class_groups.select { |_, nodes| nodes.size >= min }
|
|
56
|
+
return frequent_class unless frequent_class.empty?
|
|
57
|
+
|
|
58
|
+
structure_groups.select { |_, nodes| nodes.size >= min }
|
|
60
59
|
end
|
|
61
|
-
module_function :
|
|
62
|
-
private_class_method :
|
|
60
|
+
module_function :collect_groups
|
|
61
|
+
private_class_method :collect_groups
|
|
63
62
|
|
|
64
63
|
def normalize_class(class_names)
|
|
65
64
|
return '' if class_names.empty?
|
|
@@ -73,7 +72,7 @@ module Html2rss
|
|
|
73
72
|
kids = node.children
|
|
74
73
|
return '' if kids.empty?
|
|
75
74
|
|
|
76
|
-
kids.map { |
|
|
75
|
+
kids.map { |child| child.name.to_s }.join('>')
|
|
77
76
|
end
|
|
78
77
|
module_function :structure_signature
|
|
79
78
|
private_class_method :structure_signature
|