html2rss 0.32.4 → 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 -25
- 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 +127 -53
- 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 -45
- data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
- data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
- data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
- data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
- data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
- data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
- data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
- data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
- data/lib/html2rss/auto_source/scraper.rb +10 -13
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
- data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +24 -32
- data/lib/html2rss/capture/README.md +6 -3
- data/lib/html2rss/capture.rb +51 -59
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli/render.rb +97 -96
- data/lib/html2rss/cli/validate.rb +2 -4
- data/lib/html2rss/cli.rb +55 -38
- 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 +16 -5
- data/lib/html2rss/config/schema.rb +23 -15
- data/lib/html2rss/config/selectors_validator.rb +3 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +14 -3
- data/lib/html2rss/config.rb +54 -28
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +25 -38
- data/lib/html2rss/doctor/botasaurus.rb +18 -22
- data/lib/html2rss/error.rb +17 -3
- 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 +50 -7
- 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 +16 -34
- 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 +6 -8
- data/lib/html2rss/feed_resolution/options.rb +3 -7
- data/lib/html2rss/feed_resolution/policy.rb +29 -33
- data/lib/html2rss/feed_resolution/probe.rb +6 -4
- data/lib/html2rss/feed_resolution/scorer.rb +29 -29
- data/lib/html2rss/feed_resolution.rb +16 -21
- data/lib/html2rss/feed_result.rb +11 -6
- data/lib/html2rss/hash_util.rb +91 -90
- data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
- data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
- 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 +55 -51
- 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 +111 -42
- 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 +14 -4
- data/lib/html2rss/mcp/contract.rb +20 -9
- data/lib/html2rss/mcp/outcome.rb +18 -17
- data/lib/html2rss/mcp/runtime.rb +53 -23
- data/lib/html2rss/mcp/server/tools.rb +9 -9
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/option.rb +48 -0
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +9 -9
- data/lib/html2rss/page_recon.rb +0 -4
- data/lib/html2rss/recon.rb +19 -23
- data/lib/html2rss/request_service/blocked_surface.rb +5 -8
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
- data/lib/html2rss/request_service/budget.rb +3 -24
- data/lib/html2rss/request_service/compressed_body.rb +36 -48
- data/lib/html2rss/request_service/context.rb +16 -17
- 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 -3
- data/lib/html2rss/request_service/network_guard.rb +3 -2
- data/lib/html2rss/request_service/policy.rb +55 -36
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +33 -19
- 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 +3 -10
- data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
- data/lib/html2rss/request_session/pager/offset.rb +1 -7
- data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
- data/lib/html2rss/request_session/pager/url_template.rb +1 -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 +10 -38
- data/lib/html2rss/scoring/link_resolver.rb +2 -7
- 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/extractors/href.rb +2 -4
- data/lib/html2rss/selectors/json_xml.rb +33 -34
- 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 +9 -10
- 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 +5 -6
- 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/schema_export.rb +60 -60
- data/lib/html2rss/selectors.rb +67 -30
- 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 +42 -29
- data/lib/html2rss/sst/normalizer.rb +47 -34
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +94 -58
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +27 -31
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +39 -0
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +171 -187
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +117 -116
- data/lib/html2rss/test/enhance_audit.rb +15 -17
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test/policy.rb +9 -11
- data/lib/html2rss/test.rb +66 -57
- data/lib/html2rss/url.rb +163 -62
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +19 -9
- data/schema/html2rss-config.schema.json +56 -41
- metadata +33 -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,150 +34,220 @@ 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return if title.nil? && description.nil?
|
|
46
|
-
|
|
47
|
-
{
|
|
48
|
-
title:,
|
|
49
|
-
description:,
|
|
50
|
-
url: article_url,
|
|
51
|
-
image: resolve_link(entry, keys: IMAGE_KEYS, base_url:,
|
|
52
|
-
log_key: 'JsonState: invalid image URL encountered'),
|
|
53
|
-
published_at: published_at(entry),
|
|
54
|
-
categories: categories(entry),
|
|
55
|
-
id: identifier(entry, article_url)
|
|
56
|
-
}.compact
|
|
57
|
-
end
|
|
58
|
-
# rubocop:enable Metrics/MethodLength
|
|
40
|
+
class << self
|
|
41
|
+
# rubocop:disable Metrics/MethodLength
|
|
42
|
+
# @param entry [Hash] raw article entry candidate
|
|
43
|
+
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
44
|
+
# @return [Hash{Symbol => Object, nil}] normalized article hash for downstream extraction
|
|
45
|
+
def normalise(entry, base_url:)
|
|
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')
|
|
52
|
+
return unless article_url
|
|
53
|
+
return if title.nil? && description.nil?
|
|
59
54
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
55
|
+
{
|
|
56
|
+
title:,
|
|
57
|
+
kicker:,
|
|
58
|
+
description:,
|
|
59
|
+
url: article_url,
|
|
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)
|
|
65
|
+
}.compact
|
|
66
|
+
end
|
|
67
|
+
# rubocop:enable Metrics/MethodLength
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
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)
|
|
69
85
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
86
|
+
nodes = candidate_nodes(entry)
|
|
87
|
+
return [entry] if nodes.empty?
|
|
88
|
+
return unless nodes.one?
|
|
77
89
|
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
node = nodes.first
|
|
91
|
+
own_url?(entry) && url?(node) ? [entry, node] : [node, entry]
|
|
80
92
|
end
|
|
81
|
-
value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
82
|
-
end
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def resolve_link(entry, keys:, base_url:, log_key:)
|
|
90
|
-
value = unwrap_url(ValueFinder.fetch(entry, keys), keys) ||
|
|
91
|
-
unwrap_url(ValueFinder.fetch(entry[:links], keys), keys)
|
|
92
|
-
string = string(value)
|
|
93
|
-
return unless string
|
|
94
|
-
|
|
95
|
-
Url.from_relative(string, base_url)
|
|
96
|
-
rescue ArgumentError
|
|
97
|
-
Log.debug("#{log_key}: url=#{string}")
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
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
|
|
100
99
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
# @param value [Object] candidate scalar, or Hash wrapping +rendered+/+value+/+text+
|
|
101
|
+
# @return [String, nil] normalized non-empty string (HTML preserved for descriptions)
|
|
102
|
+
def string(value)
|
|
103
|
+
value = unwrap_scalar(value)
|
|
104
|
+
return unless value.is_a?(String) || value.is_a?(Numeric)
|
|
105
|
+
|
|
106
|
+
trimmed = value.to_s.strip
|
|
107
|
+
trimmed unless trimmed.empty?
|
|
109
108
|
end
|
|
110
|
-
value unless value.is_a?(Hash)
|
|
111
|
-
end
|
|
112
109
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
110
|
+
# @param value [Object] candidate scalar or nested Hash
|
|
111
|
+
# @return [Object, nil] String/Numeric after at most {MAX_URL_UNWRAP} Hash unwraps
|
|
112
|
+
def unwrap_scalar(value)
|
|
113
|
+
depth = 0
|
|
114
|
+
while value.is_a?(Hash) && depth < MAX_URL_UNWRAP
|
|
115
|
+
nested = SCALAR_KEYS.find { |key| value.key?(key) }
|
|
116
|
+
break unless nested
|
|
119
117
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
value = value[nested]
|
|
119
|
+
depth += 1
|
|
120
|
+
end
|
|
121
|
+
value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
122
|
+
end
|
|
124
123
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
# @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
|
|
125
|
+
# @param keys [Array<Symbol>] preferred link keys
|
|
126
|
+
# @param base_url [String, Html2rss::Url] base URL for relative link resolution
|
|
127
|
+
# @param log_key [String] structured log message key
|
|
128
|
+
# @return [Html2rss::Url, nil] resolved absolute URL
|
|
129
|
+
def resolve_link(sources, keys:, base_url:, log_key:)
|
|
130
|
+
string = string(unwrap_url(ValueFinder.fetch(sources, keys), keys))
|
|
131
|
+
return unless string
|
|
128
132
|
|
|
129
|
-
|
|
133
|
+
Url.from_relative(string, base_url)
|
|
134
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
135
|
+
Log.debug("#{log_key}: url=#{string}")
|
|
136
|
+
nil
|
|
130
137
|
end
|
|
131
138
|
|
|
132
|
-
|
|
133
|
-
|
|
139
|
+
# @param value [Object] candidate URL value, possibly a nested Hash or a links Array
|
|
140
|
+
# @param keys [Array<Symbol>] preferred link keys
|
|
141
|
+
# @return [Object, nil] scalar URL candidate after at most {MAX_URL_UNWRAP} unwraps
|
|
142
|
+
def unwrap_url(value, keys)
|
|
143
|
+
depth = 0
|
|
144
|
+
while (value.is_a?(Hash) || value.is_a?(Array)) && depth < MAX_URL_UNWRAP
|
|
145
|
+
value = ValueFinder.fetch(value, keys)
|
|
146
|
+
depth += 1
|
|
147
|
+
end
|
|
148
|
+
value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
149
|
+
end
|
|
134
150
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
# @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
|
|
152
|
+
# @return [String, nil] publication timestamp string or unix timestamp digits
|
|
153
|
+
def published_at(sources)
|
|
154
|
+
value = ValueFinder.fetch(sources, PUBLISHED_AT_KEYS)
|
|
155
|
+
string(value) || string(suffix_date_value(sources))
|
|
156
|
+
end
|
|
140
157
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
raw = ValueFinder.fetch(entry, CATEGORY_KEYS)
|
|
146
|
-
names = case raw
|
|
147
|
-
when Array then raw
|
|
148
|
-
when Hash then raw.values
|
|
149
|
-
when String then [raw]
|
|
150
|
-
else []
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
result = names.flat_map do |value|
|
|
154
|
-
case value
|
|
158
|
+
# @param sources [Hash, Array<Hash>, nil] entry or field sources, searched in order
|
|
159
|
+
# @return [Object, nil] first string or numeric value whose key matches {DATE_KEY_SUFFIX}
|
|
160
|
+
def suffix_date_value(sources)
|
|
161
|
+
case sources
|
|
155
162
|
when Hash
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
159
168
|
end
|
|
160
|
-
end
|
|
169
|
+
end
|
|
161
170
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
171
|
+
# @param key [Symbol, String] candidate published-at key
|
|
172
|
+
# @return [Boolean] whether the key ends in a date suffix (_at/_on/At/On)
|
|
173
|
+
def date_suffix_key?(key)
|
|
174
|
+
key.to_s.match?(DATE_KEY_SUFFIX)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# rubocop:disable Metrics/MethodLength
|
|
178
|
+
# @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
|
|
179
|
+
# @return [Array<String>, nil] normalized unique categories
|
|
180
|
+
def categories(sources)
|
|
181
|
+
raw = ValueFinder.fetch(sources, CATEGORY_KEYS)
|
|
182
|
+
names = case raw
|
|
183
|
+
when Array then raw
|
|
184
|
+
when Hash then raw.values
|
|
185
|
+
when String then [raw]
|
|
186
|
+
else []
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
result = names.flat_map do |value|
|
|
190
|
+
case value
|
|
191
|
+
when Hash
|
|
192
|
+
string(ValueFinder.fetch(value, %i[name title label]))
|
|
193
|
+
else
|
|
194
|
+
string(value)
|
|
195
|
+
end
|
|
196
|
+
end.compact
|
|
197
|
+
|
|
198
|
+
result.uniq!
|
|
199
|
+
result unless result.empty?
|
|
200
|
+
end
|
|
201
|
+
# rubocop:enable Metrics/MethodLength
|
|
202
|
+
|
|
203
|
+
# @param sources [Hash, Array<Hash>] entry or field sources from {article_sources}
|
|
204
|
+
# @param article_url [Html2rss::Url] resolved article URL
|
|
205
|
+
# @return [String] stable article identifier fallbacking to resolved URL
|
|
206
|
+
def identifier(sources, article_url)
|
|
207
|
+
value = ValueFinder.fetch(sources, ID_KEYS)
|
|
208
|
+
value = ValueFinder.fetch(value, ID_KEYS) if value.is_a?(Hash)
|
|
209
|
+
string(value) || article_url.to_s
|
|
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
|
|
@@ -6,51 +6,40 @@ module Html2rss
|
|
|
6
6
|
class JsonState
|
|
7
7
|
# Identifies arrays that look like collections of article hashes.
|
|
8
8
|
module CandidateDetector
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
document.any? {
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
class << self
|
|
10
|
+
# @param document [Hash, Array, Object] candidate document node
|
|
11
|
+
# @return [Boolean] whether the node contains article-like arrays
|
|
12
|
+
def candidate_array?(document)
|
|
13
|
+
case document
|
|
14
|
+
when Array
|
|
15
|
+
return true if array_of_articles?(document)
|
|
16
|
+
|
|
17
|
+
document.any? { traversable_candidate?(_1) }
|
|
18
|
+
when Hash then document.each_value.any? { candidate_array?(_1) }
|
|
19
|
+
else false
|
|
20
|
+
end
|
|
21
21
|
end
|
|
22
|
-
end
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
# @param value [Hash, Array, Object] candidate nested value
|
|
24
|
+
# @return [Boolean] whether nested value should be traversed for article candidates
|
|
25
|
+
def traversable_candidate?(value)
|
|
26
|
+
case value
|
|
27
|
+
when Array, Hash then candidate_array?(value)
|
|
28
|
+
else false
|
|
29
|
+
end
|
|
30
30
|
end
|
|
31
|
-
end
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
next unless element.is_a?(Hash)
|
|
38
|
-
|
|
39
|
-
title_from(element) && url_from(element)
|
|
32
|
+
# @param array [Array<Object>] candidate list of entries
|
|
33
|
+
# @return [Boolean] whether array holds at least one title+url hash
|
|
34
|
+
def array_of_articles?(array)
|
|
35
|
+
array.any? { linked_entry?(_1) }
|
|
40
36
|
end
|
|
41
|
-
end
|
|
42
37
|
|
|
43
|
-
|
|
44
|
-
# @return [Object, nil] detected title-like value
|
|
45
|
-
def title_from(object)
|
|
46
|
-
ValueFinder.fetch(object, ArticleNormalizer::TITLE_KEYS)
|
|
47
|
-
end
|
|
38
|
+
private
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ValueFinder.fetch(object, ArticleNormalizer::URL_KEYS) ||
|
|
53
|
-
ValueFinder.fetch(object[:links], ArticleNormalizer::URL_KEYS)
|
|
40
|
+
def linked_entry?(element)
|
|
41
|
+
ArticleNormalizer.linked?(ArticleNormalizer.article_sources(element))
|
|
42
|
+
end
|
|
54
43
|
end
|
|
55
44
|
end
|
|
56
45
|
end
|