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
data/lib/html2rss/sst/tags.rb
CHANGED
|
@@ -11,16 +11,127 @@ module Html2rss
|
|
|
11
11
|
BLOCK_NAMES = Set[:p, :div, :li, :ul, :ol, :h1, :h2, :h3, :h4, :h5, :h6, :tr, :br].freeze
|
|
12
12
|
# Names skipped when collecting visible text.
|
|
13
13
|
INVISIBLE_NAMES = Set[:svg, :script, :noscript, :style, :template].freeze
|
|
14
|
-
#
|
|
14
|
+
# Utility landmark names: peripheral unless chrome (see {.chrome_scope}).
|
|
15
15
|
UTILITY_LANDMARK_NAMES = Set[:nav, :aside, :footer, :menu].freeze
|
|
16
16
|
# Container names ignored as article roots.
|
|
17
|
-
IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :svg, :script, :style].freeze
|
|
17
|
+
IGNORED_CONTAINER_NAMES = Set[:nav, :footer, :header, :dialog, :svg, :script, :style].freeze
|
|
18
|
+
# Ignored containers that are chrome only at page level (ARIA banner/contentinfo scoping).
|
|
19
|
+
PAGE_CHROME_NAMES = Set[:header, :footer].freeze
|
|
20
|
+
# Ancestors that scope a header/footer to their own section instead of the page.
|
|
21
|
+
SECTIONING_NAMES = Set[:article, :section, :aside, :main].freeze
|
|
18
22
|
# Names excluded from class/structure clustering.
|
|
19
23
|
CLUSTER_EXCLUDED_NAMES = Set[:html, :body, :nav, :footer, :header, :svg, :script, :style].freeze
|
|
20
24
|
# Href prefixes that never count as content destinations.
|
|
21
25
|
SKIP_HREF_PREFIXES = ['#', 'javascript:', 'mailto:', 'tel:', 'file://', 'sms:', 'data:'].freeze
|
|
22
26
|
# Layout-ish names used when resolving nested wrapper groups.
|
|
23
27
|
LAYOUT_NAMES = Set[:div, :section, :article, :li, :ul, :ol].freeze
|
|
28
|
+
|
|
29
|
+
# header/footer/nav: chrome at page level, their parent's scope inside sectioning content.
|
|
30
|
+
PAGE_CHROME = PAGE_CHROME_NAMES.to_set(&:to_s).freeze
|
|
31
|
+
# +noscript+ too: the SST keeps one only for its file links (enclosures), never as content anchors.
|
|
32
|
+
ALWAYS_CHROME = ((IGNORED_CONTAINER_NAMES.to_set(&:to_s) - PAGE_CHROME - Set['nav']) | Set['noscript']).freeze
|
|
33
|
+
UTILITY_LANDMARK = UTILITY_LANDMARK_NAMES.to_set(&:to_s).freeze
|
|
34
|
+
SECTIONING = SECTIONING_NAMES.to_set(&:to_s).freeze
|
|
35
|
+
# ARIA roles whose subtree is chrome anywhere (+search+ is not: it may wrap the result list).
|
|
36
|
+
CHROME_ROLES = Set['navigation', 'menu', 'menubar', 'dialog', 'alertdialog'].freeze
|
|
37
|
+
# ARIA landmark roles that, like header/footer, are chrome only at page level.
|
|
38
|
+
PAGE_CHROME_ROLES = Set['banner', 'contentinfo'].freeze
|
|
39
|
+
# Utility landmark roles: the chrome roles plus +complementary+ (the aside role).
|
|
40
|
+
UTILITY_ROLES = (CHROME_ROLES | Set['complementary']).freeze
|
|
41
|
+
# A nav or list labelled (aria-label) or classed as pagination is chrome in any scope.
|
|
42
|
+
PAGER_NAMES = Set['nav', 'ul', 'ol'].freeze
|
|
43
|
+
# Pagination nouns ("Results pagination", "paginación"), never +paginated+.
|
|
44
|
+
PAGER_LABEL = /\bpagina(?:tion|tie|ción|cion|zione|cja|ção|cao)(?!\p{L})|\bpaginering\b|\bpaginierung\b|
|
|
45
|
+
\bpager\b|\bpaging\b|\bpage\snavigation\b/ix
|
|
46
|
+
# nav: a class-token part (+pagination+, +gsc-pagination+, +pager__list+), not +paginated-list+.
|
|
47
|
+
PAGER_CLASS = /(?:\A|\s|[-_])(?:pagination|pager|paging)(?:\z|\s|[-_])/i
|
|
48
|
+
# ul/ol: only a whole class token (+infinite-pager+ or +js-pagination-container+ is a feed list).
|
|
49
|
+
PAGER_TOKENS = Set['pagination', 'pager', 'paging'].freeze
|
|
50
|
+
PAGER_RELS = Set['next', 'prev', 'previous'].freeze
|
|
51
|
+
# A nav linking rel=next/prev is a pager.
|
|
52
|
+
PAGER_LINK_XPATH = PAGER_RELS.map { "contains(concat(' ', normalize-space(@rel), ' '), ' #{_1} ')" }
|
|
53
|
+
.join(' or ').then { ".//a[#{_1}]" }.freeze
|
|
54
|
+
# Page-navigation link shapes: a page number, an arrow, prev/next wording, or a page query/path.
|
|
55
|
+
PAGE_LINK_TEXT = /\A(?:\d{1,4}|[«‹›»←→<>]+|\W*(?:prev(?:ious)?|next|older|newer|first|last)\b.*)\z/im
|
|
56
|
+
PAGE_LINK_HREF = %r{[?&](?:page|paged|pg|p)=\d+|/page/\d+}i
|
|
57
|
+
private_constant :PAGE_CHROME, :ALWAYS_CHROME, :UTILITY_LANDMARK, :SECTIONING, :CHROME_ROLES,
|
|
58
|
+
:PAGE_CHROME_ROLES, :UTILITY_ROLES, :PAGER_NAMES, :PAGER_LABEL, :PAGER_CLASS,
|
|
59
|
+
:PAGER_TOKENS, :PAGER_RELS, :PAGER_LINK_XPATH, :PAGE_LINK_TEXT, :PAGE_LINK_HREF
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# The single chrome-scope rule, folded down the tree by both the SST normalizer
|
|
63
|
+
# and the Nokogiri ancestor walk. +:chrome+ sticks: dialog, noscript and the ARIA navigation,
|
|
64
|
+
# menu and dialog roles are chrome; header/footer/nav and the banner/contentinfo roles
|
|
65
|
+
# are chrome only at +:page+ level; a pager (a nav labelled or classed as pagination or
|
|
66
|
+
# linking rel=next/prev, or a list labelled or whole-token classed as pagination whose
|
|
67
|
+
# links mostly look like page navigation) is chrome in any scope. Otherwise a
|
|
68
|
+
# utility landmark ({.utility_landmark?}) opens a sticky +:peripheral+ scope, and
|
|
69
|
+
# article/section/main (or role=main) open +:sectioned+, so card headers and in-content
|
|
70
|
+
# nav lists stay out of chrome.
|
|
71
|
+
#
|
|
72
|
+
# @param element [Nokogiri::XML::Element] element being entered
|
|
73
|
+
# @param scope [Symbol] parent scope: +:page+, +:sectioned+, +:peripheral+, or +:chrome+
|
|
74
|
+
# @return [Symbol] scope the element's subtree inherits
|
|
75
|
+
def self.chrome_scope(element, scope)
|
|
76
|
+
tag = Html::Probe.tag(element)
|
|
77
|
+
roles = role_tokens(element['role'])
|
|
78
|
+
return :chrome if scope == :chrome || chrome?(element, tag, roles, scope)
|
|
79
|
+
return :peripheral if scope == :peripheral || utility?(tag, roles)
|
|
80
|
+
|
|
81
|
+
SECTIONING.include?(tag) || roles.include?('main') ? :sectioned : scope
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
##
|
|
85
|
+
# Utility landmark: nav/aside/footer/menu, or a navigation, menu, dialog or
|
|
86
|
+
# complementary role. Peripheral content when not chrome; never an article card.
|
|
87
|
+
#
|
|
88
|
+
# @param tag [String] folded tag name
|
|
89
|
+
# @param role [String, nil] raw +role+ attribute
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def self.utility_landmark?(tag, role) = utility?(tag, role_tokens(role))
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Chrome only at page level: header/footer, or the banner/contentinfo role.
|
|
95
|
+
#
|
|
96
|
+
# @param tag [String] folded tag name
|
|
97
|
+
# @param role [String, nil] raw +role+ attribute
|
|
98
|
+
# @return [Boolean]
|
|
99
|
+
def self.page_level_chrome?(tag, role) = page_level?(tag, role_tokens(role))
|
|
100
|
+
|
|
101
|
+
def self.role_tokens(role) = role ? role.downcase.split : []
|
|
102
|
+
|
|
103
|
+
def self.utility?(tag, roles) = UTILITY_LANDMARK.include?(tag) || roles.any? { UTILITY_ROLES.include?(_1) }
|
|
104
|
+
|
|
105
|
+
def self.page_level?(tag, roles) = PAGE_CHROME.include?(tag) || roles.any? { PAGE_CHROME_ROLES.include?(_1) }
|
|
106
|
+
|
|
107
|
+
def self.chrome?(element, tag, roles, scope)
|
|
108
|
+
return true if ALWAYS_CHROME.include?(tag) || roles.any? { CHROME_ROLES.include?(_1) }
|
|
109
|
+
|
|
110
|
+
(scope == :page && (tag == 'nav' || page_level?(tag, roles))) || pager?(element, tag)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.pager?(element, tag)
|
|
114
|
+
return false unless PAGER_NAMES.include?(tag)
|
|
115
|
+
return list_pager?(element) unless tag == 'nav'
|
|
116
|
+
return true if element['aria-label'].to_s.match?(PAGER_LABEL) || element['class'].to_s.match?(PAGER_CLASS)
|
|
117
|
+
|
|
118
|
+
!element.at_xpath(PAGER_LINK_XPATH).nil?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A list is a pager only when labelled or whole-token classed as one and at least
|
|
122
|
+
# half of its links look like page navigation.
|
|
123
|
+
def self.list_pager?(element)
|
|
124
|
+
named = element['aria-label'].to_s.match?(PAGER_LABEL) ||
|
|
125
|
+
element['class'].to_s.downcase.split.any? { PAGER_TOKENS.include?(_1) }
|
|
126
|
+
links = named ? element.css('a') : []
|
|
127
|
+
!links.empty? && links.count { page_link?(_1) } * 2 >= links.size
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.page_link?(link)
|
|
131
|
+
link['rel'].to_s.downcase.split.any? { PAGER_RELS.include?(_1) } ||
|
|
132
|
+
link['href'].to_s.match?(PAGE_LINK_HREF) || link.text.strip.match?(PAGE_LINK_TEXT)
|
|
133
|
+
end
|
|
134
|
+
private_class_method :role_tokens, :utility?, :page_level?, :chrome?, :pager?, :list_pager?, :page_link?
|
|
24
135
|
end
|
|
25
136
|
end
|
|
26
137
|
end
|
data/lib/html2rss/sst/text.rb
CHANGED
|
@@ -4,7 +4,18 @@ module Html2rss
|
|
|
4
4
|
module SST
|
|
5
5
|
##
|
|
6
6
|
# Visible-text extraction for SST nodes (port of Navigator::TextExtractor).
|
|
7
|
+
#
|
|
8
|
+
# Block children separate by a newline (or the given separator); other neighbours by one space,
|
|
9
|
+
# except where an inline element embedded in direct text touches it with no whitespace between them
|
|
10
|
+
# in the source: +Straßenbah<span>n</span> für+ stays one word. Two adjacent elements, and an element
|
|
11
|
+
# opening or closing its parent's text (+<span>Live</span>Headline+), always separate, since sites
|
|
12
|
+
# lay out such spans as blocks with CSS.
|
|
7
13
|
module Text
|
|
14
|
+
# One piece of a node's text: direct text or one child's text, whether it is a block, and whether it
|
|
15
|
+
# glues to the piece before it (no whitespace between them in the source).
|
|
16
|
+
Fragment = Data.define(:text, :block, :glue)
|
|
17
|
+
private_constant :Fragment
|
|
18
|
+
|
|
8
19
|
class << self
|
|
9
20
|
##
|
|
10
21
|
# @param node [Node]
|
|
@@ -12,7 +23,7 @@ module Html2rss
|
|
|
12
23
|
# @param exclude [Array<Node>, nil]
|
|
13
24
|
# @return [String, nil]
|
|
14
25
|
def extract(node, separator: ' ', exclude: nil)
|
|
15
|
-
raise
|
|
26
|
+
raise InvalidError, 'node is required' unless node
|
|
16
27
|
|
|
17
28
|
excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
|
|
18
29
|
join_fragments(fragments(node, separator, excluded), separator)
|
|
@@ -20,51 +31,80 @@ module Html2rss
|
|
|
20
31
|
|
|
21
32
|
private
|
|
22
33
|
|
|
23
|
-
# Ordered
|
|
34
|
+
# Ordered fragments: lead text, then each shown child followed by the text after it.
|
|
24
35
|
def fragments(node, separator, excluded)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
raws = [node.lead_text, *node.tails]
|
|
37
|
+
pieces = [direct_fragment(raws.first, false)]
|
|
38
|
+
node.children.each_with_index do |child, index|
|
|
39
|
+
next pieces << direct_fragment(raws[index + 1], false) unless shown?(child, excluded)
|
|
40
|
+
|
|
41
|
+
pieces.push(*shown_fragments(child, raws, index, separator, excluded))
|
|
29
42
|
end
|
|
30
|
-
|
|
43
|
+
pieces
|
|
31
44
|
end
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
# A shown child and the direct text after it. Only a child embedded in direct text (text on both
|
|
47
|
+
# sides) glues to it, where they meet with no whitespace between them: a leading or trailing span
|
|
48
|
+
# beside text (+<span>Live</span>Headline+) is a styled label more often than part of a word.
|
|
49
|
+
def shown_fragments(child, raws, index, separator, excluded)
|
|
50
|
+
before = raws[index].to_s
|
|
51
|
+
after = raws[index + 1].to_s
|
|
52
|
+
embedded = !before.empty? && !after.empty?
|
|
53
|
+
[child_fragment(child, separator, excluded, embedded && before.match?(/\S\z/) && opens_word?(child)),
|
|
54
|
+
direct_fragment(after, embedded && closes_word?(child) && after.match?(/\A\S/))]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether the raw text of +node+ starts with a non-space, descending into its first child while it
|
|
58
|
+
# has no lead text.
|
|
59
|
+
def opens_word?(node)
|
|
60
|
+
first = node.children.first
|
|
61
|
+
node.lead_text.empty? && first ? opens_word?(first) : node.lead_text.match?(/\A\S/)
|
|
62
|
+
end
|
|
38
63
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
# Whether the raw text of +node+ ends with a non-space, descending into its last child while no
|
|
65
|
+
# text follows it.
|
|
66
|
+
def closes_word?(node)
|
|
67
|
+
raw = node.tails.last || node.own_text
|
|
68
|
+
last = node.children.last
|
|
69
|
+
raw.empty? && last ? closes_word?(last) : raw.match?(/\S\z/)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def join_fragments(pieces, separator)
|
|
73
|
+
last_block = nil
|
|
74
|
+
parts = pieces.each_with_object([]) do |piece, out|
|
|
75
|
+
next if piece.text.empty?
|
|
76
|
+
|
|
77
|
+
out << gap(separator, piece, last_block) unless last_block.nil?
|
|
78
|
+
out << piece.text
|
|
79
|
+
last_block = piece.block
|
|
42
80
|
end
|
|
43
81
|
parts.join unless parts.empty?
|
|
44
82
|
end
|
|
45
83
|
|
|
84
|
+
def gap(separator, piece, last_block)
|
|
85
|
+
return (separator == ' ' ? "\n" : separator) if piece.block || last_block
|
|
86
|
+
|
|
87
|
+
piece.glue ? '' : ' '
|
|
88
|
+
end
|
|
89
|
+
|
|
46
90
|
def shown?(child, excluded) = !excluded&.[](child) && visible_child?(child)
|
|
47
91
|
|
|
48
92
|
def squish(text) = text.to_s.gsub(/\s+/, ' ').strip
|
|
49
93
|
|
|
50
|
-
def
|
|
94
|
+
def direct_fragment(raw, glue) = Fragment.new(squish(raw), false, glue)
|
|
95
|
+
|
|
96
|
+
def child_fragment(child, separator, excluded, glue)
|
|
97
|
+
Fragment.new(child_text(child, separator, excluded), Tags::BLOCK_NAMES.include?(child.name), glue)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A leaf's text is read directly (no fragments to join), the hot path of every text read.
|
|
101
|
+
def child_text(child, separator, excluded)
|
|
51
102
|
text = if child.children.empty?
|
|
52
103
|
squish(child.own_text)
|
|
53
104
|
else
|
|
54
|
-
extract(child, separator:, exclude: excluded&.keys).to_s
|
|
105
|
+
extract(child, separator:, exclude: excluded&.keys).to_s
|
|
55
106
|
end
|
|
56
|
-
|
|
57
|
-
[text, Tags::BLOCK_NAMES.include?(child.name)]
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def append_separator!(parts, separator, is_block, last_was_block)
|
|
61
|
-
return if parts.empty?
|
|
62
|
-
|
|
63
|
-
parts << if is_block || last_was_block
|
|
64
|
-
(separator == ' ' ? "\n" : separator)
|
|
65
|
-
else
|
|
66
|
-
' '
|
|
67
|
-
end
|
|
107
|
+
child.name == :li && !text.empty? ? "- #{text}" : text
|
|
68
108
|
end
|
|
69
109
|
|
|
70
110
|
def visible_child?(node)
|
data/lib/html2rss/sst.rb
CHANGED
data/lib/html2rss/status.rb
CHANGED
|
@@ -13,6 +13,9 @@ module Html2rss
|
|
|
13
13
|
# Stable telemetry payload for cross-repo consumers (e.g. html2rss-web observability).
|
|
14
14
|
# Tallies and counters are validated and frozen at construction (including Marshal load).
|
|
15
15
|
class Status
|
|
16
|
+
# Raised when telemetry fields fail validation at construction.
|
|
17
|
+
class InvalidError < Html2rss::ContractError; end
|
|
18
|
+
|
|
16
19
|
# Keys kept in {#to_h} even when zero or empty.
|
|
17
20
|
ALWAYS_REPORTED = %i[version dedup_dropped].freeze
|
|
18
21
|
private_constant :ALWAYS_REPORTED
|
|
@@ -157,14 +160,14 @@ module Html2rss
|
|
|
157
160
|
case value
|
|
158
161
|
when Hash, FeedResolution::Diag then value.to_h
|
|
159
162
|
else
|
|
160
|
-
raise
|
|
163
|
+
raise InvalidError, "entry_resolution must be a Hash or FeedResolution::Diag, got #{value.class}"
|
|
161
164
|
end.transform_keys(&:to_sym).freeze
|
|
162
165
|
end
|
|
163
166
|
|
|
164
167
|
def validate_selected_strategy!(selected_strategy, attempts)
|
|
165
168
|
return if selected_strategy.nil?
|
|
166
|
-
raise
|
|
167
|
-
raise
|
|
169
|
+
raise InvalidError, 'selected_strategy must be a Symbol or nil' unless selected_strategy.is_a?(Symbol)
|
|
170
|
+
raise InvalidError, 'attempt_count must be >= 1 when selected_strategy is set' if attempts < 1
|
|
168
171
|
end
|
|
169
172
|
end
|
|
170
173
|
end
|
|
@@ -20,7 +20,7 @@ module Html2rss
|
|
|
20
20
|
##
|
|
21
21
|
# @param name [Symbol, String]
|
|
22
22
|
# @return [SurfaceCategory]
|
|
23
|
-
def self.[](name) = ALL.fetch(name.to_sym) { raise
|
|
23
|
+
def self.[](name) = ALL.fetch(name.to_sym) { raise InvalidOption, "unknown surface category: #{name.inspect}" }
|
|
24
24
|
|
|
25
25
|
##
|
|
26
26
|
# @return [Boolean]
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# Syndication
|
|
2
2
|
|
|
3
|
-
Native RSS/Atom discovery and parse for auto-source promotion and direct feed URLs.
|
|
3
|
+
Native RSS/Atom/RDF discovery and parse for auto-source promotion and direct feed URLs.
|
|
4
4
|
|
|
5
5
|
## Ownership
|
|
6
6
|
|
|
7
7
|
| Concern | Owner |
|
|
8
8
|
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
9
|
-
| Head `rel=alternate` + common path probes + status-gated feedish | `Syndication::Discovery`
|
|
10
|
-
|
|
|
9
|
+
| Head `rel=alternate` (audio-hinted first on a podcast page, after the path guesses elsewhere) + common path probes + status-gated feedish | `Syndication::Discovery` |
|
|
10
|
+
| Native feed of a fetched page for Recon, Capture and Test | `Syndication::Discovery.native_feed_url` (one entry point: the page response they already hold, at the URL a redirect landed on) |
|
|
11
|
+
| Cross-origin permission for a page's advertised feed | `RequestService::Policy` (proof: `RequestService::FeedAdvertisement`) |
|
|
12
|
+
| RSS 2.0 / RSS 1.0 (RDF) / Atom → `Syndication::Feed` (article hashes + channel facts); item URL: an http(s) link, else an http(s) guid, else the first audio/video file | `Syndication::Parser` |
|
|
13
|
+
| Item media files and image (enclosure, Media RSS, iTunes) → item | `Syndication::ItemMedia` (called by `Parser`; http(s) only via `Url.resolve_web`; sizes, durations and player pages via `Article::Enclosure`) |
|
|
14
|
+
| Channel facts (author, `itunes:category`, `itunes:explicit`) → `Feed#channel` | `Syndication::ChannelFacts` (called by `Parser` on the same parse; precedence over page meta is `Channel`'s) |
|
|
11
15
|
| Strict head-only link parse (no path guessing) | `Html::FeedLink` |
|
|
12
16
|
| Response Content-Type / body feed sniff | `RequestService::Response#feed_response?` (sole sniff owner; Discovery only adds HTTP status gate) |
|
|
13
17
|
|
|
@@ -15,7 +19,9 @@ Native RSS/Atom discovery and parse for auto-source promotion and direct feed UR
|
|
|
15
19
|
|
|
16
20
|
- Entry-URL tournament / listing resolution (`FeedResolution`)
|
|
17
21
|
- Page recon / surface classification (`PageRecon`)
|
|
18
|
-
- Feed channel metadata for output (`Channel` / `FeedBuilder`)
|
|
19
|
-
- Ranking or quality scoring of competing feeds beyond first feedish hit
|
|
22
|
+
- Feed channel metadata for output and its precedence (`Channel` / `FeedBuilder`); Syndication only reads what a feed states
|
|
23
|
+
- Ranking or quality scoring of competing feeds beyond the audio-hint order and first feedish hit
|
|
20
24
|
|
|
21
|
-
Discovery stops at the first
|
|
25
|
+
Discovery stops at the first feedish URL (lazy probe). Head alternates are fetched as `:advertised_feed` with the page's `FeedAdvertisement`, so a feed on another host (a podcast platform) is followed. An advertised `http:` feed off the page origin is fetched as `https:` or skipped. Path guesses stay same-origin. Alternates keep document order, except on a podcast page: `podcast`, `episode` or `audio` (word-ish, plural allowed) in its URL host, path or `<title>`, an `og:type` of `music.*` or `video.*`, or a JSON-LD `@type` `PodcastSeries`, `PodcastEpisode` or `AudioObject` anywhere (regex on the script text, no JSON parse). There, an alternate whose title or URL hints audio (the same words plus `mp3`, `m4a`, `aac`, `opus`) goes first. On any other page those rank after the same-origin path guesses, and other alternates still precede them. A news front page that advertises only its podcast ships its page items or a same-origin feed, not the episodes; the podcast feed is its last candidate.
|
|
26
|
+
|
|
27
|
+
An item without a link takes its guid (Atom `id`, RDF `rdf:about`) when that is an absolute http(s) URL, whatever `isPermaLink` says, else its first audio or video file. Relative, `urn:` and `tag:` ids name no page. An item with none of these is skipped. Links, files and images resolve against the feed URL through `Url.resolve_web`, so a `javascript:`, `data:` or `ftp:` one counts as absent. A followed feed's items may link to the feed's own domain as well as the page's (AutoSource `Cleanup`, which owns that rule). Path guessing mirrors the legacy configs `probe_rss` script; that script should eventually thin-wrap this module.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rss'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Syndication
|
|
7
|
+
ChannelFacts = Data.define(:author, :categories, :explicit)
|
|
8
|
+
|
|
9
|
+
##
|
|
10
|
+
# What a native feed states about its show or publication, for {Html2rss::Channel}: the author
|
|
11
|
+
# name, the iTunes categories and the explicit flag. A fact the feed does not state stays unknown
|
|
12
|
+
# (+nil+, no categories); nothing is guessed.
|
|
13
|
+
#
|
|
14
|
+
# RSS 2.0: the author is +itunes:author+, else the name part of +managingEditor+
|
|
15
|
+
# (+editor@example.com (Name)+ gives +Name+, a plain name is kept, a bare email address is no name);
|
|
16
|
+
# each +itunes:category+ is its +text+ followed by its nested subcategory texts; +itunes:explicit+
|
|
17
|
+
# +true+, +yes+ or +explicit+ is true, +false+, +no+ or +clean+ false, any other value unstated.
|
|
18
|
+
# Atom: the author is the feed +author+ name. RSS 1.0 (RDF) states none of these.
|
|
19
|
+
#
|
|
20
|
+
# @!attribute [r] author
|
|
21
|
+
# @return [String, nil] the channel author name
|
|
22
|
+
# @!attribute [r] categories
|
|
23
|
+
# @return [Array<Array<String>>] each category followed by its subcategories
|
|
24
|
+
# @!attribute [r] explicit
|
|
25
|
+
# @return [Boolean, nil] whether the feed declares explicit content; nil when unstated
|
|
26
|
+
class ChannelFacts
|
|
27
|
+
# +itunes:explicit+ values, lowercased, to the flag they state.
|
|
28
|
+
EXPLICIT = {
|
|
29
|
+
'true' => true, 'yes' => true, 'explicit' => true,
|
|
30
|
+
'false' => false, 'no' => false, 'clean' => false
|
|
31
|
+
}.freeze
|
|
32
|
+
# A +managingEditor+ written as an email address followed by the name in parentheses.
|
|
33
|
+
NAMED_EMAIL = /\A\s*[^\s@]+@\S+\s+\((.+)\)\s*\z/
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
##
|
|
37
|
+
# @param feed [RSS::Rss, RSS::RDF, RSS::Atom::Feed, nil] a document the +rss+ library parsed
|
|
38
|
+
# @return [ChannelFacts, nil] the facts an RSS 2.0 or Atom feed states; nil for any other document
|
|
39
|
+
def from(feed)
|
|
40
|
+
case feed
|
|
41
|
+
when RSS::Rss then from_rss(feed.channel)
|
|
42
|
+
when RSS::Atom::Feed then new(author: feed.author&.name&.content)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def from_rss(channel)
|
|
49
|
+
new(author: rss_author(channel),
|
|
50
|
+
categories: channel.itunes_categories.map { [_1.text, *_1.itunes_categories.map(&:text)] },
|
|
51
|
+
explicit: EXPLICIT[channel.itunes_explicit.to_s.strip.downcase])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Blank and padded values are normalized by {#initialize}.
|
|
55
|
+
def rss_author(channel)
|
|
56
|
+
author = channel.itunes_author.to_s
|
|
57
|
+
return author if author.match?(/\S/)
|
|
58
|
+
|
|
59
|
+
editor = channel.managingEditor.to_s
|
|
60
|
+
editor[NAMED_EMAIL, 1] || (editor unless editor.include?('@'))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
##
|
|
65
|
+
# @param author [String, nil] blank becomes nil
|
|
66
|
+
# @param categories [Array<Array<String, nil>>] a category without a name is dropped, blank
|
|
67
|
+
# subcategories are skipped
|
|
68
|
+
# @param explicit [Boolean, nil]
|
|
69
|
+
def initialize(author: nil, categories: [], explicit: nil)
|
|
70
|
+
super(author: present(author), categories: categories.filter_map { path(_1) }.freeze, explicit:)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def path((name, *subcategories))
|
|
76
|
+
return unless (name = present(name))
|
|
77
|
+
|
|
78
|
+
[name, *subcategories.filter_map { present(_1) }].freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def present(value)
|
|
82
|
+
text = value.to_s.strip
|
|
83
|
+
text.freeze unless text.empty?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -3,12 +3,34 @@
|
|
|
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
|
|
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
|
|
12
34
|
|
|
13
35
|
class << self
|
|
14
36
|
##
|
|
@@ -24,9 +46,23 @@ module Html2rss
|
|
|
24
46
|
# rubocop:disable-next Metrics/ParameterLists -- discovery kwargs stay co-located
|
|
25
47
|
def best_feed_url(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
26
48
|
max_probes: nil)
|
|
27
|
-
best_feed_response(
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
best_feed_response(page_url:, request_session:, parsed_body:, html:, extra_paths:, max_probes:)&.url
|
|
50
|
+
end
|
|
51
|
+
|
|
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)
|
|
30
66
|
end
|
|
31
67
|
|
|
32
68
|
##
|
|
@@ -43,13 +79,13 @@ module Html2rss
|
|
|
43
79
|
def best_feed_response(page_url:, request_session:, parsed_body: nil, html: nil, extra_paths: [],
|
|
44
80
|
max_probes: nil)
|
|
45
81
|
page = Html2rss::Url.from_absolute(page_url)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
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}")
|
|
50
86
|
|
|
51
87
|
first_feedish_response(
|
|
52
|
-
candidates, request_session:, origin_url: page, max_probes:
|
|
88
|
+
candidates, request_session:, origin_url: page, advertisement:, max_probes:
|
|
53
89
|
).tap do |response|
|
|
54
90
|
next unless response
|
|
55
91
|
|
|
@@ -62,7 +98,8 @@ module Html2rss
|
|
|
62
98
|
end
|
|
63
99
|
|
|
64
100
|
##
|
|
65
|
-
# Ordered candidate feed URLs (head alternates
|
|
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).
|
|
66
103
|
#
|
|
67
104
|
# @param page_url [String, Html2rss::Url]
|
|
68
105
|
# @param parsed_body [Nokogiri::HTML::Document, nil]
|
|
@@ -71,8 +108,9 @@ module Html2rss
|
|
|
71
108
|
# @return [Array<Html2rss::Url>]
|
|
72
109
|
def candidate_urls(page_url:, parsed_body: nil, html: nil, extra_paths: [])
|
|
73
110
|
page = Html2rss::Url.from_absolute(page_url)
|
|
74
|
-
(
|
|
75
|
-
|
|
111
|
+
doc = document(parsed_body)
|
|
112
|
+
advertisement = RequestService::FeedAdvertisement.from_document(doc, page_url: page)
|
|
113
|
+
candidates(page, advertisement, doc:, html:, extra_paths:)
|
|
76
114
|
end
|
|
77
115
|
|
|
78
116
|
##
|
|
@@ -100,37 +138,53 @@ module Html2rss
|
|
|
100
138
|
|
|
101
139
|
private
|
|
102
140
|
|
|
103
|
-
def successful_status?(response)
|
|
104
|
-
status = response.status
|
|
105
|
-
status.nil? || status.between?(200, 299)
|
|
106
|
-
end
|
|
141
|
+
def successful_status?(response) = response.status.nil? || response.status.between?(200, 299)
|
|
107
142
|
|
|
108
|
-
def first_feedish_response(urls, request_session:, origin_url:, max_probes: nil)
|
|
143
|
+
def first_feedish_response(urls, request_session:, origin_url:, advertisement:, max_probes: nil)
|
|
109
144
|
scoped = max_probes.nil? ? urls.lazy : urls.lazy.take(max_probes)
|
|
110
145
|
scoped.filter_map do |url|
|
|
111
|
-
response = probe(url, request_session:, origin_url:)
|
|
146
|
+
response = probe(url, request_session:, origin_url:, advertisement:)
|
|
112
147
|
response if feedish?(response)
|
|
113
148
|
end.first
|
|
114
149
|
end
|
|
115
150
|
|
|
116
|
-
def probe(url, request_session:, origin_url:)
|
|
117
|
-
|
|
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
|
|
118
159
|
rescue Html2rss::Error => error
|
|
119
160
|
Log.debug("Syndication::Discovery: probe skipped host=#{origin_url.host} (#{error.class})")
|
|
120
161
|
nil
|
|
121
162
|
end
|
|
122
163
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
126
168
|
|
|
127
|
-
|
|
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
|
|
128
172
|
end
|
|
129
173
|
|
|
130
|
-
|
|
131
|
-
|
|
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?
|
|
178
|
+
|
|
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
|
|
132
182
|
|
|
133
|
-
|
|
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) }
|
|
134
188
|
end
|
|
135
189
|
|
|
136
190
|
def path_guess_urls(page, extra_paths:)
|
|
@@ -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
|