html2rss 0.30.0 → 0.32.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 +6 -3
- data/lib/html2rss/article/deduplicator.rb +2 -4
- data/lib/html2rss/article.rb +16 -39
- data/lib/html2rss/auto_source/README.md +12 -9
- data/lib/html2rss/auto_source/cleanup.rb +34 -7
- data/lib/html2rss/auto_source/scraper/html.rb +5 -0
- data/lib/html2rss/auto_source/scraper/json_document.rb +135 -0
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +74 -11
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +4 -8
- data/lib/html2rss/auto_source/scraper/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 +12 -17
- 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 +58 -3
- data/lib/html2rss/batch.rb +3 -1
- data/lib/html2rss/capture/README.md +6 -4
- data/lib/html2rss/capture.rb +67 -35
- 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 +6 -2
- data/lib/html2rss/config/auto_source_contract.rb +3 -0
- data/lib/html2rss/config/issue_mapper.rb +108 -0
- data/lib/html2rss/config/request_headers.rb +2 -3
- 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 +44 -114
- data/lib/html2rss/defaults.rb +42 -30
- data/lib/html2rss/doctor/botasaurus.rb +6 -4
- 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/auto_fallback.rb +11 -2
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
- 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/feed_resolution.rb +30 -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 +2 -0
- data/lib/html2rss/mcp/config_argument.rb +3 -1
- data/lib/html2rss/mcp/contract.rb +7 -21
- data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
- data/lib/html2rss/mcp/outcome.rb +36 -43
- data/lib/html2rss/mcp/server/tools.rb +111 -131
- data/lib/html2rss/mcp/server.rb +26 -18
- 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 +19 -30
- data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
- data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
- data/lib/html2rss/request_service/compressed_body.rb +4 -1
- data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
- data/lib/html2rss/request_service/network_guard.rb +7 -5
- data/lib/html2rss/request_service/response.rb +6 -4
- data/lib/html2rss/request_service/strategy.rb +3 -8
- data/lib/html2rss/request_service.rb +11 -2
- 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 -173
- data/lib/html2rss/url.rb +25 -4
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +32 -19
- data/lib/tasks/config_schema.rake +2 -2
- data/schema/html2rss-config.schema.json +26 -8
- metadata +16 -6
- 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: 4841672d87f3828928fa35646176d878469ade0afce9f9e6a119ccd1cf3251ae
|
|
4
|
+
data.tar.gz: 618382810f59ff95713064d91a2ccb6df4c0dd2cf6287f31127cddd5848e8dbd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7abf8800a5943fca86ed9fa19058a1957ed101d52db70fd75042bda91abc2a9070d3bb5e6b0129e18103ebf407de2ea97a5e1a826cec3b5a7c0f21c12d0dfd61
|
|
7
|
+
data.tar.gz: 7831958f9d9b2af2db632b341cd8c902b45ed78feaf12dc21ee4f9c80c72237d56acba58e1b2aa7b0c842fc6dd3626beb48fd0e08ef932730920e33c23eb2f4b
|
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|
|
|
3
3
|
[](http://rubygems.org/gems/html2rss) [](https://www.rubydoc.info/gems/html2rss)  [](https://github.com/html2rss/html2rss/actions)
|
|
4
4
|
|
|
@@ -62,6 +62,9 @@ html2rss capture https://example.com/news | html2rss test -
|
|
|
62
62
|
# One-shot articles now
|
|
63
63
|
html2rss scrape https://example.com/news
|
|
64
64
|
html2rss scrape https://example.com/news --format jsonfeed --explain
|
|
65
|
+
# Off-domain item URLs are dropped by default. --keep-different-domain opts in
|
|
66
|
+
# and lets the remote source control published links to unrelated domains.
|
|
67
|
+
html2rss scrape https://example.com/app-list.json --keep-different-domain
|
|
65
68
|
|
|
66
69
|
# Durable config workflow
|
|
67
70
|
html2rss capture https://example.com/news --write feed.yml
|
|
@@ -82,7 +85,7 @@ Historic CLI aliases: `feed` → `apply`, `auto` → `scrape`.
|
|
|
82
85
|
|
|
83
86
|
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
87
|
|
|
85
|
-
Cross-host redirects (e.g. `https://apex.example/` → `https://www.example/`) set `Host` per hop via
|
|
88
|
+
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
89
|
|
|
87
90
|
## Capture API
|
|
88
91
|
|
|
@@ -112,7 +115,7 @@ stdio uses stdout for JSON-RPC, so the daemon logs to **stderr**. It defaults to
|
|
|
112
115
|
|
|
113
116
|
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
117
|
|
|
115
|
-
**Strategy note:** MCP `scrape` / `capture` with `strategy: "auto"` run
|
|
118
|
+
**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
119
|
|
|
117
120
|
**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
121
|
|
|
@@ -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
|
##
|
|
@@ -10,8 +10,9 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
|
|
|
10
10
|
|
|
11
11
|
## Live flow
|
|
12
12
|
|
|
13
|
-
1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
|
|
14
|
-
2. **
|
|
13
|
+
1. **Request** — `RequestSession` returns a `Response` with `body` (String) and `parsed_body` (Nokogiri HTML, or parsed JSON for JSON media types). Direct syndication responses skip HTML scrapers and parse via `Syndication::Parser`.
|
|
14
|
+
2. **JSON document body** — When the response is a JSON media type (`Response#json_response?`), AutoSource runs `JsonDocument` only (no HTML tiers). Scalar JSON is `unsupported_surface`. Same-domain Cleanup stays on by default (`keep_different_domain: false`). If the scraper yielded items and Cleanup kept none, `Log.warn` emits `auto_source.admission_emptied` with drop tallies; a `different_domain` tally hints at `auto_source.cleanup.keep_different_domain: true` / `--keep-different-domain`, which lets the remote source control off-domain link targets (no affiliation or safety check). Guidance stays in help/docs and that structured warning — stdout feed output stays composable.
|
|
15
|
+
3. **Scraper tiers** (HTML only) — Enabled scrapers that claim the page (shallow `articles?` or instance `extractable?`) run in `Scraper::SCRAPER_TIERS` order. Merge within a tier, then stop when enough articles survive Cleanup:
|
|
15
16
|
|
|
16
17
|
0. Native feed: NativeFeed (head alternates + path discovery → RSS/Atom parse)
|
|
17
18
|
1. In-page structured: Schema, Microdata, Microformats2, JsonState, XhrArticles
|
|
@@ -19,22 +20,23 @@ Entry: `FeedPipeline` → `AutoSource#articles` → `Scraper.build_instance` →
|
|
|
19
20
|
3. Heuristic: SemanticHtml
|
|
20
21
|
4. Heuristic: Html (skipped when earlier tiers already admitted at least one clean article)
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
4. **Structured / API scrapers** — Schema, Microdata, MF2, JSON state, XHR JSON, oEmbed, WordPress REST, and Sitemap work on Nokogiri CSS/XPath or JSON/XML parsers. They do not use SST. `JsonDocument` walks a JSON response body (JSON Feed mapping or `JsonState.discover_articles`).
|
|
24
|
+
5. **Heuristic scrapers** — `SemanticHtml` and `Html` normalize once into an `SST::Document`, then:
|
|
24
25
|
|
|
25
26
|
`SST::Normalizer` → `AutoSource::Segmenter` → `Scoring::Engine` → extractor / article materialization.
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
6. **Cleanup** — Merge, dedupe, hard-exclude non-article destinations (via `PathClassifier` facts), drop junk titles, and trim to `limit`. Html is skipped when earlier tiers already admitted clean items.
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
7. **Entry URL resolution** (pipeline, not this class) — when AutoFallback sees a weak homepage extract, `FeedResolution` may rewrite the scrape URL to a listing/feed before escalating strategies. See {Html2rss::FeedPipeline} and {Html2rss::FeedResolution}.
|
|
30
31
|
|
|
31
|
-
Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (
|
|
32
|
+
Segmenter strategies: `:semantic` (leaf containers + primary link), `:list` (cards grouped by parent; `main` is a filter), `:cluster` (class/structure grids for anchorless cards). Scoring ranks and demotes; `LinkDestination::NoisePolicy` owns content-anchor eligibility. Cleanup owns feed-item admission.
|
|
32
33
|
|
|
33
34
|
## Nokogiri vs SST boundaries
|
|
34
35
|
|
|
35
36
|
| Surface | Owns DOM |
|
|
36
37
|
| --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
37
|
-
| `Response#parsed_body` |
|
|
38
|
+
| `Response#parsed_body` | HTML document, or Hash/Array for JSON bodies |
|
|
39
|
+
| `JsonDocument` | Parsed JSON Hash/Array (no Nokogiri) |
|
|
38
40
|
| Schema / Microdata / MF2 / JsonState / XhrArticles / MetaOembed / app-shell detection | Nokogiri |
|
|
39
41
|
| Sitemap detection (CSS/XPath) | Nokogiri; URL list parsing uses raw `response.body` (XML string) |
|
|
40
42
|
| Selectors path / Sanitize transformers | Nokogiri (unchanged) |
|
|
@@ -51,7 +53,8 @@ Production heuristic scrapers should reuse one `SST::Document` memoized from `pa
|
|
|
51
53
|
|
|
52
54
|
## Non-goals
|
|
53
55
|
|
|
54
|
-
- Replacing Selectors, Schema, Microdata, MF2, JsonState, or XhrArticles with SST.
|
|
56
|
+
- Replacing Selectors, Schema, Microdata, MF2, JsonState, JsonDocument, or XhrArticles with SST.
|
|
57
|
+
- Turning `keep_different_domain` on automatically for JSON documents (operators must opt in explicitly; the flag trusts the source for any published link domain).
|
|
55
58
|
- A second HTML parser beside Nokogiri.
|
|
56
59
|
- Dual Response payload (Nokogiri + SST always).
|
|
57
60
|
- App-shell classification on SST.
|
|
@@ -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 = [
|
|
@@ -63,10 +63,28 @@ module Html2rss
|
|
|
63
63
|
# Read-only ship-quality audit on RSS/article-like items (no mutation).
|
|
64
64
|
AuditResult = Data.define(:warnings, :metrics, :violations)
|
|
65
65
|
|
|
66
|
+
# Config key / CLI flag named when different_domain drops emptied admission.
|
|
67
|
+
# Opt-in trusts the remote source for off-domain link targets (no affiliation check).
|
|
68
|
+
DIFFERENT_DOMAIN_HINT =
|
|
69
|
+
'auto_source.cleanup.keep_different_domain: true (or --keep-different-domain); ' \
|
|
70
|
+
'allows published items to link to any domain supplied by the source'
|
|
71
|
+
|
|
66
72
|
class << self
|
|
73
|
+
# Mitigating config hint when admission drops emptied a non-empty capture.
|
|
74
|
+
# Other drop reasons remain counts only.
|
|
75
|
+
#
|
|
76
|
+
# @param drop_tallies [Hash{String => Integer}]
|
|
77
|
+
# @return [String, nil]
|
|
78
|
+
def admission_hint(drop_tallies)
|
|
79
|
+
return unless drop_tallies.fetch('different_domain', 0).positive?
|
|
80
|
+
|
|
81
|
+
DIFFERENT_DOMAIN_HINT
|
|
82
|
+
end
|
|
83
|
+
|
|
67
84
|
# @param articles [Array<Article>] extracted article candidates
|
|
68
85
|
# @param url [Html2rss::Url] feed source URL used for same-host filtering
|
|
69
|
-
# @param keep_different_domain [Boolean]
|
|
86
|
+
# @param keep_different_domain [Boolean] when true, keep items whose URLs are on a
|
|
87
|
+
# different domain than +url+ (source-controlled off-domain links; default false)
|
|
70
88
|
# @return [Result] cleaned articles and frozen drop tallies
|
|
71
89
|
def call(articles, url:, keep_different_domain: DEFAULT_CONFIG.fetch(:keep_different_domain)) # rubocop:disable Metrics/MethodLength -- ordered reject pipeline
|
|
72
90
|
Log.debug "Cleanup: start with #{articles.size} articles"
|
|
@@ -97,13 +115,14 @@ module Html2rss
|
|
|
97
115
|
JUNK_TITLE_RULES.find { |_, pattern| pattern.match?(normalized) }&.first
|
|
98
116
|
end
|
|
99
117
|
|
|
100
|
-
# @param items [Array
|
|
118
|
+
# @param items [Array<Html2rss::Article, RSS::Rss::Channel::Item>]
|
|
101
119
|
# @return [AuditResult]
|
|
102
120
|
def audit_feed_items(items)
|
|
103
|
-
|
|
121
|
+
articles = items.map { |item| feed_item_as_article(item) }
|
|
122
|
+
metrics = initial_audit_metrics(articles.size)
|
|
104
123
|
violations = Hash.new(0)
|
|
105
|
-
warnings = audit_url_diversity(
|
|
106
|
-
audit_item_titles(
|
|
124
|
+
warnings = audit_url_diversity(articles, metrics, violations)
|
|
125
|
+
audit_item_titles(articles, metrics, violations)
|
|
107
126
|
warnings = finalize_audit_warnings(warnings, metrics)
|
|
108
127
|
log_audit(metrics, warnings, violations)
|
|
109
128
|
AuditResult.new(warnings:, metrics: metrics.freeze, violations: violations.freeze)
|
|
@@ -256,8 +275,16 @@ module Html2rss
|
|
|
256
275
|
url&.without_fragment&.to_s
|
|
257
276
|
end
|
|
258
277
|
|
|
278
|
+
def feed_item_as_article(item)
|
|
279
|
+
case item
|
|
280
|
+
when Article then item
|
|
281
|
+
else
|
|
282
|
+
Article.new(title: item.title, url: item.link)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
259
286
|
def item_url(item)
|
|
260
|
-
raw = item.
|
|
287
|
+
raw = item.url
|
|
261
288
|
raw.nil? || raw.to_s.empty? ? nil : Html2rss::Url.from_absolute(raw.to_s)
|
|
262
289
|
rescue ArgumentError
|
|
263
290
|
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]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class AutoSource
|
|
5
|
+
module Scraper
|
|
6
|
+
# Extracts articles from a parsed JSON response body (Hash or Array).
|
|
7
|
+
# JSON Feed documents map item fields directly; every other document walks
|
|
8
|
+
# via {JsonState.discover_articles}. Does not scan HTML.
|
|
9
|
+
class JsonDocument
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
# Exact JSON Feed 1.0 / 1.1 +version+ URLs (no substring host matching).
|
|
13
|
+
JSON_FEED_VERSIONS = Set[
|
|
14
|
+
'https://jsonfeed.org/version/1',
|
|
15
|
+
'https://jsonfeed.org/version/1.1'
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# @return [Symbol] scraper config key
|
|
19
|
+
def self.options_key = :json_document
|
|
20
|
+
|
|
21
|
+
# @param _opts [Hash] unused scraper options
|
|
22
|
+
# @return [Integer] zero — no additional HTTP requests
|
|
23
|
+
def self.request_slots(_opts = {}) = 0
|
|
24
|
+
|
|
25
|
+
# Not detectable from HTML alone; instance {#extractable?} requires a
|
|
26
|
+
# Hash or Array body (wired by AutoSource for JSON responses).
|
|
27
|
+
#
|
|
28
|
+
# @param _parsed_body [Object] unused
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def self.articles?(_parsed_body) = false
|
|
31
|
+
|
|
32
|
+
# @param parsed_body [Hash, Array, Object] parsed JSON document (or HTML when misrouted)
|
|
33
|
+
# @param url [String, Html2rss::Url] base URL for relative link resolution
|
|
34
|
+
# @param _opts [Hash] scraper-specific options
|
|
35
|
+
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
36
|
+
def initialize(parsed_body, url:, **_opts)
|
|
37
|
+
@parsed_body = parsed_body
|
|
38
|
+
@url = url
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] true when +parsed_body+ is a Hash or Array
|
|
42
|
+
def extractable?
|
|
43
|
+
@parsed_body.is_a?(Hash) || @parsed_body.is_a?(Array)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @yield [Hash{Symbol => Object}] normalized article hash
|
|
47
|
+
# @return [Enumerator, void] article enumerator when no block is given
|
|
48
|
+
def each(&block)
|
|
49
|
+
return enum_for(:each) unless block
|
|
50
|
+
return unless extractable?
|
|
51
|
+
|
|
52
|
+
if json_feed?
|
|
53
|
+
each_json_feed_item(&block)
|
|
54
|
+
else
|
|
55
|
+
JsonState.discover_articles(@parsed_body, base_url: @url) do |article|
|
|
56
|
+
yield(article) if article
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def json_feed?
|
|
64
|
+
return false unless @parsed_body.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
JSON_FEED_VERSIONS.include?(@parsed_body[:version].to_s) && @parsed_body[:items].is_a?(Array)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def each_json_feed_item
|
|
70
|
+
@parsed_body[:items].each do |item|
|
|
71
|
+
article = map_json_feed_item(item)
|
|
72
|
+
yield article if article
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def map_json_feed_item(item)
|
|
77
|
+
return unless item.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
article_url = resolve_json_feed_url(item)
|
|
80
|
+
return unless article_url
|
|
81
|
+
|
|
82
|
+
json_feed_article_hash(item, article_url)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def json_feed_article_hash(item, article_url)
|
|
86
|
+
{
|
|
87
|
+
title: string(item[:title]),
|
|
88
|
+
description: json_feed_description(item),
|
|
89
|
+
url: article_url,
|
|
90
|
+
image: resolve_optional_url(item[:image]),
|
|
91
|
+
published_at: string(item[:date_published]),
|
|
92
|
+
categories: json_feed_tags(item[:tags]),
|
|
93
|
+
id: string(item[:id]) || article_url.to_s
|
|
94
|
+
}.compact
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolve_json_feed_url(item)
|
|
98
|
+
resolve_optional_url(item[:url]) || absolute_url(item[:id])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def resolve_optional_url(value)
|
|
102
|
+
str = string(value)
|
|
103
|
+
return unless str
|
|
104
|
+
|
|
105
|
+
Url.from_relative(str, @url)
|
|
106
|
+
rescue ArgumentError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def absolute_url(value)
|
|
111
|
+
str = string(value)
|
|
112
|
+
return unless str
|
|
113
|
+
|
|
114
|
+
Url.from_absolute(str)
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def json_feed_description(item)
|
|
120
|
+
string(item[:content_text]) || string(item[:content_html])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def json_feed_tags(tags)
|
|
124
|
+
return unless tags.is_a?(Array)
|
|
125
|
+
|
|
126
|
+
names = tags.filter_map { |tag| string(tag) }
|
|
127
|
+
names.uniq!
|
|
128
|
+
names unless names.empty?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def string(value) = JsonState::ArticleNormalizer.string(value)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -5,15 +5,15 @@ module Html2rss
|
|
|
5
5
|
module Scraper
|
|
6
6
|
class JsonState
|
|
7
7
|
# Shapes raw entries into the structure required downstream.
|
|
8
|
-
module ArticleNormalizer
|
|
8
|
+
module ArticleNormalizer # rubocop:disable Metrics/ModuleLength -- field keys + URL/scalar unwrap stay co-located
|
|
9
9
|
# Preferred keys when extracting title-like values from state payloads.
|
|
10
10
|
TITLE_KEYS = %i[title headline name text].freeze
|
|
11
11
|
# Preferred keys when extracting URL-like values from state payloads.
|
|
12
|
-
URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
|
|
12
|
+
URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl alias self].freeze
|
|
13
13
|
# Preferred keys when extracting description-like values from state payloads.
|
|
14
|
-
DESCRIPTION_KEYS = %i[description summary excerpt dek subheading].freeze
|
|
14
|
+
DESCRIPTION_KEYS = %i[description summary excerpt dek subheading abstract].freeze
|
|
15
15
|
# Preferred keys when extracting image-like values from state payloads.
|
|
16
|
-
IMAGE_KEYS = %i[image imageUrl thumbnailUrl thumbnail src featuredImage coverImage heroImage].freeze
|
|
16
|
+
IMAGE_KEYS = %i[image imageUrl thumbnailUrl thumbnail src featuredImage coverImage heroImage icon].freeze
|
|
17
17
|
# Preferred keys when extracting publication timestamps from state payloads.
|
|
18
18
|
PUBLISHED_AT_KEYS = %i[published_at publishedAt datePublished date publicationDate pubDate updatedAt
|
|
19
19
|
updated_at createdAt created_at].freeze
|
|
@@ -21,6 +21,12 @@ module Html2rss
|
|
|
21
21
|
CATEGORY_KEYS = %i[categories tags section sections topic topics channel].freeze
|
|
22
22
|
# Preferred keys when extracting identifier-like values from state payloads.
|
|
23
23
|
ID_KEYS = %i[id guid uuid slug key].freeze
|
|
24
|
+
# Max times to unwrap a Hash URL value (e.g. path.alias, links.self.href).
|
|
25
|
+
MAX_URL_UNWRAP = 3
|
|
26
|
+
# Nested title/description keys (WordPress +rendered+, CMS +value+/+text+).
|
|
27
|
+
SCALAR_KEYS = %i[rendered value text].freeze
|
|
28
|
+
# Snake `_at`/`_on` or camel `At`/`On` suffix after a closed published-at miss.
|
|
29
|
+
DATE_KEY_SUFFIX = /_at\z|_on\z|[a-z]At\z|[a-z]On\z/
|
|
24
30
|
|
|
25
31
|
module_function
|
|
26
32
|
|
|
@@ -44,37 +50,94 @@ module Html2rss
|
|
|
44
50
|
url: article_url,
|
|
45
51
|
image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
|
|
46
52
|
log_key: 'JsonState: invalid image URL encountered'),
|
|
47
|
-
published_at:
|
|
53
|
+
published_at: published_at(entry),
|
|
48
54
|
categories: categories(entry),
|
|
49
55
|
id: identifier(entry, article_url)
|
|
50
56
|
}.compact
|
|
51
57
|
end
|
|
52
58
|
# rubocop:enable Metrics/MethodLength
|
|
53
59
|
|
|
54
|
-
# @param value [Object] candidate scalar value
|
|
55
|
-
# @return [String, nil] normalized non-empty string
|
|
60
|
+
# @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
|
|
61
|
+
# @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
|
|
56
62
|
def string(value)
|
|
63
|
+
value = unwrap_scalar(value)
|
|
64
|
+
return unless value.is_a?(String) || value.is_a?(Numeric)
|
|
65
|
+
|
|
57
66
|
trimmed = value.to_s.strip
|
|
58
67
|
trimmed unless trimmed.empty?
|
|
59
68
|
end
|
|
60
69
|
|
|
70
|
+
# @param value [Object] candidate scalar or nested Hash
|
|
71
|
+
# @return [Object, nil] String/Numeric after at most {MAX_URL_UNWRAP} Hash unwraps
|
|
72
|
+
def unwrap_scalar(value)
|
|
73
|
+
depth = 0
|
|
74
|
+
while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
|
|
75
|
+
nested = SCALAR_KEYS.find { |key| value.key?(key) }
|
|
76
|
+
break unless nested
|
|
77
|
+
|
|
78
|
+
value = value[nested]
|
|
79
|
+
depth += 1
|
|
80
|
+
end
|
|
81
|
+
value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
82
|
+
end
|
|
83
|
+
|
|
61
84
|
# @param entry [Hash] raw article entry candidate
|
|
62
|
-
# @param keys [Array<
|
|
85
|
+
# @param keys [Array<Symbol>] preferred link keys
|
|
63
86
|
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
64
87
|
# @param log_key [String] structured log message key
|
|
65
88
|
# @return [Html2rss::Url, nil] resolved absolute URL
|
|
66
89
|
def resolve_link(entry, keys:, base_url:, log_key:)
|
|
67
|
-
value = ValueFinder.fetch(entry, keys)
|
|
68
|
-
|
|
90
|
+
value = unwrap_url(ValueFinder.fetch(entry, keys), keys) ||
|
|
91
|
+
unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
|
|
69
92
|
string = string(value)
|
|
70
93
|
return unless string
|
|
71
94
|
|
|
72
95
|
Url.from_relative(string, base_url)
|
|
73
96
|
rescue ArgumentError
|
|
74
|
-
Log.debug(log_key
|
|
97
|
+
Log.debug("#{log_key}: url=#{string}")
|
|
75
98
|
nil
|
|
76
99
|
end
|
|
77
100
|
|
|
101
|
+
# @param value [Object] candidate URL value, possibly a nested Hash
|
|
102
|
+
# @param keys [Array<Symbol>] preferred link keys
|
|
103
|
+
# @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} Hash unwraps
|
|
104
|
+
def unwrap_url(value, keys)
|
|
105
|
+
depth = 0
|
|
106
|
+
while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
|
|
107
|
+
value = ValueFinder.fetch(value, keys)
|
|
108
|
+
depth += 1
|
|
109
|
+
end
|
|
110
|
+
value unless value.is_a?(Hash)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param entry [Hash] raw article entry candidate
|
|
114
|
+
# @return [String, nil] publication timestamp string or unix timestamp digits
|
|
115
|
+
def published_at(entry)
|
|
116
|
+
value = ValueFinder.fetch(entry, PUBLISHED_AT_KEYS)
|
|
117
|
+
string(value) || string(suffix_date_value(entry))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @param hash [Hash, nil] entry or nested attributes/data hash
|
|
121
|
+
# @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
|
|
122
|
+
def suffix_date_value(hash)
|
|
123
|
+
return unless hash.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
hash.each do |key, value|
|
|
126
|
+
next unless date_suffix_key?(key)
|
|
127
|
+
next unless value.is_a?(String) || value.is_a?(Numeric)
|
|
128
|
+
|
|
129
|
+
return value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @param key [Symbol, String] candidate published-at key
|
|
136
|
+
# @return [Boolean] whether the key ends in a date suffix (_at/_on/At/On)
|
|
137
|
+
def date_suffix_key?(key)
|
|
138
|
+
key.to_s.match?(DATE_KEY_SUFFIX)
|
|
139
|
+
end
|
|
140
|
+
|
|
78
141
|
# rubocop:disable Metrics/MethodLength
|
|
79
142
|
# @param entry [Hash] raw article entry candidate
|
|
80
143
|
# @return [Array<String>, nil] normalized unique categories
|
|
@@ -6,11 +6,6 @@ module Html2rss
|
|
|
6
6
|
class JsonState
|
|
7
7
|
# Identifies arrays that look like collections of article hashes.
|
|
8
8
|
module CandidateDetector
|
|
9
|
-
# Preferred keys when extracting title-like values from state payloads.
|
|
10
|
-
TITLE_KEYS = %i[title headline name text].freeze
|
|
11
|
-
# Preferred keys when extracting URL-like values from state payloads.
|
|
12
|
-
URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl].freeze
|
|
13
|
-
|
|
14
9
|
module_function
|
|
15
10
|
|
|
16
11
|
# @param document [Hash, Array, Object] candidate document node
|
|
@@ -48,13 +43,14 @@ module Html2rss
|
|
|
48
43
|
# @param object [Hash] article candidate object
|
|
49
44
|
# @return [Object, nil] detected title-like value
|
|
50
45
|
def title_from(object)
|
|
51
|
-
ValueFinder.fetch(object, TITLE_KEYS)
|
|
46
|
+
ValueFinder.fetch(object, ArticleNormalizer::TITLE_KEYS)
|
|
52
47
|
end
|
|
53
48
|
|
|
54
49
|
# @param object [Hash] article candidate object
|
|
55
|
-
# @return [Object, nil] detected URL-like value
|
|
50
|
+
# @return [Object, nil] detected URL-like value (including links.self)
|
|
56
51
|
def url_from(object)
|
|
57
|
-
ValueFinder.fetch(object, URL_KEYS)
|
|
52
|
+
ValueFinder.fetch(object, ArticleNormalizer::URL_KEYS) ||
|
|
53
|
+
ValueFinder.fetch(object[:links], ArticleNormalizer::URL_KEYS)
|
|
58
54
|
end
|
|
59
55
|
end
|
|
60
56
|
end
|
|
@@ -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
|
#
|