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
|
@@ -3,217 +3,201 @@
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module Syndication
|
|
5
5
|
##
|
|
6
|
-
# Finds
|
|
6
|
+
# Finds an RSS/Atom URL for a page via head alternates and same-origin path guesses.
|
|
7
7
|
#
|
|
8
8
|
# Ports configs +probe_rss+ path/alternate logic onto {RequestSession#follow_up}.
|
|
9
|
-
|
|
9
|
+
# Head alternates keep document order within two groups: those whose title or URL
|
|
10
|
+
# hints audio ({AUDIO_HINT}) and the rest. On a page that shows podcast signals
|
|
11
|
+
# ({PODCAST_PAGE_HINT} in its URL host, path or +<title>+, an +og:type+ matching {MEDIA_OG_TYPE},
|
|
12
|
+
# or a {PODCAST_SCHEMA_TYPE} in its JSON-LD) the order is audio-hinted alternates, other
|
|
13
|
+
# alternates, path guesses. On any other page it is other alternates, path guesses, then the
|
|
14
|
+
# audio-hinted alternates, so a news page that advertises only a podcast feed does not become
|
|
15
|
+
# a media feed. This is the one ordering owner: {.best_feed_url}, {.candidate_urls} and the
|
|
16
|
+
# feed-resolution feed slot all read it. Alternates are fetched as +:advertised_feed+ with the
|
|
17
|
+
# page's {RequestService::FeedAdvertisement}, so a feed the page advertises on another host
|
|
18
|
+
# is followed (see {RequestService::Policy}). Path guesses stay same-origin.
|
|
19
|
+
module Discovery # rubocop:disable Metrics/ModuleLength -- discovery ordering + probe stay on one owner
|
|
10
20
|
# Common feed path suffixes probed after head +rel=alternate+ hints.
|
|
11
21
|
DEFAULT_PATHS = CandidateCatalog::FEED_PATHS
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# Like {#best_feed_url} but returns the validated syndication response for parsing.
|
|
41
|
-
#
|
|
42
|
-
# @param page_url [String, Html2rss::Url]
|
|
43
|
-
# @param request_session [Html2rss::RequestSession]
|
|
44
|
-
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
45
|
-
# @param html [String, nil]
|
|
46
|
-
# @param extra_paths [Array<String>]
|
|
47
|
-
# @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
|
|
48
|
-
# @return [Html2rss::RequestService::Response, nil]
|
|
49
|
-
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
|
|
50
|
-
def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
51
|
-
max_probes: nil)
|
|
52
|
-
page = Html2rss::Url.from_absolute(page_url)
|
|
53
|
-
candidates = candidate_urls(page_url: page, parsed_body:, html:, extra_paths:)
|
|
54
|
-
Log.debug(
|
|
55
|
-
"Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}"
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
first_feedish_response(
|
|
59
|
-
candidates, request_session:, origin_url: page, max_probes:
|
|
60
|
-
).tap do |response|
|
|
61
|
-
next unless response
|
|
62
|
-
|
|
63
|
-
Log.info({
|
|
64
|
-
event_name: 'syndication.discovery.selected',
|
|
65
|
-
host: page.host,
|
|
66
|
-
message: "Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}"
|
|
67
|
-
}.to_json)
|
|
22
|
+
# Words marking a podcast page in its URL host, path or title; {AUDIO_HINT} adds audio file formats.
|
|
23
|
+
PODCAST_WORDS = %w[podcast episode audio].freeze
|
|
24
|
+
# Word-ish podcast hint in a page's URL host, path or +<title>+ (plural allowed, case-insensitive).
|
|
25
|
+
PODCAST_PAGE_HINT = /(?<![[:alpha:]])(?:#{PODCAST_WORDS.join('|')})s?(?![[:alpha:]])/i
|
|
26
|
+
# Word-ish audio hint in an alternate's title or URL (Podlove-style per-format podcast feeds).
|
|
27
|
+
AUDIO_HINT = /(?<![[:alpha:]])(?:#{(PODCAST_WORDS + %w[mp3 m4a aac opus]).join('|')})s?(?![[:alpha:]])/i
|
|
28
|
+
# +og:type+ values (+music.song+, +video.episode+, ...) marking a podcast or media page.
|
|
29
|
+
MEDIA_OG_TYPE = /\A\s*(?:music|video)\./i
|
|
30
|
+
# A JSON-LD +@type+ naming podcast content (short name or schema.org URL, alone or in an
|
|
31
|
+
# array), matched on the script text anywhere in the page without parsing the JSON.
|
|
32
|
+
PODCAST_SCHEMA_TYPE = %r{"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?
|
|
33
|
+
"(?:https?://schema\.org/)?(?:PodcastSeries|PodcastEpisode|AudioObject)"}ix
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
##
|
|
37
|
+
# Probes candidates lazily and returns the first feedish URL.
|
|
38
|
+
#
|
|
39
|
+
# @param page_url [String, Html2rss::Url] page or origin URL
|
|
40
|
+
# @param request_session [Html2rss::RequestSession] shared session for follow-ups
|
|
41
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML when available
|
|
42
|
+
# @param html [String, nil] raw HTML when a document is unavailable
|
|
43
|
+
# @param extra_paths [Array<String>] additional path guesses
|
|
44
|
+
# @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
|
|
45
|
+
# @return [Html2rss::Url, nil]
|
|
46
|
+
# rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
|
|
47
|
+
def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
48
|
+
max_probes: nil)
|
|
49
|
+
best_feed_response(page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:)&.url
|
|
68
50
|
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
##
|
|
72
|
-
# Ordered candidate feed URLs (head alternates first, then path guesses).
|
|
73
|
-
#
|
|
74
|
-
# @param page_url [String, Html2rss::Url]
|
|
75
|
-
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
76
|
-
# @param html [String, nil]
|
|
77
|
-
# @param extra_paths [Array<String>]
|
|
78
|
-
# @return [Array<Html2rss::Url>]
|
|
79
|
-
def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
|
|
80
|
-
page = Html2rss::Url.from_absolute(page_url)
|
|
81
|
-
(alternate_feed_urls(page, parsed_body:, html:) +
|
|
82
|
-
path_guess_urls(page, extra_paths:)).uniq
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
##
|
|
86
|
-
# @param response [Html2rss::RequestService::Response, nil]
|
|
87
|
-
# @return [Boolean]
|
|
88
|
-
def feedish?(response)
|
|
89
|
-
return false unless response
|
|
90
|
-
return false unless successful_status?(response)
|
|
91
|
-
|
|
92
|
-
response.feed_response?
|
|
93
|
-
end
|
|
94
51
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
dir = path.sub(%r{/[^/]*$}, '/')
|
|
112
|
-
["#{dir}feed", "#{dir}rss.xml", "#{dir}atom.xml"]
|
|
113
|
-
end
|
|
52
|
+
##
|
|
53
|
+
# The native feed of a page already fetched: the one entry point for the curation gates
|
|
54
|
+
# (Recon, Capture, Test), so each reads the page's head alternates (cross-origin only
|
|
55
|
+
# through its {RequestService::FeedAdvertisement}) before same-origin path guesses. The page
|
|
56
|
+
# is the response's own URL, where a redirect landed, so relative alternates and the origin of
|
|
57
|
+
# the follow-ups are the fetched page's, never the entry URL's.
|
|
58
|
+
#
|
|
59
|
+
# @param response [Html2rss::RequestService::Response] the fetched page; a non-HTML
|
|
60
|
+
# response contributes its raw body only
|
|
61
|
+
# @param request_session [Html2rss::RequestSession] session for the probes
|
|
62
|
+
# @return [Html2rss::Url, nil]
|
|
63
|
+
def native_feed_url(response:, request_session:)
|
|
64
|
+
parsed_body = response.parsed_body if response.html_response?
|
|
65
|
+
best_feed_url(page_url: response.url, request_session:, parsed_body:, html: response.body)
|
|
66
|
+
end
|
|
114
67
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
68
|
+
##
|
|
69
|
+
# Like {#best_feed_url} but returns the validated syndication response for parsing.
|
|
70
|
+
#
|
|
71
|
+
# @param page_url [String, Html2rss::Url]
|
|
72
|
+
# @param request_session [Html2rss::RequestSession]
|
|
73
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
74
|
+
# @param html [String, nil]
|
|
75
|
+
# @param extra_paths [Array<String>]
|
|
76
|
+
# @param max_probes [Integer, nil] max candidate GETs (nil = uncapped)
|
|
77
|
+
# @return [Html2rss::RequestService::Response, nil]
|
|
78
|
+
# rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength -- discovery kwargs stay co-located
|
|
79
|
+
def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
80
|
+
max_probes: nil)
|
|
81
|
+
page = Html2rss::Url.from_absolute(page_url)
|
|
82
|
+
doc = document(parsed_body)
|
|
83
|
+
advertisement = RequestService::FeedAdvertisement.from_document(doc, page_url: page)
|
|
84
|
+
candidates = candidates(page, advertisement, doc:, html:, extra_paths:)
|
|
85
|
+
Log.debug("Syndication::Discovery: host=#{page.host} candidate_count=#{candidates.size}")
|
|
86
|
+
|
|
87
|
+
first_feedish_response(
|
|
88
|
+
candidates, request_session:, origin_url: page, advertisement:, max_probes:
|
|
89
|
+
).tap do |response|
|
|
90
|
+
next unless response
|
|
91
|
+
|
|
92
|
+
Log.info({
|
|
93
|
+
event_name: 'syndication.discovery.selected',
|
|
94
|
+
host: page.host,
|
|
95
|
+
message: "Syndication::Discovery: host=#{page.host} selected_feed_url=#{response.url}"
|
|
96
|
+
}.to_json)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
124
99
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
module_function :probe
|
|
141
|
-
private_class_method :probe
|
|
100
|
+
##
|
|
101
|
+
# Ordered candidate feed URLs (head alternates, then path guesses; on a page without podcast
|
|
102
|
+
# signals the audio-hinted alternates come after the guesses).
|
|
103
|
+
#
|
|
104
|
+
# @param page_url [String, Html2rss::Url]
|
|
105
|
+
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
106
|
+
# @param html [String, nil]
|
|
107
|
+
# @param extra_paths [Array<String>]
|
|
108
|
+
# @return [Array<Html2rss::Url>]
|
|
109
|
+
def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
|
|
110
|
+
page = Html2rss::Url.from_absolute(page_url)
|
|
111
|
+
doc = document(parsed_body)
|
|
112
|
+
advertisement = RequestService::FeedAdvertisement.from_document(doc, page_url: page)
|
|
113
|
+
candidates(page, advertisement, doc:, html:, extra_paths:)
|
|
114
|
+
end
|
|
142
115
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return
|
|
116
|
+
##
|
|
117
|
+
# @param response [Html2rss::RequestService::Response, nil]
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def feedish?(response)
|
|
120
|
+
return false unless response
|
|
121
|
+
return false unless successful_status?(response)
|
|
146
122
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
module_function :alternate_feed_urls
|
|
150
|
-
private_class_method :alternate_feed_urls
|
|
151
|
-
|
|
152
|
-
def feed_links_from_document(parsed_body, page)
|
|
153
|
-
return [] unless parsed_body.is_a?(Nokogiri::HTML::Document)
|
|
123
|
+
response.feed_response?
|
|
124
|
+
end
|
|
154
125
|
|
|
155
|
-
|
|
156
|
-
|
|
126
|
+
##
|
|
127
|
+
# Directory-relative feed path guesses for a page URL.
|
|
128
|
+
#
|
|
129
|
+
# @param page_url [String, Html2rss::Url]
|
|
130
|
+
# @return [Array<String>] relative paths
|
|
131
|
+
def page_dir_paths(page_url)
|
|
132
|
+
path = Html2rss::Url.from_absolute(page_url).path
|
|
133
|
+
return [] if path == '/'
|
|
134
|
+
|
|
135
|
+
dir = path.sub(%r{/[^/]*$}, '/')
|
|
136
|
+
["#{dir}feed", "#{dir}rss.xml", "#{dir}atom.xml"]
|
|
157
137
|
end
|
|
158
|
-
end
|
|
159
|
-
module_function :feed_links_from_document
|
|
160
|
-
private_class_method :feed_links_from_document
|
|
161
138
|
|
|
162
|
-
|
|
163
|
-
paths = (page_dir_paths(page) + DEFAULT_PATHS + Array(extra_paths)).uniq
|
|
164
|
-
paths.filter_map { |path| absolute_url(page, path) }
|
|
165
|
-
end
|
|
166
|
-
module_function :path_guess_urls
|
|
167
|
-
private_class_method :path_guess_urls
|
|
139
|
+
private
|
|
168
140
|
|
|
169
|
-
|
|
170
|
-
return [] if html.nil? || html.empty?
|
|
141
|
+
def successful_status?(response) = response.status.nil? || response.status.between?(200, 299)
|
|
171
142
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
143
|
+
def first_feedish_response(urls, request_session:, origin_url:, advertisement:, max_probes: nil)
|
|
144
|
+
scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
|
|
145
|
+
scoped.filter_map do |url|
|
|
146
|
+
response = probe(url, request_session:, origin_url:, advertisement:)
|
|
147
|
+
response if feedish?(response)
|
|
148
|
+
end.first
|
|
149
|
+
end
|
|
176
150
|
|
|
177
|
-
|
|
178
|
-
|
|
151
|
+
def probe(url, request_session:, origin_url:, advertisement:)
|
|
152
|
+
if advertisement.advertises?(url)
|
|
153
|
+
request_session.follow_up(url:, relation: :advertised_feed, origin_url:, advertisement:)
|
|
154
|
+
else
|
|
155
|
+
request_session.follow_up(url:, relation: :auto_source, origin_url:)
|
|
156
|
+
end
|
|
157
|
+
rescue Html2rss::ContractError
|
|
158
|
+
raise
|
|
159
|
+
rescue Html2rss::Error => error
|
|
160
|
+
Log.debug("Syndication::Discovery: probe skipped host=#{origin_url.host} (#{error.class})")
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
179
163
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
164
|
+
def candidates(page, advertisement, doc:, html:, extra_paths:)
|
|
165
|
+
leading, trailing = alternate_feed_urls(page, advertisement, doc:, html:)
|
|
166
|
+
(leading + path_guess_urls(page, extra_paths:) + trailing).uniq
|
|
167
|
+
end
|
|
183
168
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
169
|
+
# An HTML page without a parsed document still gets an (empty) advertisement.
|
|
170
|
+
def document(parsed_body)
|
|
171
|
+
parsed_body.is_a?(Nokogiri::HTML::Document) ? parsed_body : Nokogiri::HTML::Document.new
|
|
172
|
+
end
|
|
188
173
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
private_class_method :alternate_rel?
|
|
174
|
+
# @return [Array(Array, Array)] alternates ranked before the path guesses, and those after them
|
|
175
|
+
def alternate_feed_urls(page, advertisement, doc:, html:)
|
|
176
|
+
feeds = advertisement.feeds
|
|
177
|
+
return [AlternateLinks.hrefs(html.to_s).filter_map { |href| absolute_url(page, href) }, []] if feeds.empty?
|
|
194
178
|
|
|
195
|
-
|
|
196
|
-
|
|
179
|
+
audio, other = feeds.keys.partition { |url| AUDIO_HINT.match?("#{feeds.fetch(url)} #{url}") }
|
|
180
|
+
podcast_page?(doc, page) ? [audio + other, []] : [other, audio]
|
|
181
|
+
end
|
|
197
182
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
183
|
+
# The page itself looks like a podcast page: cheap signals only (no request, no JSON parse).
|
|
184
|
+
def podcast_page?(doc, page)
|
|
185
|
+
PODCAST_PAGE_HINT.match?("#{page.host}#{page.path}") || PODCAST_PAGE_HINT.match?(doc.title) ||
|
|
186
|
+
MEDIA_OG_TYPE.match?(doc.at_css('meta[property="og:type"]')&.[]('content')) ||
|
|
187
|
+
Html::Probe.scripts(doc, Html::Probe::APPLICATION_LD_JSON).any? { PODCAST_SCHEMA_TYPE.match?(_1.text) }
|
|
188
|
+
end
|
|
203
189
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
private_class_method :feed_like_href?
|
|
190
|
+
def path_guess_urls(page, extra_paths:)
|
|
191
|
+
paths = (page_dir_paths(page) + DEFAULT_PATHS + Array(extra_paths)).uniq
|
|
192
|
+
paths.filter_map { |path| absolute_url(page, path) }
|
|
193
|
+
end
|
|
209
194
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
195
|
+
def absolute_url(base, path)
|
|
196
|
+
Html2rss::Url.from_relative(path, base)
|
|
197
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
214
200
|
end
|
|
215
|
-
module_function :absolute_url
|
|
216
|
-
private_class_method :absolute_url
|
|
217
201
|
end
|
|
218
202
|
end
|
|
219
203
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Syndication
|
|
5
|
+
Feed = Data.define(:articles, :channel)
|
|
6
|
+
|
|
7
|
+
##
|
|
8
|
+
# One parsed native feed ({Parser.parse}): its items as AutoSource article hashes and what it
|
|
9
|
+
# states about its channel ({ChannelFacts}).
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] articles
|
|
12
|
+
# @return [Array<Hash{Symbol => Object}>] article hashes (may be empty)
|
|
13
|
+
# @!attribute [r] channel
|
|
14
|
+
# @return [ChannelFacts, nil] nil when the document is no RSS 2.0 or Atom feed or failed to parse
|
|
15
|
+
class Feed
|
|
16
|
+
# No feed read: no articles, no channel facts.
|
|
17
|
+
NONE = new(articles: [].freeze, channel: nil)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Syndication
|
|
5
|
+
##
|
|
6
|
+
# Reads the media files and image a feed item declares, for {Parser}: the +rss+ library parses the
|
|
7
|
+
# item text but drops Media RSS, so this module reads the same document's item elements.
|
|
8
|
+
#
|
|
9
|
+
# Enclosures, in document order and once per URL: RSS 2.0 +enclosure+ (+url+, +type+, +length+),
|
|
10
|
+
# Atom +link rel="enclosure"+ (+href+, +type+, +length+) and Media RSS +media:content+, also inside
|
|
11
|
+
# +media:group+ (+url+, +type+, +fileSize+, +duration+). A Media RSS +medium+ (+audio+, +video+, +image+)
|
|
12
|
+
# is the file's kind, typing an untyped file ({Article::Enclosure::KIND_TYPES}); it never makes a watch or
|
|
13
|
+
# player page ({Article::Enclosure#player_page?}, judged without it) a file, except that +image+ keeps one.
|
|
14
|
+
# An item +itunes:duration+ is the duration of every file that declares none ({Article::Enclosure} keeps it
|
|
15
|
+
# only on audio and video). The item image is its +itunes:image+ (+href+, or the URL as its text), else its
|
|
16
|
+
# first +media:thumbnail+ (also inside +media:group+); without either, {Article} makes its first image file
|
|
17
|
+
# the item image. Every declared URL resolves against the feed URL through {Url.resolve_web}, so a
|
|
18
|
+
# +javascript:+, +data:+ or +ftp:+ file or image is dropped, never shipped nor made an item URL ({Parser}).
|
|
19
|
+
# Sizes, durations and kinds come from {Article::Enclosure}. Items match by namespace
|
|
20
|
+
# ({ITEMS}); a document without any media marker ({MEDIA_MARKERS}) is not parsed a second time.
|
|
21
|
+
module ItemMedia
|
|
22
|
+
# Namespace prefixes the item queries bind.
|
|
23
|
+
NAMESPACES = {
|
|
24
|
+
'rss1' => 'http://purl.org/rss/1.0/',
|
|
25
|
+
'rdf' => 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
26
|
+
'atom' => 'http://www.w3.org/2005/Atom',
|
|
27
|
+
'media' => 'http://search.yahoo.com/mrss/',
|
|
28
|
+
'itunes' => 'http://www.itunes.com/dtds/podcast-1.0.dtd'
|
|
29
|
+
}.freeze
|
|
30
|
+
# Item elements in document order, by format: RSS 2.0 +item+ (no namespace) under +rss/channel+,
|
|
31
|
+
# RSS 1.0 +item+ under +rdf:RDF+, Atom +entry+ under +feed+. Extension elements named +item+ never count.
|
|
32
|
+
ITEMS = '/rss/channel/item | /rdf:RDF/rss1:item | /atom:feed/atom:entry'
|
|
33
|
+
# Text only a document declaring item media contains: an RSS 2.0 enclosure, an Atom enclosure link,
|
|
34
|
+
# or the Media RSS or iTunes namespace. Any other document is not parsed a second time.
|
|
35
|
+
MEDIA_MARKERS = Regexp.union('<enclosure', /rel=["']enclosure["']/, NAMESPACES['media'], NAMESPACES['itunes'])
|
|
36
|
+
# Enclosure elements of one item, in document order.
|
|
37
|
+
ENCLOSURES = "enclosure | atom:link[@rel='enclosure'] | media:content | media:group/media:content"
|
|
38
|
+
# Thumbnail URLs of one item, in document order (blank ones skipped, like a blank iTunes image).
|
|
39
|
+
THUMBNAILS = 'media:thumbnail/@url[normalize-space()] | media:group/media:thumbnail/@url[normalize-space()]'
|
|
40
|
+
# The item's iTunes image URL: its +href+, else the URL written as the element text.
|
|
41
|
+
ITUNES_IMAGE = 'itunes:image/@href[normalize-space()] | ' \
|
|
42
|
+
'itunes:image[not(normalize-space(@href))][normalize-space()]'
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
##
|
|
46
|
+
# @param body [String] the feed document
|
|
47
|
+
# @param count [Integer] items the +rss+ library read from +body+
|
|
48
|
+
# @param url [String, Html2rss::Url, nil] the feed URL (nil when unknown): the base of relative file and
|
|
49
|
+
# image URLs, and named in the log when items cannot be matched
|
|
50
|
+
# @return [Array<Hash{Symbol => Object}>] per item, in document order, its +enclosures+ and +image+
|
|
51
|
+
# when present; all empty when the document declares no media or its item elements do not
|
|
52
|
+
# number +count+ (logged)
|
|
53
|
+
def for_items(body, count:, url:)
|
|
54
|
+
return Array.new(count) { {} } unless MEDIA_MARKERS.match?(body)
|
|
55
|
+
|
|
56
|
+
nodes = Nokogiri::XML(body).xpath(ITEMS, NAMESPACES)
|
|
57
|
+
return nodes.map { |node| item_media(node, url) } if nodes.size == count
|
|
58
|
+
|
|
59
|
+
warn_mismatch(nodes.size, count, url)
|
|
60
|
+
Array.new(count) { {} }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def item_media(node, feed_url)
|
|
66
|
+
duration = Article::Enclosure.parse_duration(node.at_xpath('itunes:duration', NAMESPACES)&.text)
|
|
67
|
+
files = node.xpath(ENCLOSURES, NAMESPACES).filter_map { file(_1, duration, feed_url) }
|
|
68
|
+
declared = node.at_xpath(ITUNES_IMAGE, NAMESPACES) || node.at_xpath(THUMBNAILS, NAMESPACES)
|
|
69
|
+
{ enclosures: files.uniq { _1[:url].to_s }, image: declared && Url.resolve_web(declared.content, feed_url) }
|
|
70
|
+
.reject { |_, value| value.nil? || value == [] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The file's facts: its declared type, its +medium+ as kind, its byte length and its own duration, else
|
|
74
|
+
# the item's.
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash{Symbol => Object}, nil] nil without an http(s) URL or for a watch or player page not marked
|
|
77
|
+
# an image
|
|
78
|
+
def file(element, item_duration, feed_url)
|
|
79
|
+
return unless (url = Url.resolve_web(attribute(element, 'url', 'href'), feed_url))
|
|
80
|
+
|
|
81
|
+
type = attribute(element, 'type')
|
|
82
|
+
bytes_length = Article::Enclosure.parse_size(attribute(element, 'length', 'fileSize'))
|
|
83
|
+
kind = Article::Enclosure::KIND_TYPES.each_key.find { _1.name == element['medium'] }
|
|
84
|
+
return if kind != :image && Article::Enclosure.new(url:, type:, bytes_length:).player_page?
|
|
85
|
+
|
|
86
|
+
duration = Article::Enclosure.parse_duration(attribute(element, 'duration')) || item_duration
|
|
87
|
+
{ url:, type:, kind:, bytes_length:, duration: }.compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def attribute(element, *names)
|
|
91
|
+
names.filter_map { element[_1]&.strip }.find { !_1.empty? }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def warn_mismatch(elements, count, url)
|
|
95
|
+
Log.warn({
|
|
96
|
+
event_name: 'syndication.item_media_mismatch', feed_url: url,
|
|
97
|
+
message: "Syndication::ItemMedia: #{elements} item elements for #{count} parsed items; no item media read"
|
|
98
|
+
}.to_json)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|