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
|
@@ -26,149 +26,149 @@ module Html2rss
|
|
|
26
26
|
# Combined regex for faster matching of global assignments.
|
|
27
27
|
GLOBAL_ASSIGNMENT_REGEXP = Regexp.union(GLOBAL_ASSIGNMENT_PATTERNS).freeze
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
end
|
|
29
|
+
class << self
|
|
30
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
31
|
+
# @return [Array<Hash, Array>] parsed JSON documents discovered in scripts
|
|
32
|
+
def json_documents(parsed_body)
|
|
33
|
+
# Use identity-based cache to avoid double-parsing of the same document.
|
|
34
|
+
# WeakMap allows the Nokogiri Document (key) to be garbage collected.
|
|
35
|
+
# rubocop:disable-next ThreadSafety/ClassInstanceVariable
|
|
36
|
+
(@cache ||= ObjectSpace::WeakMap.new)[parsed_body] ||=
|
|
37
|
+
script_documents(parsed_body) + assignment_documents(parsed_body)
|
|
38
|
+
end
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
41
|
+
# @return [Array<Hash, Array>] JSON documents extracted from JSON script tags
|
|
42
|
+
def script_documents(parsed_body)
|
|
43
|
+
::Html2rss::Html::Probe.scripts(parsed_body, ::Html2rss::Html::Probe::APPLICATION_JSON)
|
|
44
|
+
.filter_map { parse_json(_1.text) }
|
|
45
|
+
end
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
48
|
+
# @return [Array<Hash, Array>] JSON documents extracted from global assignments
|
|
49
|
+
def assignment_documents(parsed_body)
|
|
50
|
+
parsed_body.css('script').filter_map { parse_assignment(_1.text) }
|
|
51
|
+
end
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
# @param text [String] script text that may contain a global assignment
|
|
54
|
+
# @return [Hash, Array, nil] parsed assignment payload when available
|
|
55
|
+
def parse_assignment(text)
|
|
56
|
+
payload = assignment_payload(text)
|
|
57
|
+
parse_json(payload) if payload
|
|
58
|
+
end
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
# @param text [String] script text to inspect for known assignment patterns
|
|
61
|
+
# @return [String, nil] extracted JSON-like assignment payload
|
|
62
|
+
def assignment_payload(text)
|
|
63
|
+
trimmed = text.to_s.strip
|
|
64
|
+
return if trimmed.empty?
|
|
65
|
+
return unless trimmed.match?(GLOBAL_ASSIGNMENT_REGEXP)
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
payload = trimmed.sub(GLOBAL_ASSIGNMENT_REGEXP, '')
|
|
68
|
+
extract_assignment_payload(payload)
|
|
69
|
+
end
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
# @param text [String] text potentially containing JSON-like payloads
|
|
72
|
+
# @return [String, nil] normalized assignment payload
|
|
73
|
+
def extract_assignment_payload(text)
|
|
74
|
+
extract_json_block(text) || text
|
|
75
|
+
end
|
|
77
76
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
# @param text [String] text potentially containing JSON blocks
|
|
78
|
+
# @return [String, nil] extracted JSON block spanning balanced brackets
|
|
79
|
+
def extract_json_block(text)
|
|
80
|
+
start_index = text.index(/[\[{]/)
|
|
81
|
+
return unless start_index
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
stop_index = scan_for_json_end(text, start_index)
|
|
84
|
+
text[start_index..stop_index] if stop_index
|
|
85
|
+
end
|
|
87
86
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
87
|
+
# @param text [String] text starting with a JSON object/array opening token
|
|
88
|
+
# @param start_index [Integer] index where JSON-like content starts
|
|
89
|
+
# @return [Integer, nil] index where the balanced JSON payload ends
|
|
90
|
+
def scan_for_json_end(text, start_index) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
91
|
+
stack = []
|
|
92
|
+
in_string = false
|
|
93
|
+
escape = false
|
|
94
|
+
|
|
95
|
+
i = start_index
|
|
96
|
+
len = text.length
|
|
97
|
+
while i < len
|
|
98
|
+
char = text[i]
|
|
99
|
+
|
|
100
|
+
if in_string
|
|
101
|
+
if escape
|
|
102
|
+
escape = false
|
|
103
|
+
elsif char == '\\'
|
|
104
|
+
escape = true
|
|
105
|
+
elsif char == '"'
|
|
106
|
+
in_string = false
|
|
107
|
+
end
|
|
108
|
+
else
|
|
109
|
+
case char
|
|
110
|
+
when '"' then in_string = true
|
|
111
|
+
when '{' then stack << '}'
|
|
112
|
+
when '[' then stack << ']'
|
|
113
|
+
when '}', ']'
|
|
114
|
+
expected = stack.pop
|
|
115
|
+
return i if expected == char && stack.empty?
|
|
116
|
+
end
|
|
117
117
|
end
|
|
118
|
+
i += 1
|
|
118
119
|
end
|
|
119
|
-
i += 1
|
|
120
|
-
end
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
124
123
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
# @param payload [String, nil] JSON payload to parse
|
|
125
|
+
# @return [Hash, Array, nil] parsed payload or nil when parsing fails
|
|
126
|
+
def parse_json(payload)
|
|
127
|
+
return unless payload
|
|
129
128
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
JSON.parse(payload, symbolize_names: true)
|
|
130
|
+
rescue JSON::ParserError => error
|
|
131
|
+
parse_js_object(payload, error)
|
|
132
|
+
end
|
|
134
133
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
134
|
+
# @param payload [String] JavaScript object-literal payload
|
|
135
|
+
# @param _original_error [JSON::ParserError] original JSON parse error
|
|
136
|
+
# @return [Hash, Array, nil] parsed payload after JavaScript coercion
|
|
137
|
+
def parse_js_object(payload, _original_error)
|
|
138
|
+
coerced = coerce_javascript_object(payload)
|
|
139
|
+
return unless coerced
|
|
140
|
+
|
|
141
|
+
# Some sites emit JavaScript object literals (unquoted keys, trailing commas).
|
|
142
|
+
# Coerce those payloads into valid JSON so we keep the same parsing pipeline.
|
|
143
|
+
JSON.parse(coerced, symbolize_names: true)
|
|
144
|
+
rescue JSON::ParserError => error
|
|
145
|
+
Html2rss::Log.debug("JsonState: failed to parse coerced JavaScript object (#{error.message})")
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
149
148
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
# @param payload [String] JavaScript object-literal payload
|
|
150
|
+
# @return [String] JSON-compatible payload string
|
|
151
|
+
def coerce_javascript_object(payload)
|
|
152
|
+
string = payload.dup
|
|
154
153
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
# KISS approach: mutate common JS literal quirks instead of a full parser.
|
|
155
|
+
strip_trailing_commas(quote_unquoted_keys(string))
|
|
156
|
+
end
|
|
158
157
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
158
|
+
# @param jsonish [String] JSON-like string with potentially unquoted keys
|
|
159
|
+
# @return [String] payload with unquoted object keys quoted
|
|
160
|
+
def quote_unquoted_keys(jsonish)
|
|
161
|
+
jsonish.gsub(/(?<prefix>\A\s*|[{,\[]\s*)(?<key>[A-Za-z_]\w*)(?<suffix>\s*:)/) do
|
|
162
|
+
captures = Regexp.last_match.named_captures(symbolize_names: true)
|
|
163
|
+
"#{captures[:prefix]}\"#{captures[:key]}\"#{captures[:suffix]}"
|
|
164
|
+
end
|
|
165
165
|
end
|
|
166
|
-
end
|
|
167
166
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
167
|
+
# @param jsonish [String] JSON-like string with potential trailing commas
|
|
168
|
+
# @return [String] payload without trailing commas before closing tokens
|
|
169
|
+
def strip_trailing_commas(jsonish)
|
|
170
|
+
jsonish.gsub(/,(\s*[\]}])/, '\1')
|
|
171
|
+
end
|
|
172
172
|
end
|
|
173
173
|
end
|
|
174
174
|
end
|
|
@@ -6,47 +6,39 @@ module Html2rss
|
|
|
6
6
|
class JsonState
|
|
7
7
|
# Retrieves values from heterogeneous objects by probing multiple keys.
|
|
8
8
|
module ValueFinder
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
class << self
|
|
10
|
+
# @param object [Hash, Array] candidate container traversed during key lookup
|
|
11
|
+
# @param keys [Array<Symbol>] keys to probe in order
|
|
12
|
+
# @return [Object, nil] first matching value
|
|
13
|
+
def fetch(object, keys)
|
|
14
|
+
case object
|
|
15
|
+
when Hash then fetch_from_hash(object, keys)
|
|
16
|
+
when Array then fetch_from_array(object, keys)
|
|
17
|
+
end
|
|
18
18
|
end
|
|
19
|
-
end
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
20
|
+
# @param hash [Hash] hash candidate traversed during key lookup
|
|
21
|
+
# @param keys [Array<Symbol>] keys to probe in order
|
|
22
|
+
# @return [Object, nil] value of the first present key
|
|
23
|
+
def fetch_from_hash(hash, keys)
|
|
24
|
+
keys.each do |key|
|
|
25
|
+
return hash[key] if hash.key?(key)
|
|
26
|
+
end
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
fetch_nested(hash[:data], keys)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# @param array [Array] array whose entries may contain target keys
|
|
34
|
-
# @param keys [Array<Symbol>] keys to probe in order
|
|
35
|
-
# @return [Object, nil] first matching value from array entries
|
|
36
|
-
def fetch_from_array(array, keys)
|
|
37
|
-
array.each do |entry|
|
|
38
|
-
result = fetch(entry, keys)
|
|
39
|
-
return result if result
|
|
28
|
+
nil
|
|
40
29
|
end
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
|
|
31
|
+
# @param array [Array] array whose entries may contain target keys
|
|
32
|
+
# @param keys [Array<Symbol>] keys to probe in order
|
|
33
|
+
# @return [Object, nil] first matching value from array entries
|
|
34
|
+
def fetch_from_array(array, keys)
|
|
35
|
+
array.each do |entry|
|
|
36
|
+
result = fetch(entry, keys)
|
|
37
|
+
return result if result
|
|
38
|
+
end
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# @return [Object, nil] matching nested value
|
|
48
|
-
def fetch_nested(value, keys)
|
|
49
|
-
fetch(value, keys) if value
|
|
40
|
+
nil
|
|
41
|
+
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
|
|
|
@@ -43,7 +44,7 @@ module Html2rss
|
|
|
43
44
|
def discover_articles(document, base_url:, &block)
|
|
44
45
|
case document
|
|
45
46
|
when Array then handle_array(document, base_url:, &block)
|
|
46
|
-
when Hash then document.each_value { discover_articles(_1, base_url:, &block)
|
|
47
|
+
when Hash then document.each_value { discover_articles(_1, base_url:, &block) }
|
|
47
48
|
end
|
|
48
49
|
end
|
|
49
50
|
|
|
@@ -55,20 +56,14 @@ module Html2rss
|
|
|
55
56
|
yield(ArticleNormalizer.normalise(entry, base_url:))
|
|
56
57
|
end
|
|
57
58
|
else
|
|
58
|
-
array.each { discover_articles(_1, base_url:, &block)
|
|
59
|
+
array.each { discover_articles(_1, base_url:, &block) }
|
|
59
60
|
end
|
|
60
61
|
end
|
|
61
|
-
|
|
62
|
-
def traversable?(value)
|
|
63
|
-
value.is_a?(Array) || value.is_a?(Hash)
|
|
64
|
-
end
|
|
65
62
|
end
|
|
66
63
|
|
|
67
64
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML document
|
|
68
65
|
# @param url [String, Html2rss::Url] page URL used to resolve relative links
|
|
69
|
-
|
|
70
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
71
|
-
def initialize(parsed_body, url:, **_opts)
|
|
66
|
+
def initialize(parsed_body, url:)
|
|
72
67
|
@parsed_body = parsed_body
|
|
73
68
|
@url = url
|
|
74
69
|
end
|
|
@@ -23,7 +23,15 @@ module Html2rss
|
|
|
23
23
|
'og:image' => :image,
|
|
24
24
|
'twitter:image' => :image,
|
|
25
25
|
'article:published_time' => :published_at,
|
|
26
|
-
'article:author' => :author
|
|
26
|
+
'article:author' => :author,
|
|
27
|
+
'og:video' => :video,
|
|
28
|
+
'og:video:url' => :video,
|
|
29
|
+
'og:video:secure_url' => :video,
|
|
30
|
+
'og:video:type' => :video_type,
|
|
31
|
+
'og:audio' => :audio,
|
|
32
|
+
'og:audio:url' => :audio,
|
|
33
|
+
'og:audio:secure_url' => :audio,
|
|
34
|
+
'og:audio:type' => :audio_type
|
|
27
35
|
}.freeze
|
|
28
36
|
private_constant :META_MAP
|
|
29
37
|
|
|
@@ -52,10 +60,8 @@ module Html2rss
|
|
|
52
60
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
53
61
|
# @param url [String, Html2rss::Url] base page URL
|
|
54
62
|
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
55
|
-
# @param _opts [Hash] unused scraper-specific options
|
|
56
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
57
63
|
# @return [void]
|
|
58
|
-
def initialize(parsed_body, url:, request_session: nil
|
|
64
|
+
def initialize(parsed_body, url:, request_session: nil)
|
|
59
65
|
@parsed_body = parsed_body
|
|
60
66
|
@url = Html2rss::Url.from_absolute(url)
|
|
61
67
|
@request_session = request_session
|
|
@@ -123,6 +129,8 @@ module Html2rss
|
|
|
123
129
|
|
|
124
130
|
response = request_session.follow_up(url: oembed_url, relation: :auto_source, origin_url: url)
|
|
125
131
|
parse_oembed_response(response)
|
|
132
|
+
rescue Html2rss::ContractError
|
|
133
|
+
raise
|
|
126
134
|
rescue Html2rss::Error, RequestService::UnsupportedResponseContentType, JSON::ParserError => error
|
|
127
135
|
Log.warn({
|
|
128
136
|
event_name: 'meta_oembed.fetch_failed',
|
|
@@ -167,10 +175,24 @@ module Html2rss
|
|
|
167
175
|
description: meta_data[:description] || oembed_data[:html],
|
|
168
176
|
author: oembed_data[:author] || meta_data[:author],
|
|
169
177
|
published_at: meta_data[:published_at],
|
|
170
|
-
image: oembed_data[:thumbnail] || meta_data[:image]
|
|
178
|
+
image: oembed_data[:thumbnail] || meta_data[:image],
|
|
179
|
+
enclosures: media_enclosures(meta_data)
|
|
171
180
|
}.compact
|
|
172
181
|
end
|
|
173
182
|
|
|
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; no
|
|
185
|
+
# kind, since an og:video is often a player page rather than a file)
|
|
186
|
+
def media_enclosures(meta_data)
|
|
187
|
+
found = %i[video audio].filter_map do |kind|
|
|
188
|
+
type = meta_data[:"#{kind}_type"]
|
|
189
|
+
next if type && !type.start_with?("#{kind}/")
|
|
190
|
+
|
|
191
|
+
Html2rss::Html::ArticleRules::Enclosure.from_media(meta_data[kind], type, url)
|
|
192
|
+
end
|
|
193
|
+
found unless found.empty?
|
|
194
|
+
end
|
|
195
|
+
|
|
174
196
|
# @param raw_url [String, nil] raw relative or absolute URL string
|
|
175
197
|
# @return [Html2rss::Url, nil] resolved absolute URL or nil
|
|
176
198
|
def resolve_url(raw_url)
|