html2rss 0.33.0 → 0.34.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 +4 -2
- data/lib/html2rss/article/deduplicator.rb +5 -2
- data/lib/html2rss/article/enclosure.rb +185 -21
- data/lib/html2rss/article/title.rb +203 -0
- data/lib/html2rss/article.rb +79 -13
- data/lib/html2rss/auto_source/README.md +16 -8
- data/lib/html2rss/auto_source/cleanup.rb +126 -50
- data/lib/html2rss/auto_source/corroboration.rb +167 -0
- data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
- data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
- data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
- data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
- data/lib/html2rss/auto_source/page_links.rb +164 -0
- data/lib/html2rss/auto_source/scraper/html.rb +11 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- data/lib/html2rss/config/auto_source_contract.rb +52 -45
- data/lib/html2rss/config/issue_mapper.rb +4 -1
- data/lib/html2rss/config/request_controls.rb +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- data/lib/html2rss/error_kind.rb +39 -0
- data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
- data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
- data/lib/html2rss/feed_builder/json_feed.rb +2 -2
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
- data/lib/html2rss/feed_builder/rss.rb +49 -6
- data/lib/html2rss/feed_builder.rb +2 -1
- data/lib/html2rss/feed_pipeline/README.md +1 -1
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
- data/lib/html2rss/feed_pipeline.rb +40 -27
- data/lib/html2rss/feed_resolution/README.md +1 -1
- data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
- data/lib/html2rss/html/article_extractor.rb +19 -47
- data/lib/html2rss/html/article_rules/category.rb +3 -3
- data/lib/html2rss/html/article_rules/description.rb +6 -15
- data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
- data/lib/html2rss/html/article_rules/image.rb +30 -16
- data/lib/html2rss/html/feed_link.rb +7 -9
- data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
- data/lib/html2rss/html/navigator.rb +39 -9
- data/lib/html2rss/html/probe.rb +4 -0
- data/lib/html2rss/html/rendering/description_builder.rb +46 -32
- data/lib/html2rss/html/rendering.rb +2 -9
- data/lib/html2rss/html/sst_article_extractor.rb +55 -145
- data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
- data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
- data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
- data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
- data/lib/html2rss/html/title_pieces.rb +169 -0
- data/lib/html2rss/html/visibility.rb +56 -0
- data/lib/html2rss/html.rb +2 -0
- data/lib/html2rss/link_destination/destination_facts.rb +17 -1
- data/lib/html2rss/link_destination/noise_policy.rb +11 -10
- data/lib/html2rss/link_destination/path_classifier.rb +100 -9
- data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
- data/lib/html2rss/link_destination/sponsorship.rb +184 -0
- data/lib/html2rss/link_destination/text_classifier.rb +104 -8
- data/lib/html2rss/mcp/README.md +13 -1
- data/lib/html2rss/mcp/config_argument.rb +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- data/lib/html2rss/request_service/cookie_jar.rb +89 -0
- data/lib/html2rss/request_service/cookie_store.rb +79 -0
- data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
- data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
- data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- data/lib/html2rss/request_service/response_cache.rb +109 -0
- data/lib/html2rss/request_service/response_caching.rb +95 -0
- data/lib/html2rss/request_service/strategy.rb +5 -11
- data/lib/html2rss/request_service.rb +71 -12
- data/lib/html2rss/request_session/pager/base.rb +0 -7
- data/lib/html2rss/request_session/pager.rb +1 -1
- data/lib/html2rss/request_session.rb +6 -2
- data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
- data/lib/html2rss/scoring/container_assessor.rb +12 -13
- data/lib/html2rss/scoring/engine.rb +6 -6
- data/lib/html2rss/scoring/observation.rb +2 -2
- data/lib/html2rss/selector_candidates.rb +19 -8
- data/lib/html2rss/selectors/README.md +1 -1
- data/lib/html2rss/selectors/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
- data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
- data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
- data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
- data/lib/html2rss/selectors.rb +53 -12
- data/lib/html2rss/sst/attrs.rb +2 -2
- data/lib/html2rss/sst/document.rb +3 -3
- data/lib/html2rss/sst/index.rb +30 -11
- data/lib/html2rss/sst/node.rb +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -8,8 +8,15 @@ module Html2rss
|
|
|
8
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
|
+
# Title keys that mark an article; a bare +name+ also labels people and other entities.
|
|
12
|
+
ARTICLE_TITLE_KEYS = (TITLE_KEYS - %i[name]).freeze
|
|
13
|
+
# Explicit kicker field names (app-state +overline+/+kickerText+, German CMS +topline+/+dachzeile+) that
|
|
14
|
+
# sit beside a headline. The one closed list: no standard Schema/JSON-LD field exists, so none is guessed.
|
|
15
|
+
KICKER_KEYS = %i[overline kicker kickerText topline dachzeile eyebrow].freeze
|
|
11
16
|
# Preferred keys when extracting URL-like values from state payloads.
|
|
12
|
-
URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl alias self].freeze
|
|
17
|
+
URL_KEYS = %i[url link href permalink slug path canonicalUrl shortUrl alias self links].freeze
|
|
18
|
+
# URL keys an entry itself can own; +links+ is an envelope sibling of the node, never the entry's own URL.
|
|
19
|
+
DIRECT_URL_KEYS = (URL_KEYS - %i[links]).freeze
|
|
13
20
|
# Preferred keys when extracting description-like values from state payloads.
|
|
14
21
|
DESCRIPTION_KEYS = %i[description summary excerpt dek subheading abstract].freeze
|
|
15
22
|
# Preferred keys when extracting image-like values from state payloads.
|
|
@@ -27,6 +34,8 @@ module Html2rss
|
|
|
27
34
|
SCALAR_KEYS = %i[rendered value text].freeze
|
|
28
35
|
# Snake `_at`/`_on` or camel `At`/`On` suffix after a closed published-at miss.
|
|
29
36
|
DATE_KEY_SUFFIX = /_at\z|_on\z|[a-z]At\z|[a-z]On\z/
|
|
37
|
+
# Levels below a wrapper entry searched for its article node.
|
|
38
|
+
MAX_NODE_DEPTH = 2
|
|
30
39
|
|
|
31
40
|
class << self
|
|
32
41
|
# rubocop:disable Metrics/MethodLength
|
|
@@ -34,28 +43,60 @@ module Html2rss
|
|
|
34
43
|
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
35
44
|
# @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
|
|
36
45
|
def normalise(entry, base_url:)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
description = string(ValueFinder.fetch(
|
|
41
|
-
article_url = resolve_link(
|
|
42
|
-
|
|
46
|
+
sources = article_sources(entry) # nil (skipped entry) yields no URL below
|
|
47
|
+
title = string(ValueFinder.fetch(sources, TITLE_KEYS))
|
|
48
|
+
kicker = title && string(ValueFinder.fetch(sources, KICKER_KEYS))
|
|
49
|
+
description = string(ValueFinder.fetch(sources, DESCRIPTION_KEYS))
|
|
50
|
+
article_url = resolve_link(sources, keys: URL_KEYS, base_url:,
|
|
51
|
+
log_key: 'JsonState: invalid URL encountered')
|
|
43
52
|
return unless article_url
|
|
44
53
|
return if title.nil? && description.nil?
|
|
45
54
|
|
|
46
55
|
{
|
|
47
56
|
title:,
|
|
57
|
+
kicker:,
|
|
48
58
|
description:,
|
|
49
59
|
url: article_url,
|
|
50
|
-
image: resolve_link(
|
|
51
|
-
|
|
52
|
-
published_at: published_at(
|
|
53
|
-
categories: categories(
|
|
54
|
-
id: identifier(
|
|
60
|
+
image: resolve_link(sources, keys: IMAGE_KEYS, base_url:,
|
|
61
|
+
log_key: 'JsonState: invalid image URL encountered'),
|
|
62
|
+
published_at: published_at(sources),
|
|
63
|
+
categories: categories(sources),
|
|
64
|
+
id: identifier(sources, article_url)
|
|
55
65
|
}.compact
|
|
56
66
|
end
|
|
57
67
|
# rubocop:enable Metrics/MethodLength
|
|
58
68
|
|
|
69
|
+
# Locates the hashes holding an entry's article fields, article node first.
|
|
70
|
+
#
|
|
71
|
+
# An entry with its own title or description is its own article node. A wrapper entry
|
|
72
|
+
# delegates to the only nested Hash (up to {MAX_NODE_DEPTH} levels down) carrying a title
|
|
73
|
+
# and URL, else to the only nested Hash carrying a title; the entry stays the fallback
|
|
74
|
+
# source (e.g. an envelope's sibling +links+). A wrapper entry with its own URL is the
|
|
75
|
+
# link: it delegates only to the only nested Hash carrying an article title (never a bare
|
|
76
|
+
# +name+), and its URL wins over the node's. An entry whose only URL is +links+ is not that
|
|
77
|
+
# link: the node leads and +links+ stays a fallback. Competing nested nodes skip the entry, so an
|
|
78
|
+
# author object holding a name and URL never becomes the article.
|
|
79
|
+
#
|
|
80
|
+
# @param entry [Object] raw article entry candidate
|
|
81
|
+
# @return [Array<Hash>, nil] field sources in lookup order; nil when not a Hash or ambiguous
|
|
82
|
+
def article_sources(entry)
|
|
83
|
+
return unless entry.is_a?(Hash)
|
|
84
|
+
return [entry] if text?(entry)
|
|
85
|
+
|
|
86
|
+
nodes = candidate_nodes(entry)
|
|
87
|
+
return [entry] if nodes.empty?
|
|
88
|
+
return unless nodes.one?
|
|
89
|
+
|
|
90
|
+
node = nodes.first
|
|
91
|
+
own_url?(entry) && url?(node) ? [entry, node] : [node, entry]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @param sources [Hash, Array<Hash>, nil] entry or field sources from {article_sources}
|
|
95
|
+
# @return [Boolean] whether the sources hold a title and a URL value
|
|
96
|
+
def linked?(sources)
|
|
97
|
+
title?(sources) && url?(sources)
|
|
98
|
+
end
|
|
99
|
+
|
|
59
100
|
# @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
|
|
60
101
|
# @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
|
|
61
102
|
def string(value)
|
|
@@ -80,15 +121,13 @@ module Html2rss
|
|
|
80
121
|
value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
81
122
|
end
|
|
82
123
|
|
|
83
|
-
# @param
|
|
124
|
+
# @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
|
|
84
125
|
# @param keys [Array<Symbol>] preferred link keys
|
|
85
126
|
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
86
127
|
# @param log_key [String] structured log message key
|
|
87
128
|
# @return [Html2rss::Url, nil] resolved absolute URL
|
|
88
|
-
def resolve_link(
|
|
89
|
-
|
|
90
|
-
unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
|
|
91
|
-
string = string(value)
|
|
129
|
+
def resolve_link(sources, keys:, base_url:, log_key:)
|
|
130
|
+
string = string(unwrap_url(ValueFinder.fetch(sources, keys), keys))
|
|
92
131
|
return unless string
|
|
93
132
|
|
|
94
133
|
Url.from_relative(string, base_url)
|
|
@@ -97,38 +136,36 @@ module Html2rss
|
|
|
97
136
|
nil
|
|
98
137
|
end
|
|
99
138
|
|
|
100
|
-
# @param value [Object] candidate URL value, possibly a nested Hash
|
|
139
|
+
# @param value [Object] candidate URL value, possibly a nested Hash or a links Array
|
|
101
140
|
# @param keys [Array<Symbol>] preferred link keys
|
|
102
|
-
# @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP}
|
|
141
|
+
# @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} unwraps
|
|
103
142
|
def unwrap_url(value, keys)
|
|
104
143
|
depth = 0
|
|
105
|
-
while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
|
|
144
|
+
while (value.is_a?(Hash) || value.is_a?(Array)) && depth < MAX_URL_UNWRAP
|
|
106
145
|
value = ValueFinder.fetch(value, keys)
|
|
107
146
|
depth += 1
|
|
108
147
|
end
|
|
109
|
-
value unless value.is_a?(Hash)
|
|
148
|
+
value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
110
149
|
end
|
|
111
150
|
|
|
112
|
-
# @param
|
|
151
|
+
# @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
|
|
113
152
|
# @return [String, nil] publication timestamp string or unix timestamp digits
|
|
114
|
-
def published_at(
|
|
115
|
-
value = ValueFinder.fetch(
|
|
116
|
-
string(value) || string(suffix_date_value(
|
|
153
|
+
def published_at(sources)
|
|
154
|
+
value = ValueFinder.fetch(sources, PUBLISHED_AT_KEYS)
|
|
155
|
+
string(value) || string(suffix_date_value(sources))
|
|
117
156
|
end
|
|
118
157
|
|
|
119
|
-
# @param
|
|
158
|
+
# @param sources [Hash, Array<Hash>, nil] entry or field sources, searched in order
|
|
120
159
|
# @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
|
|
121
|
-
def suffix_date_value(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
160
|
+
def suffix_date_value(sources)
|
|
161
|
+
case sources
|
|
162
|
+
when Hash
|
|
163
|
+
sources.each do |key, value|
|
|
164
|
+
return value if date_suffix_key?(key) && (value.is_a?(String) || value.is_a?(Numeric))
|
|
165
|
+
end
|
|
166
|
+
nil
|
|
167
|
+
when Array then sources.filter_map { suffix_date_value(_1) }.first
|
|
129
168
|
end
|
|
130
|
-
|
|
131
|
-
suffix_date_value(hash[:attributes]) || suffix_date_value(hash[:data])
|
|
132
169
|
end
|
|
133
170
|
|
|
134
171
|
# @param key [Symbol, String] candidate published-at key
|
|
@@ -138,10 +175,10 @@ module Html2rss
|
|
|
138
175
|
end
|
|
139
176
|
|
|
140
177
|
# rubocop:disable Metrics/MethodLength
|
|
141
|
-
# @param
|
|
178
|
+
# @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
|
|
142
179
|
# @return [Array<String>, nil] normalized unique categories
|
|
143
|
-
def categories(
|
|
144
|
-
raw = ValueFinder.fetch(
|
|
180
|
+
def categories(sources)
|
|
181
|
+
raw = ValueFinder.fetch(sources, CATEGORY_KEYS)
|
|
145
182
|
names = case raw
|
|
146
183
|
when Array then raw
|
|
147
184
|
when Hash then raw.values
|
|
@@ -163,14 +200,54 @@ module Html2rss
|
|
|
163
200
|
end
|
|
164
201
|
# rubocop:enable Metrics/MethodLength
|
|
165
202
|
|
|
166
|
-
# @param
|
|
203
|
+
# @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
|
|
167
204
|
# @param article_url [Html2rss::Url] resolved article URL
|
|
168
205
|
# @return [String] stable article identifier fallbacking to resolved URL
|
|
169
|
-
def identifier(
|
|
170
|
-
value = ValueFinder.fetch(
|
|
206
|
+
def identifier(sources, article_url)
|
|
207
|
+
value = ValueFinder.fetch(sources, ID_KEYS)
|
|
171
208
|
value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
|
|
172
209
|
string(value) || article_url.to_s
|
|
173
210
|
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# Shape checks (key holds a value), not usability checks: normalise decides usability.
|
|
215
|
+
def text?(hash)
|
|
216
|
+
title?(hash) || !ValueFinder.fetch(hash, DESCRIPTION_KEYS).nil?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def title?(object)
|
|
220
|
+
!ValueFinder.fetch(object, TITLE_KEYS).nil?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def url?(object)
|
|
224
|
+
!ValueFinder.fetch(object, URL_KEYS).nil?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def own_url?(entry)
|
|
228
|
+
!ValueFinder.fetch(entry, DIRECT_URL_KEYS).nil?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Nested nodes that may hold the article fields of a wrapper entry.
|
|
232
|
+
def candidate_nodes(entry)
|
|
233
|
+
titled = titled_nodes(entry, MAX_NODE_DEPTH)
|
|
234
|
+
return titled.reject { ValueFinder.fetch(_1, ARTICLE_TITLE_KEYS).nil? } if own_url?(entry)
|
|
235
|
+
|
|
236
|
+
linked = titled.select { url?(_1) }
|
|
237
|
+
linked.empty? ? titled : linked
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Titled Hash values up to +depth+ levels down; a titled node's children are its own fields.
|
|
241
|
+
def titled_nodes(hash, depth)
|
|
242
|
+
return [] if depth.zero?
|
|
243
|
+
|
|
244
|
+
hash.each_value.flat_map do |value|
|
|
245
|
+
next [] unless value.is_a?(Hash)
|
|
246
|
+
next value if title?(value)
|
|
247
|
+
|
|
248
|
+
titled_nodes(value, depth - 1)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
174
251
|
end
|
|
175
252
|
end
|
|
176
253
|
end
|
|
@@ -30,26 +30,15 @@ module Html2rss
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# @param array [Array<Object>] candidate list of entries
|
|
33
|
-
# @return [Boolean] whether array
|
|
33
|
+
# @return [Boolean] whether array holds at least one title+url hash
|
|
34
34
|
def array_of_articles?(array)
|
|
35
|
-
array.any?
|
|
36
|
-
next unless element.is_a?(Hash)
|
|
37
|
-
|
|
38
|
-
title_from(element) && url_from(element)
|
|
39
|
-
end
|
|
35
|
+
array.any? { linked_entry?(_1) }
|
|
40
36
|
end
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
# @return [Object, nil] detected title-like value
|
|
44
|
-
def title_from(object)
|
|
45
|
-
ValueFinder.fetch(object, ArticleNormalizer::TITLE_KEYS)
|
|
46
|
-
end
|
|
38
|
+
private
|
|
47
39
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def url_from(object)
|
|
51
|
-
ValueFinder.fetch(object, ArticleNormalizer::URL_KEYS) ||
|
|
52
|
-
ValueFinder.fetch(object[:links], ArticleNormalizer::URL_KEYS)
|
|
40
|
+
def linked_entry?(element)
|
|
41
|
+
ArticleNormalizer.linked?(ArticleNormalizer.article_sources(element))
|
|
53
42
|
end
|
|
54
43
|
end
|
|
55
44
|
end
|
|
@@ -19,14 +19,13 @@ module Html2rss
|
|
|
19
19
|
|
|
20
20
|
# @param hash [Hash] hash candidate traversed during key lookup
|
|
21
21
|
# @param keys [Array<Symbol>] keys to probe in order
|
|
22
|
-
# @return [Object, nil]
|
|
22
|
+
# @return [Object, nil] value of the first present key
|
|
23
23
|
def fetch_from_hash(hash, keys)
|
|
24
24
|
keys.each do |key|
|
|
25
25
|
return hash[key] if hash.key?(key)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
fetch_nested(hash[:data], keys)
|
|
28
|
+
nil
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# @param array [Array] array whose entries may contain target keys
|
|
@@ -40,13 +39,6 @@ module Html2rss
|
|
|
40
39
|
|
|
41
40
|
nil
|
|
42
41
|
end
|
|
43
|
-
|
|
44
|
-
# @param value [Hash, Array, nil] nested value to recurse into
|
|
45
|
-
# @param keys [Array<Symbol>] keys to probe in order
|
|
46
|
-
# @return [Object, nil] matching nested value
|
|
47
|
-
def fetch_nested(value, keys)
|
|
48
|
-
fetch(value, keys) if value
|
|
49
|
-
end
|
|
50
42
|
end
|
|
51
43
|
end
|
|
52
44
|
end
|
|
@@ -21,6 +21,7 @@ module Html2rss
|
|
|
21
21
|
|
|
22
22
|
class << self
|
|
23
23
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
24
|
+
# @return [Boolean] whether any embedded JSON document holds candidate articles
|
|
24
25
|
def articles?(parsed_body)
|
|
25
26
|
return false unless parsed_body
|
|
26
27
|
|
|
@@ -62,9 +63,7 @@ module Html2rss
|
|
|
62
63
|
|
|
63
64
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
64
65
|
# @param url [String, Html2rss::Url] page URL used to resolve relative links
|
|
65
|
-
|
|
66
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
67
|
-
def initialize(parsed_body, url:, **_opts)
|
|
66
|
+
def initialize(parsed_body, url:)
|
|
68
67
|
@parsed_body = parsed_body
|
|
69
68
|
@url = url
|
|
70
69
|
end
|
|
@@ -60,10 +60,8 @@ module Html2rss
|
|
|
60
60
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
61
61
|
# @param url [String, Html2rss::Url] base page URL
|
|
62
62
|
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
63
|
-
# @param _opts [Hash] unused scraper-specific options
|
|
64
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
65
63
|
# @return [void]
|
|
66
|
-
def initialize(parsed_body, url:, request_session: nil
|
|
64
|
+
def initialize(parsed_body, url:, request_session: nil)
|
|
67
65
|
@parsed_body = parsed_body
|
|
68
66
|
@url = Html2rss::Url.from_absolute(url)
|
|
69
67
|
@request_session = request_session
|
|
@@ -131,6 +129,8 @@ module Html2rss
|
|
|
131
129
|
|
|
132
130
|
response = request_session.follow_up(url: oembed_url, relation: :auto_source, origin_url: url)
|
|
133
131
|
parse_oembed_response(response)
|
|
132
|
+
rescue Html2rss::ContractError
|
|
133
|
+
raise
|
|
134
134
|
rescue Html2rss::Error, RequestService::UnsupportedResponseContentType, JSON::ParserError => error
|
|
135
135
|
Log.warn({
|
|
136
136
|
event_name: 'meta_oembed.fetch_failed',
|
|
@@ -181,26 +181,18 @@ module Html2rss
|
|
|
181
181
|
end
|
|
182
182
|
|
|
183
183
|
# @param meta_data [Hash{Symbol => Object}] extracted OpenGraph meta tags
|
|
184
|
-
# @return [Array<Hash{Symbol => Object}>, nil] og:video / og:audio enclosures (page-type embeds skipped
|
|
184
|
+
# @return [Array<Hash{Symbol => Object}>, nil] og:video / og:audio enclosures (page-type embeds skipped; no
|
|
185
|
+
# kind, since an og:video is often a player page rather than a file)
|
|
185
186
|
def media_enclosures(meta_data)
|
|
186
187
|
found = %i[video audio].filter_map do |kind|
|
|
187
188
|
type = meta_data[:"#{kind}_type"]
|
|
188
189
|
next if type && !type.start_with?("#{kind}/")
|
|
189
190
|
|
|
190
|
-
|
|
191
|
+
Html2rss::Html::ArticleRules::Enclosure.from_media(meta_data[kind], type, url)
|
|
191
192
|
end
|
|
192
193
|
found unless found.empty?
|
|
193
194
|
end
|
|
194
195
|
|
|
195
|
-
# @param src [String, nil] raw media URL
|
|
196
|
-
# @param type [String, nil] declared MIME type
|
|
197
|
-
# @return [Hash{Symbol => Object}, nil] enclosure attributes
|
|
198
|
-
def media_enclosure(src, type)
|
|
199
|
-
::Html2rss::Html::ArticleRules::Enclosure.from_media(src, type, url)
|
|
200
|
-
rescue Html2rss::Url::InvalidUrlError
|
|
201
|
-
nil
|
|
202
|
-
end
|
|
203
|
-
|
|
204
196
|
# @param raw_url [String, nil] raw relative or absolute URL string
|
|
205
197
|
# @return [Html2rss::Url, nil] resolved absolute URL or nil
|
|
206
198
|
def resolve_url(raw_url)
|
|
@@ -24,6 +24,7 @@ module Html2rss
|
|
|
24
24
|
|
|
25
25
|
class << self
|
|
26
26
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
27
|
+
# @return [Boolean] whether any supported Microdata root exists
|
|
27
28
|
def articles?(parsed_body)
|
|
28
29
|
supported_roots(parsed_body).any?
|
|
29
30
|
end
|
|
@@ -37,6 +38,7 @@ module Html2rss
|
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# @param node [Nokogiri::XML::Element] itemscope candidate node
|
|
41
|
+
# @return [Boolean] whether the node is a supported top-level root
|
|
40
42
|
def supported_root?(node)
|
|
41
43
|
supported_type_name(node) && top_level_item?(node)
|
|
42
44
|
end
|
|
@@ -59,6 +61,7 @@ module Html2rss
|
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# @param node [Nokogiri::XML::Element] itemscope candidate node
|
|
64
|
+
# @return [Boolean] whether the node is not nested in another item property
|
|
62
65
|
def top_level_item?(node)
|
|
63
66
|
return false if node.attribute('itemprop')
|
|
64
67
|
|
|
@@ -78,10 +81,8 @@ module Html2rss
|
|
|
78
81
|
# @param parsed_body [Nokogiri::HTML5::Document, Nokogiri::HTML4::Document, Nokogiri::XML::Node, nil]
|
|
79
82
|
# the parsed response body to inspect for top-level Microdata items.
|
|
80
83
|
# @param url [Html2rss::Url] the absolute page URL used to resolve relative links.
|
|
81
|
-
# @param _opts [Hash] unused scraper-specific options.
|
|
82
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
83
84
|
# @return [void]
|
|
84
|
-
def initialize(parsed_body, url
|
|
85
|
+
def initialize(parsed_body, url:)
|
|
85
86
|
@parsed_body = parsed_body
|
|
86
87
|
@url = url
|
|
87
88
|
end
|
|
@@ -447,7 +448,8 @@ module Html2rss
|
|
|
447
448
|
image: ValueNormalizer.image_value(properties.delete(:image), properties.delete(:thumbnailUrl),
|
|
448
449
|
properties.delete(:thumbnail)),
|
|
449
450
|
video: properties.delete(:video),
|
|
450
|
-
audio: properties.delete(:audio)
|
|
451
|
+
audio: properties.delete(:audio),
|
|
452
|
+
associatedMedia: properties.delete(:associatedMedia)
|
|
451
453
|
}
|
|
452
454
|
end
|
|
453
455
|
|
|
@@ -35,10 +35,8 @@ module Html2rss
|
|
|
35
35
|
|
|
36
36
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
37
37
|
# @param url [String, Html2rss::Url] base page URL
|
|
38
|
-
# @param _opts [Hash] unused scraper-specific options
|
|
39
|
-
# @option _opts [Object] :_reserved reserved for future options
|
|
40
38
|
# @return [void]
|
|
41
|
-
def initialize(parsed_body, url
|
|
39
|
+
def initialize(parsed_body, url:)
|
|
42
40
|
@parsed_body = parsed_body
|
|
43
41
|
@url = Html2rss::Url.from_absolute(url)
|
|
44
42
|
end
|
|
@@ -5,9 +5,15 @@ module Html2rss
|
|
|
5
5
|
module Scraper
|
|
6
6
|
##
|
|
7
7
|
# Promotes a page's native RSS/Atom feed via {Syndication::Discovery} + {Syndication::Parser}.
|
|
8
|
+
# After {#each}, {#channel} holds what that feed states about its channel and {#feed_url} where
|
|
9
|
+
# it was read, the provenance {Cleanup} admits the feed's own domain by.
|
|
8
10
|
class NativeFeed
|
|
9
11
|
include Enumerable
|
|
10
12
|
|
|
13
|
+
# Href shapes that Discovery's path probing can still resolve when the link type is not a feed MIME type.
|
|
14
|
+
FEEDISH_HREF = /rss|atom|feed|\.xml/i
|
|
15
|
+
private_constant :FEEDISH_HREF
|
|
16
|
+
|
|
11
17
|
# @return [Symbol] scraper config key
|
|
12
18
|
def self.options_key = :native_feed
|
|
13
19
|
|
|
@@ -27,25 +33,16 @@ module Html2rss
|
|
|
27
33
|
def self.articles?(parsed_body)
|
|
28
34
|
return false unless parsed_body.is_a?(Nokogiri::HTML::Document)
|
|
29
35
|
|
|
30
|
-
return true if
|
|
36
|
+
return true if Html2rss::Html::FeedLink.from_document(parsed_body).any?
|
|
31
37
|
|
|
32
|
-
parsed_body.css('head link[rel~="alternate"][href]').any?
|
|
33
|
-
href = node['href'].to_s
|
|
34
|
-
::Html2rss::Html::Probe.mime_match?(
|
|
35
|
-
node['type'],
|
|
36
|
-
::Html2rss::Html::Probe::APPLICATION_RSS_XML,
|
|
37
|
-
::Html2rss::Html::Probe::APPLICATION_ATOM_XML
|
|
38
|
-
) || href.match?(/rss|atom|feed|\.xml/i)
|
|
39
|
-
end
|
|
38
|
+
parsed_body.css('head link[rel~="alternate"][href]').any? { |node| node['href'].match?(FEEDISH_HREF) }
|
|
40
39
|
end
|
|
41
40
|
|
|
42
41
|
##
|
|
43
42
|
# @param parsed_body [Nokogiri::HTML::Document]
|
|
44
43
|
# @param url [String, Html2rss::Url]
|
|
45
44
|
# @param request_session [Html2rss::RequestSession, nil]
|
|
46
|
-
|
|
47
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
48
|
-
def initialize(parsed_body, url:, request_session: nil, **_opts)
|
|
45
|
+
def initialize(parsed_body, url:, request_session: nil)
|
|
49
46
|
@parsed_body = parsed_body
|
|
50
47
|
@url = Html2rss::Url.from_absolute(url)
|
|
51
48
|
@request_session = request_session
|
|
@@ -54,6 +51,16 @@ module Html2rss
|
|
|
54
51
|
# @return [Boolean]
|
|
55
52
|
def extractable? = self.class.articles?(parsed_body)
|
|
56
53
|
|
|
54
|
+
##
|
|
55
|
+
# @return [Syndication::ChannelFacts, nil] what the followed feed states about its channel;
|
|
56
|
+
# nil before {#each} or when no feed was read
|
|
57
|
+
def channel = @feed&.channel
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @return [Html2rss::Url, nil] URL of the feed read (after redirects); nil before {#each} or
|
|
61
|
+
# when no feed was read
|
|
62
|
+
attr_reader :feed_url
|
|
63
|
+
|
|
57
64
|
##
|
|
58
65
|
# @yieldparam article [Hash{Symbol => Object}]
|
|
59
66
|
# @return [Enumerator, void]
|
|
@@ -61,7 +68,7 @@ module Html2rss
|
|
|
61
68
|
def each(&)
|
|
62
69
|
return enum_for(:each) unless block_given?
|
|
63
70
|
|
|
64
|
-
articles =
|
|
71
|
+
articles = (@feed = fetch_feed).articles
|
|
65
72
|
if articles.empty?
|
|
66
73
|
Log.info({
|
|
67
74
|
event_name: 'auto_source.native_feed.empty',
|
|
@@ -84,8 +91,8 @@ module Html2rss
|
|
|
84
91
|
attr_reader :parsed_body, :url, :request_session
|
|
85
92
|
|
|
86
93
|
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- discovery + parse + structured warn
|
|
87
|
-
def
|
|
88
|
-
return
|
|
94
|
+
def fetch_feed
|
|
95
|
+
return Syndication::Feed::NONE unless request_session
|
|
89
96
|
|
|
90
97
|
response = Syndication::Discovery.best_feed_response(
|
|
91
98
|
page_url: url,
|
|
@@ -93,9 +100,12 @@ module Html2rss
|
|
|
93
100
|
parsed_body:,
|
|
94
101
|
max_probes: self.class.request_slots
|
|
95
102
|
)
|
|
96
|
-
return
|
|
103
|
+
return Syndication::Feed::NONE unless response
|
|
97
104
|
|
|
105
|
+
@feed_url = response.url
|
|
98
106
|
Syndication::Parser.parse_response(response)
|
|
107
|
+
rescue Html2rss::ContractError
|
|
108
|
+
raise
|
|
99
109
|
rescue Html2rss::Error => error
|
|
100
110
|
Log.warn({
|
|
101
111
|
event_name: 'auto_source.native_feed.failed',
|
|
@@ -103,7 +113,7 @@ module Html2rss
|
|
|
103
113
|
error_class: error.class.name,
|
|
104
114
|
message: "#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})"
|
|
105
115
|
}.to_json)
|
|
106
|
-
|
|
116
|
+
Syndication::Feed::NONE
|
|
107
117
|
end
|
|
108
118
|
end
|
|
109
119
|
end
|
|
@@ -49,11 +49,13 @@ module Html2rss
|
|
|
49
49
|
|
|
50
50
|
# @param object [Hash] candidate schema object
|
|
51
51
|
# @param from_list_item [Boolean] whether object came from a ListItem unwrap
|
|
52
|
-
# @return [Boolean] whether to emit this object
|
|
52
|
+
# @return [Boolean] whether to emit this object: a ListItem unwrap admits everything except pure
|
|
53
|
+
# entities ({Thing::ENTITY_TYPES}); a bare element must be an article, media, Event or JobPosting type
|
|
53
54
|
def emit?(object, from_list_item:)
|
|
54
|
-
|
|
55
|
+
types = Schema.normalize_types(object[:@type])
|
|
56
|
+
return types.empty? || !types.subset?(Thing::ENTITY_TYPES) if from_list_item
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
types.intersect?(Thing::ITEM_TYPES | Thing::ITEM_LIST_TYPES)
|
|
57
59
|
end
|
|
58
60
|
end
|
|
59
61
|
end
|