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
|
@@ -35,10 +35,8 @@ module Html2rss
|
|
|
35
35
|
|
|
36
36
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
37
37
|
# @param url [String, Html2rss::Url] base page URL
|
|
38
|
-
# @param _opts [Hash] unused scraper-specific options
|
|
39
|
-
# @option _opts [Object] :_reserved reserved for future options
|
|
40
38
|
# @return [void]
|
|
41
|
-
def initialize(parsed_body, url
|
|
39
|
+
def initialize(parsed_body, url:)
|
|
42
40
|
@parsed_body = parsed_body
|
|
43
41
|
@url = Html2rss::Url.from_absolute(url)
|
|
44
42
|
end
|
|
@@ -111,11 +109,19 @@ module Html2rss
|
|
|
111
109
|
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
112
110
|
# @return [String, nil] image URL
|
|
113
111
|
def extract_image(entry)
|
|
114
|
-
node = entry.
|
|
112
|
+
node = entry.css('.u-featured, .u-photo').find { !author_node?(_1, entry) }
|
|
115
113
|
raw_src = node&.attr('src')&.strip || node&.attr('href')&.strip
|
|
116
114
|
resolve_url(raw_src)&.to_s
|
|
117
115
|
end
|
|
118
116
|
|
|
117
|
+
# @param node [Nokogiri::XML::Element] candidate node
|
|
118
|
+
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
119
|
+
# @return [Boolean] whether the node is, or sits inside, an author card within the entry
|
|
120
|
+
def author_node?(node, entry)
|
|
121
|
+
chain = [node, *node.ancestors.take_while { _1 != entry }]
|
|
122
|
+
chain.any? { _1['class'].to_s.split.intersect?(%w[p-author h-card]) }
|
|
123
|
+
end
|
|
124
|
+
|
|
119
125
|
# @param entry [Nokogiri::XML::Element] entry DOM node
|
|
120
126
|
# @return [String, nil] author text
|
|
121
127
|
def extract_author(entry)
|
|
@@ -5,9 +5,15 @@ module Html2rss
|
|
|
5
5
|
module Scraper
|
|
6
6
|
##
|
|
7
7
|
# Promotes a page's native RSS/Atom feed via {Syndication::Discovery} + {Syndication::Parser}.
|
|
8
|
+
# After {#each}, {#channel} holds what that feed states about its channel and {#feed_url} where
|
|
9
|
+
# it was read, the provenance {Cleanup} admits the feed's own domain by.
|
|
8
10
|
class NativeFeed
|
|
9
11
|
include Enumerable
|
|
10
12
|
|
|
13
|
+
# Href shapes that Discovery's path probing can still resolve when the link type is not a feed MIME type.
|
|
14
|
+
FEEDISH_HREF = /rss|atom|feed|\.xml/i
|
|
15
|
+
private_constant :FEEDISH_HREF
|
|
16
|
+
|
|
11
17
|
# @return [Symbol] scraper config key
|
|
12
18
|
def self.options_key = :native_feed
|
|
13
19
|
|
|
@@ -27,25 +33,16 @@ module Html2rss
|
|
|
27
33
|
def self.articles?(parsed_body)
|
|
28
34
|
return false unless parsed_body.is_a?(Nokogiri::HTML::Document)
|
|
29
35
|
|
|
30
|
-
return true if
|
|
36
|
+
return true if Html2rss::Html::FeedLink.from_document(parsed_body).any?
|
|
31
37
|
|
|
32
|
-
parsed_body.css('head link[rel~="alternate"][href]').any?
|
|
33
|
-
href = node['href'].to_s
|
|
34
|
-
::Html2rss::Html::Probe.mime_match?(
|
|
35
|
-
node['type'],
|
|
36
|
-
::Html2rss::Html::Probe::APPLICATION_RSS_XML,
|
|
37
|
-
::Html2rss::Html::Probe::APPLICATION_ATOM_XML
|
|
38
|
-
) || href.match?(/rss|atom|feed|\.xml/i)
|
|
39
|
-
end
|
|
38
|
+
parsed_body.css('head link[rel~="alternate"][href]').any? { |node| node['href'].match?(FEEDISH_HREF) }
|
|
40
39
|
end
|
|
41
40
|
|
|
42
41
|
##
|
|
43
42
|
# @param parsed_body [Nokogiri::HTML::Document]
|
|
44
43
|
# @param url [String, Html2rss::Url]
|
|
45
44
|
# @param request_session [Html2rss::RequestSession, nil]
|
|
46
|
-
|
|
47
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
48
|
-
def initialize(parsed_body, url:, request_session: nil, **_opts)
|
|
45
|
+
def initialize(parsed_body, url:, request_session: nil)
|
|
49
46
|
@parsed_body = parsed_body
|
|
50
47
|
@url = Html2rss::Url.from_absolute(url)
|
|
51
48
|
@request_session = request_session
|
|
@@ -54,6 +51,16 @@ module Html2rss
|
|
|
54
51
|
# @return [Boolean]
|
|
55
52
|
def extractable? = self.class.articles?(parsed_body)
|
|
56
53
|
|
|
54
|
+
##
|
|
55
|
+
# @return [Syndication::ChannelFacts, nil] what the followed feed states about its channel;
|
|
56
|
+
# nil before {#each} or when no feed was read
|
|
57
|
+
def channel = @feed&.channel
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# @return [Html2rss::Url, nil] URL of the feed read (after redirects); nil before {#each} or
|
|
61
|
+
# when no feed was read
|
|
62
|
+
attr_reader :feed_url
|
|
63
|
+
|
|
57
64
|
##
|
|
58
65
|
# @yieldparam article [Hash{Symbol => Object}]
|
|
59
66
|
# @return [Enumerator, void]
|
|
@@ -61,7 +68,7 @@ module Html2rss
|
|
|
61
68
|
def each(&)
|
|
62
69
|
return enum_for(:each) unless block_given?
|
|
63
70
|
|
|
64
|
-
articles =
|
|
71
|
+
articles = (@feed = fetch_feed).articles
|
|
65
72
|
if articles.empty?
|
|
66
73
|
Log.info({
|
|
67
74
|
event_name: 'auto_source.native_feed.empty',
|
|
@@ -84,8 +91,8 @@ module Html2rss
|
|
|
84
91
|
attr_reader :parsed_body, :url, :request_session
|
|
85
92
|
|
|
86
93
|
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- discovery + parse + structured warn
|
|
87
|
-
def
|
|
88
|
-
return
|
|
94
|
+
def fetch_feed
|
|
95
|
+
return Syndication::Feed::NONE unless request_session
|
|
89
96
|
|
|
90
97
|
response = Syndication::Discovery.best_feed_response(
|
|
91
98
|
page_url: url,
|
|
@@ -93,17 +100,20 @@ module Html2rss
|
|
|
93
100
|
parsed_body:,
|
|
94
101
|
max_probes: self.class.request_slots
|
|
95
102
|
)
|
|
96
|
-
return
|
|
103
|
+
return Syndication::Feed::NONE unless response
|
|
97
104
|
|
|
105
|
+
@feed_url = response.url
|
|
98
106
|
Syndication::Parser.parse_response(response)
|
|
99
|
-
rescue Html2rss::
|
|
107
|
+
rescue Html2rss::ContractError
|
|
108
|
+
raise
|
|
109
|
+
rescue Html2rss::Error => error
|
|
100
110
|
Log.warn({
|
|
101
111
|
event_name: 'auto_source.native_feed.failed',
|
|
102
112
|
host: url.host,
|
|
103
113
|
error_class: error.class.name,
|
|
104
114
|
message: "#{self.class}: host=#{url.host} failed (#{error.class}: #{error.message})"
|
|
105
115
|
}.to_json)
|
|
106
|
-
|
|
116
|
+
Syndication::Feed::NONE
|
|
107
117
|
end
|
|
108
118
|
end
|
|
109
119
|
end
|
|
@@ -8,7 +8,7 @@ module Html2rss
|
|
|
8
8
|
# Extracts categories from Schema.org structured data.
|
|
9
9
|
module CategoryExtractor
|
|
10
10
|
# Schema.org fields checked for category-like string values.
|
|
11
|
-
CATEGORY_FIELDS = %i[articleSection keywords categories tags].freeze
|
|
11
|
+
CATEGORY_FIELDS = %i[articleSection keywords categories tags occupationalCategory industry].freeze
|
|
12
12
|
|
|
13
13
|
##
|
|
14
14
|
# @param schema_object [Hash] The schema object
|
|
@@ -26,15 +26,20 @@ module Html2rss
|
|
|
26
26
|
def add_field(categories, value)
|
|
27
27
|
case value
|
|
28
28
|
when Array
|
|
29
|
-
value.each { |item|
|
|
29
|
+
value.each { |item| add_item(categories, item) }
|
|
30
30
|
when String
|
|
31
31
|
split_string(categories, value)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def add_item(categories, item)
|
|
36
|
+
item.is_a?(String) ? split_string(categories, item) : add_string(categories, item.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
35
39
|
def add_about(categories, about)
|
|
36
40
|
case about
|
|
37
41
|
when Array then add_about_items(categories, about)
|
|
42
|
+
when Hash then add_about_items(categories, [about])
|
|
38
43
|
when String then split_string(categories, about)
|
|
39
44
|
end
|
|
40
45
|
end
|
|
@@ -49,11 +49,13 @@ module Html2rss
|
|
|
49
49
|
|
|
50
50
|
# @param object [Hash] candidate schema object
|
|
51
51
|
# @param from_list_item [Boolean] whether object came from a ListItem unwrap
|
|
52
|
-
# @return [Boolean] whether to emit this object
|
|
52
|
+
# @return [Boolean] whether to emit this object: a ListItem unwrap admits everything except pure
|
|
53
|
+
# entities ({Thing::ENTITY_TYPES}); a bare element must be an article, media, Event or JobPosting type
|
|
53
54
|
def emit?(object, from_list_item:)
|
|
54
|
-
|
|
55
|
+
types = Schema.normalize_types(object[:@type])
|
|
56
|
+
return types.empty? || !types.subset?(Thing::ENTITY_TYPES) if from_list_item
|
|
55
57
|
|
|
56
|
-
|
|
58
|
+
types.intersect?(Thing::ITEM_TYPES | Thing::ITEM_LIST_TYPES)
|
|
57
59
|
end
|
|
58
60
|
end
|
|
59
61
|
end
|
|
@@ -10,7 +10,7 @@ module Html2rss
|
|
|
10
10
|
# A Thing is kind of the 'base class' for Schema.org schema_objects.
|
|
11
11
|
#
|
|
12
12
|
# @see https://schema.org/Thing
|
|
13
|
-
class Thing
|
|
13
|
+
class Thing # rubocop:disable Metrics/ClassLength -- one accessor per schema.org field
|
|
14
14
|
# Supported Schema.org `@type` values mapped to article extraction.
|
|
15
15
|
SUPPORTED_TYPES = %w[
|
|
16
16
|
AdvertiserContentArticle AnalysisNewsArticle APIReference Article
|
|
@@ -19,6 +19,41 @@ module Html2rss
|
|
|
19
19
|
ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
|
|
20
20
|
].to_set.freeze
|
|
21
21
|
|
|
22
|
+
# Schema.org media types and the file kind each proves (`contentUrl` is the file; `embedUrl`, the player,
|
|
23
|
+
# is never an enclosure nor the item URL), so an untyped `contentUrl` without a media extension is still
|
|
24
|
+
# audio or video.
|
|
25
|
+
MEDIA_KINDS = {
|
|
26
|
+
'AudioObject' => :audio, 'PodcastEpisode' => :audio, 'VideoObject' => :video, 'Clip' => :video
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Schema.org media types: items wherever they appear (standalone, in `@graph` or an array, as ItemList
|
|
30
|
+
# elements), marked `media: true`; their own `contentUrl` (when present) is a media enclosure.
|
|
31
|
+
MEDIA_TYPES = MEDIA_KINDS.keys.to_set.freeze
|
|
32
|
+
|
|
33
|
+
# Types emitted as items wherever they appear: articles and media.
|
|
34
|
+
ITEM_TYPES = (SUPPORTED_TYPES | MEDIA_TYPES).freeze
|
|
35
|
+
|
|
36
|
+
# Keys holding the media objects (object or array) whose `contentUrl` is an enclosure, in choice order.
|
|
37
|
+
MEDIA_KEYS = %i[video audio associatedMedia].freeze
|
|
38
|
+
|
|
39
|
+
# A MIME type (`type/subtype`) at the start of `encodingFormat`; a bare format name such as `mp3` maps
|
|
40
|
+
# through {Article::Enclosure::MEDIA_TYPES}.
|
|
41
|
+
MIME_TYPE = %r{\A[a-z]+/[a-z0-9][\w.+-]*}i
|
|
42
|
+
|
|
43
|
+
# Schema.org entity types that describe who or what something is, never a piece of content; a
|
|
44
|
+
# ListItem-wrapped object typed only with these is not a feed item. Organization and Place subtypes
|
|
45
|
+
# are listed because the wire form carries the subtype, not the parent.
|
|
46
|
+
ENTITY_TYPES = Set[
|
|
47
|
+
'Person',
|
|
48
|
+
'Organization', 'Corporation', 'NGO', 'LocalBusiness', 'GovernmentOrganization',
|
|
49
|
+
'EducationalOrganization', 'NewsMediaOrganization', 'Brand',
|
|
50
|
+
'Product', 'ProductGroup', 'Offer',
|
|
51
|
+
'Place', 'City', 'Country', 'PostalAddress'
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
# Schema.org types emitted only as elements of an ItemList (never standalone).
|
|
55
|
+
ITEM_LIST_TYPES = Set['Event', 'JobPosting'].freeze
|
|
56
|
+
|
|
22
57
|
# @param schema_object [Hash{Symbol => Object}] parsed schema.org object
|
|
23
58
|
# @param url [String, Html2rss::Url, nil] base URL used for relative normalization
|
|
24
59
|
def initialize(schema_object, url:)
|
|
@@ -26,9 +61,10 @@ module Html2rss
|
|
|
26
61
|
@base_url = normalized_base_url(url)
|
|
27
62
|
end
|
|
28
63
|
|
|
29
|
-
# @return [Hash{Symbol => Object}] scraped article attributes
|
|
64
|
+
# @return [Hash{Symbol => Object}] scraped article attributes; a media object's carry `media: true`
|
|
30
65
|
def call
|
|
31
|
-
{ id:, title:, description:, author:, url:, image:, published_at:, categories: }
|
|
66
|
+
item = { id:, title:, description:, author:, url:, image:, published_at:, categories:, enclosures: }
|
|
67
|
+
own_media? ? item.merge(media: true) : item
|
|
32
68
|
end
|
|
33
69
|
|
|
34
70
|
# @return [String, nil] stable schema object identifier
|
|
@@ -43,31 +79,29 @@ module Html2rss
|
|
|
43
79
|
def title
|
|
44
80
|
schema_object.values_at(:headline, :name, :alternativeHeadline, :title)
|
|
45
81
|
.lazy
|
|
46
|
-
.map { _1.to_s.strip }
|
|
82
|
+
.map { first_scalar(_1).to_s.strip }
|
|
47
83
|
.find { !_1.empty? }
|
|
48
84
|
end
|
|
49
85
|
|
|
50
86
|
# @return [String, nil] longest available description field
|
|
51
87
|
def description
|
|
52
|
-
schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body)
|
|
88
|
+
schema_object.values_at(:description, :articleBody, :abstract, :schema_object_body)
|
|
89
|
+
.map { first_scalar(_1) }
|
|
90
|
+
.max_by { _1.to_s.size }
|
|
53
91
|
end
|
|
54
92
|
|
|
55
93
|
# @return [String, nil] extracted author or publisher name
|
|
56
94
|
def author
|
|
57
|
-
extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher])
|
|
95
|
+
extract_author_name(schema_object[:author]) || extract_author_name(schema_object[:publisher]) ||
|
|
96
|
+
extract_author_name(schema_object[:hiringOrganization]) ||
|
|
97
|
+
extract_author_name(schema_object[:organizer])
|
|
58
98
|
end
|
|
59
99
|
|
|
60
100
|
# @return [Html2rss::Url, nil] the URL of the schema object
|
|
61
101
|
def url
|
|
62
102
|
return @url if defined?(@url)
|
|
63
103
|
|
|
64
|
-
url = schema_object[:url]
|
|
65
|
-
if url.to_s.empty?
|
|
66
|
-
Log.debug("Schema#Thing.url: no url in schema_object: #{schema_object.inspect}")
|
|
67
|
-
return @url = nil
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
@url = Url.from_relative(url, base_url || url)
|
|
104
|
+
@url = build_url(first_scalar(schema_object[:url])) || fallback_url
|
|
71
105
|
end
|
|
72
106
|
|
|
73
107
|
# @return [Html2rss::Url, nil] normalized article image URL
|
|
@@ -78,10 +112,11 @@ module Html2rss
|
|
|
78
112
|
@image = img_url ? Url.from_relative(img_url, base_url || img_url) : nil
|
|
79
113
|
end
|
|
80
114
|
|
|
81
|
-
# @return [String, nil]
|
|
115
|
+
# @return [String, nil] first non-empty of datePublished, datePosted, dateCreated, dateModified, uploadDate
|
|
82
116
|
def published_at
|
|
83
|
-
|
|
84
|
-
|
|
117
|
+
schema_object.values_at(:datePublished, :datePosted, :dateCreated, :dateModified, :uploadDate)
|
|
118
|
+
.map { first_scalar(_1) }
|
|
119
|
+
.find { !_1.to_s.empty? }
|
|
85
120
|
end
|
|
86
121
|
|
|
87
122
|
# @return [Array<String>, nil] extracted category labels
|
|
@@ -89,15 +124,95 @@ module Html2rss
|
|
|
89
124
|
@categories ||= CategoryExtractor.call(schema_object)
|
|
90
125
|
end
|
|
91
126
|
|
|
127
|
+
# @return [Array<Hash{Symbol => Object}>, nil] enclosures from the `contentUrl` of nested media objects
|
|
128
|
+
# ({MEDIA_KEYS}) and of a media object itself, with declared size and duration; `embedUrl` is never one
|
|
129
|
+
def enclosures
|
|
130
|
+
nodes = schema_object.values_at(*MEDIA_KEYS).flatten
|
|
131
|
+
nodes << schema_object if own_media?
|
|
132
|
+
found = nodes.filter_map { media_enclosure(_1) }
|
|
133
|
+
found unless found.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
92
136
|
attr_reader :schema_object, :base_url
|
|
93
137
|
|
|
94
138
|
# @return [Array<String>] normalized image URL candidates
|
|
95
139
|
def image_urls
|
|
96
|
-
@image_urls ||= schema_object.values_at(:image, :thumbnailUrl).filter_map { image_url_from(_1) }
|
|
140
|
+
@image_urls ||= schema_object.values_at(:image, :thumbnailUrl, :thumbnail).filter_map { image_url_from(_1) }
|
|
97
141
|
end
|
|
98
142
|
|
|
99
143
|
private
|
|
100
144
|
|
|
145
|
+
# @return [Boolean] whether the object itself is a media object (an item marked media)
|
|
146
|
+
def own_media? = Schema.normalize_types(schema_object[:@type]).intersect?(MEDIA_TYPES)
|
|
147
|
+
|
|
148
|
+
# @param node [Object] media object (VideoObject, AudioObject, MediaObject) or the media item itself
|
|
149
|
+
# @return [Hash{Symbol => Object}, nil] enclosure attributes, or nil without an http(s) contentUrl
|
|
150
|
+
# (an ImageObject is never one: images are {#image})
|
|
151
|
+
def media_enclosure(node)
|
|
152
|
+
return unless node.is_a?(Hash) && (src = first_scalar(node[:contentUrl])).is_a?(String)
|
|
153
|
+
return if (types = Schema.normalize_types(node[:@type])).include?('ImageObject')
|
|
154
|
+
|
|
155
|
+
kind = types.filter_map { MEDIA_KINDS[_1] }.first
|
|
156
|
+
type = mime_type(node[:encodingFormat])
|
|
157
|
+
Html2rss::Html::ArticleRules::Enclosure.from_media(src, type, base_url, kind:)&.merge(file_facts(node))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @param node [Hash] media object
|
|
161
|
+
# @return [Hash{Symbol => Integer, nil}] its byte length and playback seconds, the duration else that of
|
|
162
|
+
# the item itself (a PodcastEpisode's duration)
|
|
163
|
+
def file_facts(node)
|
|
164
|
+
duration = Article::Enclosure.parse_duration(node[:duration]) ||
|
|
165
|
+
Article::Enclosure.parse_duration(schema_object[:duration])
|
|
166
|
+
{ bytes_length: Article::Enclosure.parse_size(node[:contentSize]), duration: }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param format [Object] encodingFormat
|
|
170
|
+
# @return [String, nil] its lowercase MIME type, or the playable type of a media format name (+mp3+,
|
|
171
|
+
# +.m4a+); nil for any other name, so the file name decides
|
|
172
|
+
def mime_type(format)
|
|
173
|
+
return unless format.is_a?(String)
|
|
174
|
+
|
|
175
|
+
format[MIME_TYPE]&.downcase || Article::Enclosure::MEDIA_TYPES[format.strip.downcase.delete_prefix('.')]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @param value [Object] url candidate
|
|
179
|
+
# @return [Html2rss::Url, nil] absolute url, or nil when missing or unusable
|
|
180
|
+
def build_url(value)
|
|
181
|
+
return Url.from_relative(value, base_url || value) if value.is_a?(String) && !value.empty?
|
|
182
|
+
|
|
183
|
+
Log.debug("Schema#Thing.url: no usable url in schema_object: #{schema_object.inspect}")
|
|
184
|
+
nil
|
|
185
|
+
rescue Url::InvalidUrlError
|
|
186
|
+
Log.debug("Schema#Thing.url: unusable url #{value.inspect}")
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @return [Html2rss::Url, nil] first absolute http(s) mainEntityOfPage or @id, without fragment; never
|
|
191
|
+
# `contentUrl` (a file) or `embedUrl` (a player page)
|
|
192
|
+
def fallback_url
|
|
193
|
+
main = schema_object[:mainEntityOfPage]
|
|
194
|
+
candidates = main.is_a?(Hash) ? [main[:@id], main[:url]] : [main]
|
|
195
|
+
(candidates << schema_object[:@id]).filter_map { web_url(_1) }.first
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @param value [Object] url candidate
|
|
199
|
+
# @return [Html2rss::Url, nil] fragment-free url when value is an absolute http(s) string
|
|
200
|
+
def web_url(value)
|
|
201
|
+
return unless value.is_a?(String) && value.match?(%r{\Ahttps?://}i)
|
|
202
|
+
|
|
203
|
+
Url.from_absolute(value.strip).without_fragment
|
|
204
|
+
rescue Url::InvalidUrlError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# @param value [Object] schema value that may be wrapped in a list (multilingual or multi-valued fields)
|
|
209
|
+
# @return [Object, nil] the value, or the first non-empty String of a list
|
|
210
|
+
def first_scalar(value)
|
|
211
|
+
return value unless value.is_a?(Array)
|
|
212
|
+
|
|
213
|
+
value.find { _1.is_a?(String) && !_1.strip.empty? }
|
|
214
|
+
end
|
|
215
|
+
|
|
101
216
|
# @param obj [String, Hash, Array, nil] schema author or publisher field
|
|
102
217
|
# @return [String, nil] extracted author name
|
|
103
218
|
def extract_author_name(obj)
|
|
@@ -110,12 +225,15 @@ module Html2rss
|
|
|
110
225
|
end
|
|
111
226
|
end
|
|
112
227
|
|
|
113
|
-
# @param obj [String, Hash, nil] image node
|
|
114
|
-
# @return [String, nil] image URL string
|
|
228
|
+
# @param obj [String, Hash, Array, nil] image node or list of image nodes
|
|
229
|
+
# @return [String, nil] image URL string (first usable entry for lists)
|
|
115
230
|
def image_url_from(obj)
|
|
116
231
|
return obj if obj.is_a?(String)
|
|
232
|
+
return obj.filter_map { image_url_from(_1) }.first if obj.is_a?(Array)
|
|
117
233
|
return unless obj.is_a?(Hash)
|
|
118
|
-
|
|
234
|
+
|
|
235
|
+
types = Schema.normalize_types(obj[:@type])
|
|
236
|
+
return unless types.empty? || types.include?('ImageObject')
|
|
119
237
|
|
|
120
238
|
obj[:url] || obj[:contentUrl]
|
|
121
239
|
end
|
|
@@ -129,7 +247,7 @@ module Html2rss
|
|
|
129
247
|
|
|
130
248
|
norm_url = normalized_id_url(text, reference_url:)
|
|
131
249
|
reference_url && norm_url.host == reference_url.host ? normalized_id_value(norm_url) : text
|
|
132
|
-
rescue
|
|
250
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
133
251
|
text
|
|
134
252
|
end
|
|
135
253
|
|
|
@@ -153,7 +271,7 @@ module Html2rss
|
|
|
153
271
|
# @return [Html2rss::Url, nil] normalized absolute URL for schema resolution
|
|
154
272
|
def normalized_base_url(url)
|
|
155
273
|
Url.from_absolute(url) unless url.to_s.strip.empty?
|
|
156
|
-
rescue
|
|
274
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
157
275
|
nil
|
|
158
276
|
end
|
|
159
277
|
end
|
|
@@ -25,14 +25,15 @@ module Html2rss
|
|
|
25
25
|
|
|
26
26
|
# Canonical Schema.org type names keyed by folded wire forms (case-insensitive lookup).
|
|
27
27
|
CANONICAL_BY_DOWNCASE = begin
|
|
28
|
-
canonical_types = Thing::
|
|
28
|
+
canonical_types = Thing::ITEM_TYPES | ItemList::SUPPORTED_TYPES | Thing::ITEM_LIST_TYPES |
|
|
29
|
+
DENIED_CONTAINER_TYPES | Set['Product']
|
|
29
30
|
canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
|
|
30
31
|
end.freeze
|
|
31
32
|
|
|
32
33
|
# Pre-compiled regex for supported schema types (short name or schema.org URL; string or array @type).
|
|
33
34
|
# Allows preceding entries in a JSON @type array (e.g. ["WebPage","NewsArticle"]).
|
|
34
35
|
SUPPORTED_TYPES_RE = begin
|
|
35
|
-
types = Thing::
|
|
36
|
+
types = Thing::ITEM_TYPES | ItemList::SUPPORTED_TYPES
|
|
36
37
|
type_re = Regexp.union(types.to_a)
|
|
37
38
|
%r{(?i)"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
|
|
38
39
|
end.freeze
|
|
@@ -43,6 +44,9 @@ module Html2rss
|
|
|
43
44
|
# Shared empty type set for nil/unsupported wire forms (avoids per-call Set.new).
|
|
44
45
|
EMPTY_TYPES = Set.new.freeze
|
|
45
46
|
|
|
47
|
+
# Keys whose `{"@id": ...}` stubs are resolved against sibling nodes of the same JSON-LD document.
|
|
48
|
+
REFERENCE_KEYS = (%i[author publisher image] + Thing::MEDIA_KEYS).freeze
|
|
49
|
+
|
|
46
50
|
# @return [Symbol] scraper config key
|
|
47
51
|
def self.options_key = :schema
|
|
48
52
|
|
|
@@ -84,6 +88,18 @@ module Html2rss
|
|
|
84
88
|
end
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
##
|
|
92
|
+
# Like {from} for a script tag, but resolves `@id` stubs for {REFERENCE_KEYS} against the same document.
|
|
93
|
+
#
|
|
94
|
+
# @param script [Nokogiri::XML::Element] schema JSON-LD script tag
|
|
95
|
+
# @return [Array<Hash>] the schema_objects with resolved references
|
|
96
|
+
# @api private
|
|
97
|
+
def from_script(script)
|
|
98
|
+
document = parse_script_tag(script)
|
|
99
|
+
index = node_index(document)
|
|
100
|
+
from(document).map { |object| resolve_references(object, index) }
|
|
101
|
+
end
|
|
102
|
+
|
|
87
103
|
# @param object [Hash{Symbol => Object}] schema candidate object
|
|
88
104
|
# @return [Boolean] whether an extractor exists for the candidate object
|
|
89
105
|
def supported_schema_object?(object)
|
|
@@ -99,7 +115,7 @@ module Html2rss
|
|
|
99
115
|
# Prefer article/list extractors over denied containers for multi-type @type arrays
|
|
100
116
|
# (e.g. ["NewsArticle","WebPage"]).
|
|
101
117
|
return ItemList if types.intersect?(ItemList::SUPPORTED_TYPES)
|
|
102
|
-
return Thing if types.intersect?(Thing::
|
|
118
|
+
return Thing if types.intersect?(Thing::ITEM_TYPES)
|
|
103
119
|
return nil if types.intersect?(DENIED_CONTAINER_TYPES)
|
|
104
120
|
|
|
105
121
|
Log.debug("#{name}: unsupported schema object @type=#{schema_object[:@type].inspect}")
|
|
@@ -139,6 +155,37 @@ module Html2rss
|
|
|
139
155
|
|
|
140
156
|
private
|
|
141
157
|
|
|
158
|
+
# @param object [Hash, Array, Object] parsed JSON-LD value
|
|
159
|
+
# @param index [Hash{String => Hash}] accumulator of nodes (more than just `@id`) keyed by `@id`
|
|
160
|
+
# @return [Hash{String => Hash}] nodes keyed by `@id`
|
|
161
|
+
def node_index(object, index = {})
|
|
162
|
+
case object
|
|
163
|
+
when Hash
|
|
164
|
+
index[object[:@id]] ||= object if object[:@id].is_a?(String) && object.size > 1
|
|
165
|
+
object.each_value { node_index(_1, index) }
|
|
166
|
+
when Array then object.each { node_index(_1, index) }
|
|
167
|
+
end
|
|
168
|
+
index
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @param object [Hash{Symbol => Object}] schema object
|
|
172
|
+
# @param index [Hash{String => Hash}] nodes keyed by `@id`
|
|
173
|
+
# @return [Hash{Symbol => Object}] object with reference stubs replaced where a sibling node exists
|
|
174
|
+
def resolve_references(object, index)
|
|
175
|
+
object.to_h { |key, value| [key, REFERENCE_KEYS.include?(key) ? resolve_stub(value, index) : value] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @param value [Object] author, publisher or image value
|
|
179
|
+
# @param index [Hash{String => Hash}] nodes keyed by `@id`
|
|
180
|
+
# @return [Object] the referenced node for an `{"@id": ...}` stub, else the value unchanged
|
|
181
|
+
def resolve_stub(value, index)
|
|
182
|
+
case value
|
|
183
|
+
when Array then value.map { resolve_stub(_1, index) }
|
|
184
|
+
when Hash then value.keys == [:@id] ? index.fetch(value[:@id], value) : value
|
|
185
|
+
else value
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
142
189
|
# @param hash [Hash] candidate schema object
|
|
143
190
|
# @return [Array<Hash>] the object itself or nested supported objects
|
|
144
191
|
def from_hash(hash)
|
|
@@ -167,12 +214,9 @@ module Html2rss
|
|
|
167
214
|
|
|
168
215
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
169
216
|
# @param url [String, Html2rss::Url] base page URL
|
|
170
|
-
|
|
171
|
-
# @option opts [Object] :_reserved reserved for future scraper-specific options
|
|
172
|
-
def initialize(parsed_body, url:, **opts)
|
|
217
|
+
def initialize(parsed_body, url:)
|
|
173
218
|
@parsed_body = parsed_body
|
|
174
219
|
@url = url
|
|
175
|
-
@opts = opts
|
|
176
220
|
end
|
|
177
221
|
|
|
178
222
|
# @return [Boolean]
|
|
@@ -200,7 +244,7 @@ module Html2rss
|
|
|
200
244
|
|
|
201
245
|
def schema_objects
|
|
202
246
|
::Html2rss::Html::Probe.scripts(@parsed_body, ::Html2rss::Html::Probe::APPLICATION_LD_JSON)
|
|
203
|
-
.flat_map { |tag| Schema.
|
|
247
|
+
.flat_map { |tag| Schema.from_script(tag) }
|
|
204
248
|
end
|
|
205
249
|
|
|
206
250
|
attr_reader :parsed_body, :url
|
|
@@ -33,12 +33,8 @@ module Html2rss
|
|
|
33
33
|
# @return [Html2rss::Article, nil]
|
|
34
34
|
def article_for(entry)
|
|
35
35
|
@article_cache.fetch(entry) do
|
|
36
|
-
@article_cache[entry] =
|
|
37
|
-
entry,
|
|
38
|
-
base_url: @url,
|
|
39
|
-
scraper: @scraper,
|
|
40
|
-
fallback_anchorless: true
|
|
41
|
-
)
|
|
36
|
+
@article_cache[entry] =
|
|
37
|
+
::Html2rss::Html::SstArticleExtractor.call(entry, base_url: @url, scraper: @scraper)
|
|
42
38
|
end
|
|
43
39
|
end
|
|
44
40
|
|
|
@@ -83,9 +79,7 @@ module Html2rss
|
|
|
83
79
|
]
|
|
84
80
|
end
|
|
85
81
|
|
|
86
|
-
def word_count(text)
|
|
87
|
-
(@word_counts ||= {})[text] ||= text.to_s.scan(/\p{Alnum}+/).size
|
|
88
|
-
end
|
|
82
|
+
def word_count(text) = (@word_counts ||= {})[text] ||= WordCount.count(text)
|
|
89
83
|
end
|
|
90
84
|
end
|
|
91
85
|
end
|
|
@@ -26,22 +26,17 @@ module Html2rss
|
|
|
26
26
|
return false unless parsed_body
|
|
27
27
|
|
|
28
28
|
new(parsed_body, url: 'https://example.com').extractable?
|
|
29
|
-
rescue ArgumentError
|
|
30
|
-
false
|
|
31
29
|
end
|
|
32
30
|
|
|
33
31
|
# @param parsed_body [Nokogiri::HTML::Document, nil] parsed HTML (when +document:+ omitted)
|
|
34
32
|
# @param url [String, Html2rss::Url] base url
|
|
35
33
|
# @param document [SST::Document, nil] memoized SST document from AutoSource
|
|
36
34
|
# @param link_resolver [Scoring::LinkResolver, nil] shared page-scoped resolver
|
|
37
|
-
|
|
38
|
-
# @option opts [Boolean] :fallback_anchorless whether to keep containers without a primary anchor
|
|
39
|
-
def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil, **opts)
|
|
35
|
+
def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil)
|
|
40
36
|
@parsed_body = parsed_body
|
|
41
37
|
@provided_document = document
|
|
42
38
|
@provided_link_resolver = link_resolver
|
|
43
39
|
@url = url
|
|
44
|
-
@permit_unanchored = opts.fetch(:fallback_anchorless, false)
|
|
45
40
|
end
|
|
46
41
|
|
|
47
42
|
##
|
|
@@ -57,8 +52,6 @@ module Html2rss
|
|
|
57
52
|
# @return [Boolean]
|
|
58
53
|
def extractable?
|
|
59
54
|
ranked_segments.any?
|
|
60
|
-
rescue ArgumentError
|
|
61
|
-
false
|
|
62
55
|
end
|
|
63
56
|
|
|
64
57
|
private
|
|
@@ -69,21 +62,20 @@ module Html2rss
|
|
|
69
62
|
entries = deduplicator.call(ranked_segments)
|
|
70
63
|
entries.filter_map { |ranked| deduplicator.article_for(ranked) }
|
|
71
64
|
end
|
|
72
|
-
rescue ArgumentError
|
|
73
|
-
[]
|
|
74
65
|
end
|
|
75
66
|
|
|
76
67
|
def ranked_segments
|
|
77
|
-
@ranked_segments ||=
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
68
|
+
@ranked_segments ||= document ? rank_document : []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def rank_document
|
|
72
|
+
segments = Segmenter.call(
|
|
73
|
+
document,
|
|
74
|
+
base_url: @url,
|
|
75
|
+
strategy: :semantic,
|
|
76
|
+
link_resolver:
|
|
77
|
+
)
|
|
78
|
+
Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
|
|
87
79
|
end
|
|
88
80
|
|
|
89
81
|
def link_resolver
|
|
@@ -149,10 +149,7 @@ module Html2rss
|
|
|
149
149
|
def stale_date?(published_at)
|
|
150
150
|
return false unless published_at
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
(Time.now.utc - time) > (max_age_days * 86_400)
|
|
154
|
-
rescue ArgumentError
|
|
155
|
-
false
|
|
152
|
+
(Time.now.utc - Time.parse(published_at)) > (max_age_days * 86_400)
|
|
156
153
|
end
|
|
157
154
|
end
|
|
158
155
|
end
|