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
|
@@ -19,11 +19,40 @@ module Html2rss
|
|
|
19
19
|
ReviewNewsArticle SatiricalArticle ScholarlyArticle SocialMediaPosting TechArticle
|
|
20
20
|
].to_set.freeze
|
|
21
21
|
|
|
22
|
-
# Schema.org types
|
|
23
|
-
|
|
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
|
|
24
53
|
|
|
25
|
-
# Schema.org types
|
|
26
|
-
|
|
54
|
+
# Schema.org types emitted only as elements of an ItemList (never standalone).
|
|
55
|
+
ITEM_LIST_TYPES = Set['Event', 'JobPosting'].freeze
|
|
27
56
|
|
|
28
57
|
# @param schema_object [Hash{Symbol => Object}] parsed schema.org object
|
|
29
58
|
# @param url [String, Html2rss::Url, nil] base URL used for relative normalization
|
|
@@ -32,9 +61,10 @@ module Html2rss
|
|
|
32
61
|
@base_url = normalized_base_url(url)
|
|
33
62
|
end
|
|
34
63
|
|
|
35
|
-
# @return [Hash{Symbol => Object}] scraped article attributes
|
|
64
|
+
# @return [Hash{Symbol => Object}] scraped article attributes; a media object's carry `media: true`
|
|
36
65
|
def call
|
|
37
|
-
{ id:, title:, description:, author:, url:, image:, published_at:, categories:, enclosures: }
|
|
66
|
+
item = { id:, title:, description:, author:, url:, image:, published_at:, categories:, enclosures: }
|
|
67
|
+
own_media? ? item.merge(media: true) : item
|
|
38
68
|
end
|
|
39
69
|
|
|
40
70
|
# @return [String, nil] stable schema object identifier
|
|
@@ -94,9 +124,10 @@ module Html2rss
|
|
|
94
124
|
@categories ||= CategoryExtractor.call(schema_object)
|
|
95
125
|
end
|
|
96
126
|
|
|
97
|
-
# @return [Array<Hash{Symbol => Object}>, nil] enclosures from
|
|
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
|
|
98
129
|
def enclosures
|
|
99
|
-
nodes =
|
|
130
|
+
nodes = schema_object.values_at(*MEDIA_KEYS).flatten
|
|
100
131
|
nodes << schema_object if own_media?
|
|
101
132
|
found = nodes.filter_map { media_enclosure(_1) }
|
|
102
133
|
found unless found.empty?
|
|
@@ -111,28 +142,37 @@ module Html2rss
|
|
|
111
142
|
|
|
112
143
|
private
|
|
113
144
|
|
|
114
|
-
# @return [Boolean] whether the object itself is a media object
|
|
115
|
-
def own_media?
|
|
116
|
-
Schema.normalize_types(schema_object[:@type]).intersect?(MEDIA_TYPES)
|
|
117
|
-
end
|
|
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)
|
|
118
147
|
|
|
119
|
-
# @param node [Object] VideoObject
|
|
120
|
-
# @return [Hash{Symbol => Object}, nil] enclosure attributes, or nil without
|
|
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})
|
|
121
151
|
def media_enclosure(node)
|
|
122
|
-
return unless node.is_a?(Hash) && node[:contentUrl].is_a?(String)
|
|
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')
|
|
123
154
|
|
|
124
|
-
|
|
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))
|
|
125
158
|
end
|
|
126
159
|
|
|
127
|
-
# @param
|
|
128
|
-
# @
|
|
129
|
-
#
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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('.')]
|
|
136
176
|
end
|
|
137
177
|
|
|
138
178
|
# @param value [Object] url candidate
|
|
@@ -147,11 +187,12 @@ module Html2rss
|
|
|
147
187
|
nil
|
|
148
188
|
end
|
|
149
189
|
|
|
150
|
-
# @return [Html2rss::Url, nil] first absolute http(s) mainEntityOfPage or @id, without fragment
|
|
190
|
+
# @return [Html2rss::Url, nil] first absolute http(s) mainEntityOfPage or @id, without fragment; never
|
|
191
|
+
# `contentUrl` (a file) or `embedUrl` (a player page)
|
|
151
192
|
def fallback_url
|
|
152
193
|
main = schema_object[:mainEntityOfPage]
|
|
153
194
|
candidates = main.is_a?(Hash) ? [main[:@id], main[:url]] : [main]
|
|
154
|
-
(candidates
|
|
195
|
+
(candidates << schema_object[:@id]).filter_map { web_url(_1) }.first
|
|
155
196
|
end
|
|
156
197
|
|
|
157
198
|
# @param value [Object] url candidate
|
|
@@ -25,7 +25,7 @@ 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
29
|
DENIED_CONTAINER_TYPES | Set['Product']
|
|
30
30
|
canonical_types.to_h { |type| [::Html2rss::Html::Probe.fold(type), type] }.freeze
|
|
31
31
|
end.freeze
|
|
@@ -33,7 +33,7 @@ module Html2rss
|
|
|
33
33
|
# Pre-compiled regex for supported schema types (short name or schema.org URL; string or array @type).
|
|
34
34
|
# Allows preceding entries in a JSON @type array (e.g. ["WebPage","NewsArticle"]).
|
|
35
35
|
SUPPORTED_TYPES_RE = begin
|
|
36
|
-
types = Thing::
|
|
36
|
+
types = Thing::ITEM_TYPES | ItemList::SUPPORTED_TYPES
|
|
37
37
|
type_re = Regexp.union(types.to_a)
|
|
38
38
|
%r{(?i)"@type"\s*:\s*(?:\[\s*(?:"[^"]*"\s*,\s*)*)?"(?:https?://schema\.org/)?(?:#{type_re.source})"}
|
|
39
39
|
end.freeze
|
|
@@ -45,7 +45,7 @@ module Html2rss
|
|
|
45
45
|
EMPTY_TYPES = Set.new.freeze
|
|
46
46
|
|
|
47
47
|
# Keys whose `{"@id": ...}` stubs are resolved against sibling nodes of the same JSON-LD document.
|
|
48
|
-
REFERENCE_KEYS = %i[author publisher image].freeze
|
|
48
|
+
REFERENCE_KEYS = (%i[author publisher image] + Thing::MEDIA_KEYS).freeze
|
|
49
49
|
|
|
50
50
|
# @return [Symbol] scraper config key
|
|
51
51
|
def self.options_key = :schema
|
|
@@ -115,7 +115,7 @@ module Html2rss
|
|
|
115
115
|
# Prefer article/list extractors over denied containers for multi-type @type arrays
|
|
116
116
|
# (e.g. ["NewsArticle","WebPage"]).
|
|
117
117
|
return ItemList if types.intersect?(ItemList::SUPPORTED_TYPES)
|
|
118
|
-
return Thing if types.intersect?(Thing::
|
|
118
|
+
return Thing if types.intersect?(Thing::ITEM_TYPES)
|
|
119
119
|
return nil if types.intersect?(DENIED_CONTAINER_TYPES)
|
|
120
120
|
|
|
121
121
|
Log.debug("#{name}: unsupported schema object @type=#{schema_object[:@type].inspect}")
|
|
@@ -214,12 +214,9 @@ module Html2rss
|
|
|
214
214
|
|
|
215
215
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
216
216
|
# @param url [String, Html2rss::Url] base page URL
|
|
217
|
-
|
|
218
|
-
# @option opts [Object] :_reserved reserved for future scraper-specific options
|
|
219
|
-
def initialize(parsed_body, url:, **opts)
|
|
217
|
+
def initialize(parsed_body, url:)
|
|
220
218
|
@parsed_body = parsed_body
|
|
221
219
|
@url = url
|
|
222
|
-
@opts = opts
|
|
223
220
|
end
|
|
224
221
|
|
|
225
222
|
# @return [Boolean]
|
|
@@ -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
|
|
@@ -32,14 +32,11 @@ module Html2rss
|
|
|
32
32
|
# @param url [String, Html2rss::Url] base url
|
|
33
33
|
# @param document [SST::Document, nil] memoized SST document from AutoSource
|
|
34
34
|
# @param link_resolver [Scoring::LinkResolver, nil] shared page-scoped resolver
|
|
35
|
-
|
|
36
|
-
# @option opts [Boolean] :fallback_anchorless whether to keep containers without a primary anchor
|
|
37
|
-
def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil, **opts)
|
|
35
|
+
def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil)
|
|
38
36
|
@parsed_body = parsed_body
|
|
39
37
|
@provided_document = document
|
|
40
38
|
@provided_link_resolver = link_resolver
|
|
41
39
|
@url = url
|
|
42
|
-
@permit_unanchored = opts.fetch(:fallback_anchorless, false)
|
|
43
40
|
end
|
|
44
41
|
|
|
45
42
|
##
|
|
@@ -76,7 +73,6 @@ module Html2rss
|
|
|
76
73
|
document,
|
|
77
74
|
base_url: @url,
|
|
78
75
|
strategy: :semantic,
|
|
79
|
-
permit_unanchored: @permit_unanchored,
|
|
80
76
|
link_resolver:
|
|
81
77
|
)
|
|
82
78
|
Scoring::Engine.new(link_resolver:).rank_top(segments, limit: TOP_K)
|
|
@@ -43,8 +43,9 @@ module Html2rss
|
|
|
43
43
|
# @param url [String, Html2rss::Url] canonical page URL
|
|
44
44
|
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
45
45
|
# @param body [String, nil] raw response body for direct sitemap XML parsing
|
|
46
|
-
# @
|
|
47
|
-
# @option
|
|
46
|
+
# @param opts [Hash] sitemap options
|
|
47
|
+
# @option opts [Float] :min_priority minimum priority score (default: 0.3)
|
|
48
|
+
# @option opts [Integer] :max_age_days maximum entry age in days (default: 30)
|
|
48
49
|
def initialize(parsed_body, url:, request_session: nil, body: nil, **opts)
|
|
49
50
|
@parsed_body = parsed_body
|
|
50
51
|
@body = body
|
|
@@ -135,7 +136,7 @@ module Html2rss
|
|
|
135
136
|
|
|
136
137
|
response = request_session.follow_up(url: target_url, relation: :auto_source, origin_url: url)
|
|
137
138
|
response&.body
|
|
138
|
-
rescue Html2rss::RequestService::RequestBudgetExceeded
|
|
139
|
+
rescue Html2rss::RequestService::RequestBudgetExceeded, Html2rss::ContractError
|
|
139
140
|
raise
|
|
140
141
|
rescue Html2rss::Error => error
|
|
141
142
|
Log.warn({
|
|
@@ -46,10 +46,8 @@ module Html2rss
|
|
|
46
46
|
# @param parsed_body [Nokogiri::HTML::Document] parsed HTML document
|
|
47
47
|
# @param url [String, Html2rss::Url] canonical page URL
|
|
48
48
|
# @param request_session [Html2rss::RequestSession, nil] shared request session for follow-up fetches
|
|
49
|
-
# @param _opts [Hash] unused scraper-specific options
|
|
50
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
51
49
|
# @return [void]
|
|
52
|
-
def initialize(parsed_body, url:, request_session: nil
|
|
50
|
+
def initialize(parsed_body, url:, request_session: nil)
|
|
53
51
|
@parsed_body = parsed_body
|
|
54
52
|
@url = Html2rss::Url.from_absolute(url)
|
|
55
53
|
@request_session = request_session
|
|
@@ -109,6 +107,8 @@ module Html2rss
|
|
|
109
107
|
relation: :auto_source,
|
|
110
108
|
origin_url: url
|
|
111
109
|
)
|
|
110
|
+
rescue Html2rss::ContractError
|
|
111
|
+
raise
|
|
112
112
|
rescue Html2rss::Error => error
|
|
113
113
|
Log.warn({
|
|
114
114
|
event_name: 'wordpress_api.fetch_failed',
|
|
@@ -26,9 +26,7 @@ module Html2rss
|
|
|
26
26
|
# @param _parsed_body [Nokogiri::HTML::Document, nil] unused HTML document
|
|
27
27
|
# @param url [String, Html2rss::Url] page URL used to resolve relative links
|
|
28
28
|
# @param captured_responses [Array<Hash>] JSON bodies from Response#captured_responses
|
|
29
|
-
|
|
30
|
-
# @option _opts [Object] :_reserved reserved for future scraper-specific options
|
|
31
|
-
def initialize(_parsed_body, url:, captured_responses: [], **_opts)
|
|
29
|
+
def initialize(_parsed_body, url:, captured_responses: [])
|
|
32
30
|
@url = url
|
|
33
31
|
@captured_responses = captured_responses
|
|
34
32
|
end
|
|
@@ -38,8 +38,16 @@ module Html2rss
|
|
|
38
38
|
# Flat ordered list (request slot accounting, detection helpers).
|
|
39
39
|
SCRAPERS = (DOCUMENT_SCRAPERS + SCRAPER_TIERS.flatten).freeze
|
|
40
40
|
|
|
41
|
-
# Heuristic scrapers that share one memoized SST::Document per page.
|
|
41
|
+
# Heuristic scrapers that share one memoized SST::Document per page. The only titles composed
|
|
42
|
+
# (+Kicker: Headline+, noise stripped): their cards are read by Html::SstArticleExtractor. Every
|
|
43
|
+
# other scraper yields publisher-declared or structured headlines, kept verbatim (collapse only).
|
|
42
44
|
HEURISTIC_SCRAPERS = Set[SemanticHtml, Html].freeze
|
|
45
|
+
# In-page structured scrapers whose items {Corroboration} checks against the rendered page.
|
|
46
|
+
# Publisher-declared sources (NativeFeed, WordpressApi, Sitemap, MetaOembed) stay exempt.
|
|
47
|
+
CORROBORATED_SCRAPERS = Set[Schema, Microdata, Microformats2, JsonState, XhrArticles, JsonDocument].freeze
|
|
48
|
+
# Scrapers whose items are read off the rendered page: such an item needs a title ({Cleanup}
|
|
49
|
+
# drops it as +blank_title+), while a publisher-declared item (feed, API) may be untitled.
|
|
50
|
+
PAGE_SCRAPERS = (CORROBORATED_SCRAPERS | HEURISTIC_SCRAPERS).freeze
|
|
43
51
|
# Scrapers that accept a shared follow-up +request_session+.
|
|
44
52
|
REQUEST_SESSION_SCRAPERS = Set[NativeFeed, WordpressApi, Sitemap, MetaOembed].freeze
|
|
45
53
|
# Scrapers that consume browser-captured XHR/fetch JSON bodies.
|
|
@@ -80,7 +88,7 @@ module Html2rss
|
|
|
80
88
|
return if CATEGORY_MESSAGES.key?(category)
|
|
81
89
|
|
|
82
90
|
valid_categories = CATEGORY_MESSAGES.keys.join(', ')
|
|
83
|
-
raise
|
|
91
|
+
raise InvalidOption, "Unknown category: #{category.inspect}. Valid categories are: #{valid_categories}"
|
|
84
92
|
end
|
|
85
93
|
end
|
|
86
94
|
|
|
@@ -7,6 +7,9 @@ module Html2rss
|
|
|
7
7
|
##
|
|
8
8
|
# One candidate content block discovered by {Segmenter}.
|
|
9
9
|
class Segment
|
|
10
|
+
# Raised when a segment (or the Segmenter producing segments) gets invalid input.
|
|
11
|
+
class InvalidError < Html2rss::ContractError; end
|
|
12
|
+
|
|
10
13
|
# Allowed Segmenter strategies.
|
|
11
14
|
STRATEGIES = Set[:semantic, :list, :cluster].freeze
|
|
12
15
|
|
|
@@ -16,17 +19,28 @@ module Html2rss
|
|
|
16
19
|
# @param strategy [Symbol]
|
|
17
20
|
# @param position [Integer]
|
|
18
21
|
# @return [Segment]
|
|
19
|
-
# @raise [
|
|
22
|
+
# @raise [InvalidError] on invalid construction
|
|
20
23
|
def self.build(root_node:, strategy:, position:, primary_link: nil)
|
|
21
|
-
raise
|
|
24
|
+
raise InvalidError, 'root_node must be SST::Node' unless root_node.is_a?(SST::Node)
|
|
22
25
|
unless primary_link.nil? || primary_link.is_a?(SST::Node)
|
|
23
|
-
raise
|
|
26
|
+
raise InvalidError, 'primary_link must be SST::Node or nil'
|
|
24
27
|
end
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
|
|
29
|
+
strategy!(strategy)
|
|
30
|
+
raise InvalidError, 'position must be Integer' unless position.is_a?(Integer)
|
|
27
31
|
|
|
28
32
|
new(root_node:, primary_link:, strategy:, position:)
|
|
29
33
|
end
|
|
34
|
+
|
|
35
|
+
##
|
|
36
|
+
# @param strategy [Symbol]
|
|
37
|
+
# @return [Symbol] +strategy+ when it is one of {STRATEGIES}
|
|
38
|
+
# @raise [InvalidError] for any other value
|
|
39
|
+
def self.strategy!(strategy)
|
|
40
|
+
return strategy if STRATEGIES.include?(strategy)
|
|
41
|
+
|
|
42
|
+
raise InvalidError, "unknown strategy: #{strategy.inspect}"
|
|
43
|
+
end
|
|
30
44
|
end
|
|
31
45
|
end
|
|
32
46
|
end
|
|
@@ -15,7 +15,7 @@ module Html2rss
|
|
|
15
15
|
nodes = best_group_nodes(segmenter)
|
|
16
16
|
nodes.each_with_index.filter_map do |node, position|
|
|
17
17
|
link = primary.select(node)
|
|
18
|
-
next unless link
|
|
18
|
+
next unless link
|
|
19
19
|
|
|
20
20
|
Segment.build(root_node: node, primary_link: link, strategy: :cluster, position:)
|
|
21
21
|
end
|
|
@@ -115,21 +115,23 @@ module Html2rss
|
|
|
115
115
|
groups.sort_by { |_key, nodes| -nodes.size }.first(MAX_GROUPS).to_h
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
#
|
|
118
|
+
# A group is a container of +nodes_b+ when one of its nodes strictly encloses two or more of them.
|
|
119
119
|
def container_of?(nodes_a, nodes_b)
|
|
120
120
|
return false unless @layout_tags.include?(nodes_b.first.name)
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
next if node_a.equal?(node_b)
|
|
122
|
+
counts = enclosed_counts(nodes_b)
|
|
123
|
+
nodes_a.any? { |node_a| counts[node_a] > 1 }
|
|
124
|
+
end
|
|
126
125
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
# Number of +nodes+ strictly beneath each ancestor, tallied once per group instead of per pair.
|
|
127
|
+
def enclosed_counts(nodes)
|
|
128
|
+
@enclosed_counts ||= {}.compare_by_identity
|
|
129
|
+
@enclosed_counts[nodes] ||= nodes.each_with_object(Hash.new(0).compare_by_identity) do |node, tally|
|
|
130
|
+
ancestor = @index.parent_of(node)
|
|
131
|
+
while ancestor
|
|
132
|
+
tally[ancestor] += 1
|
|
133
|
+
ancestor = @index.parent_of(ancestor)
|
|
131
134
|
end
|
|
132
|
-
count > 1
|
|
133
135
|
end
|
|
134
136
|
end
|
|
135
137
|
|
|
@@ -33,9 +33,10 @@ module Html2rss
|
|
|
33
33
|
index = segmenter.index
|
|
34
34
|
main = main_landmark(index)
|
|
35
35
|
groups = {}.compare_by_identity
|
|
36
|
+
link_counts = relevant_link_counts(segmenter)
|
|
36
37
|
|
|
37
38
|
index.each_node.select { |node| card_anchor?(segmenter, index, main, node) }.each do |node|
|
|
38
|
-
card = parent_until_boundary(segmenter, node)
|
|
39
|
+
card = parent_until_boundary(segmenter, node, link_counts)
|
|
39
40
|
(groups[index.parent_of(card)] ||= []) << [card, node]
|
|
40
41
|
end
|
|
41
42
|
groups
|
|
@@ -69,9 +70,8 @@ module Html2rss
|
|
|
69
70
|
BOUNDARY_TAGS = Set[:body, :html].freeze
|
|
70
71
|
private_constant :BOUNDARY_TAGS
|
|
71
72
|
|
|
72
|
-
def self.parent_until_boundary(segmenter, node)
|
|
73
|
+
def self.parent_until_boundary(segmenter, node, link_counts)
|
|
73
74
|
index = segmenter.index
|
|
74
|
-
link_counts = Hash.new { |hash, curr| hash[curr] = count_relevant_links(segmenter, curr) }
|
|
75
75
|
|
|
76
76
|
index.parent_until(node, lambda { |curr|
|
|
77
77
|
return true if BOUNDARY_TAGS.include?(curr.name)
|
|
@@ -81,11 +81,19 @@ module Html2rss
|
|
|
81
81
|
end
|
|
82
82
|
private_class_method :parent_until_boundary
|
|
83
83
|
|
|
84
|
+
# Node-keyed counts shared across every anchor of one run, so shared ancestors are counted once.
|
|
85
|
+
# Keyed by identity: SST nodes are Data, whose structural +#hash+ walks the whole subtree.
|
|
86
|
+
def self.relevant_link_counts(segmenter)
|
|
87
|
+
relevant = Hash.new { |hash, node| hash[node] = relevant_anchor?(segmenter, node) }.compare_by_identity
|
|
88
|
+
Hash.new { |hash, node| hash[node] = count_relevant_links(node, relevant) }.compare_by_identity
|
|
89
|
+
end
|
|
90
|
+
private_class_method :relevant_link_counts
|
|
91
|
+
|
|
84
92
|
# Walk nodes are relevant anchors or their non-link ancestors, so a link counts as one.
|
|
85
|
-
def self.count_relevant_links(
|
|
93
|
+
def self.count_relevant_links(node, relevant)
|
|
86
94
|
return 1 if node.link?
|
|
87
95
|
|
|
88
|
-
node.count_descendants { |child| child.link? &&
|
|
96
|
+
node.count_descendants { |child| child.link? && relevant[child] }
|
|
89
97
|
end
|
|
90
98
|
private_class_method :count_relevant_links
|
|
91
99
|
end
|
|
@@ -4,8 +4,9 @@ module Html2rss
|
|
|
4
4
|
class AutoSource
|
|
5
5
|
class Segmenter
|
|
6
6
|
##
|
|
7
|
-
# Selects the strongest content-like primary link
|
|
8
|
-
# (port of Discovery::SemanticAnchorCandidates).
|
|
7
|
+
# Selects the strongest content-like primary link of a container
|
|
8
|
+
# (port of Discovery::SemanticAnchorCandidates). A card without a qualifying link of its own
|
|
9
|
+
# falls back to the anchor that wraps the whole card.
|
|
9
10
|
class PrimaryLink
|
|
10
11
|
# @param segmenter [Segmenter]
|
|
11
12
|
def initialize(segmenter)
|
|
@@ -32,11 +33,28 @@ module Html2rss
|
|
|
32
33
|
winners[dest] = facts if current.nil? || facts[:score] > current[:score]
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
winners.values.max_by { |f| f[:score] }&.fetch(:anchor)
|
|
36
|
+
winners.values.max_by { |f| f[:score] }&.fetch(:anchor) || wrapping_link(container)
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
private
|
|
39
40
|
|
|
41
|
+
# The nearest ancestor link when it wraps a whole card.
|
|
42
|
+
def wrapping_link(container)
|
|
43
|
+
link = @index.parent_until(@index.parent_of(container), :link?.to_proc)
|
|
44
|
+
return unless link && !@index.ignored_chrome?(link) && card_link?(link)
|
|
45
|
+
|
|
46
|
+
link if candidate_facts(link, container)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A card link holds the card's heading and no other link. A link holding other links is a
|
|
50
|
+
# parser-recovered unclosed anchor spanning a region; one without a heading wraps a fragment
|
|
51
|
+
# of a story (such as one live-blog entry), not a card.
|
|
52
|
+
def card_link?(link)
|
|
53
|
+
(@card_links ||= {}.compare_by_identity).fetch(link) do
|
|
54
|
+
@card_links[link] = link.find(&:heading?) && link.find { |node| node.link? && !node.equal?(link) }.nil?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
40
58
|
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
41
59
|
def candidate_facts(anchor, container)
|
|
42
60
|
destination = @link_resolver.destination_facts(anchor)
|
|
@@ -45,8 +63,8 @@ module Html2rss
|
|
|
45
63
|
text = anchor.visible_text.to_s.strip
|
|
46
64
|
heading = first_heading(container)
|
|
47
65
|
heading_text = heading ? heading.visible_text.to_s.strip : ''
|
|
48
|
-
heading_anchor = heading_anchor?(anchor, heading)
|
|
49
66
|
meaningful = text.match?(/\p{Alnum}/)
|
|
67
|
+
heading_anchor = heading_anchor?(anchor, heading) || (!meaningful && overlay_link?(anchor, heading))
|
|
50
68
|
content_like = destination.content_path
|
|
51
69
|
heading_match = meaningful && heading_text.match?(/\p{Alnum}/) && heading_text == text
|
|
52
70
|
|
|
@@ -73,6 +91,13 @@ module Html2rss
|
|
|
73
91
|
|
|
74
92
|
anchor.equal?(heading) || @index.descendant_of?(anchor, heading)
|
|
75
93
|
end
|
|
94
|
+
|
|
95
|
+
# An empty overlay link beside the heading (or a wrapper of it) stands in for the heading's link.
|
|
96
|
+
def overlay_link?(anchor, heading)
|
|
97
|
+
return false unless heading&.visible_text.to_s.match?(/\p{Alnum}/)
|
|
98
|
+
|
|
99
|
+
@index.descendant_of?(heading, @index.parent_of(anchor))
|
|
100
|
+
end
|
|
76
101
|
end
|
|
77
102
|
end
|
|
78
103
|
end
|
|
@@ -13,16 +13,27 @@ module Html2rss
|
|
|
13
13
|
# @param segmenter [Segmenter]
|
|
14
14
|
# @return [Array<Segment>]
|
|
15
15
|
def self.call(segmenter)
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
picks = linked_containers(segmenter)
|
|
17
|
+
# A link wrapping its container is a whole card: it roots one segment, not one per div inside
|
|
18
|
+
# it, and none when a container already selected that link from inside.
|
|
19
|
+
claimed = picks.filter_map { |_, link, inside| link if inside }.to_set.compare_by_identity
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
picks.each_with_index.filter_map do |(container, link, inside), position|
|
|
22
|
+
root = inside ? container : (link if claimed.add?(link))
|
|
23
|
+
Segment.build(root_node: root, primary_link: link, strategy: :semantic, position:) if root
|
|
24
|
+
end
|
|
25
|
+
end
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
# @return [Array<Array(SST::Node, SST::Node, Boolean)>] container, its primary link, link inside it
|
|
28
|
+
def self.linked_containers(segmenter)
|
|
29
|
+
index = segmenter.index
|
|
30
|
+
primary = PrimaryLink.new(segmenter)
|
|
31
|
+
collect_containers(index).filter_map do |container|
|
|
32
|
+
link = primary.select(container)
|
|
33
|
+
[container, link, index.descendant_of?(link, container)] if link
|
|
24
34
|
end
|
|
25
35
|
end
|
|
36
|
+
private_class_method :linked_containers
|
|
26
37
|
|
|
27
38
|
def self.collect_containers(index)
|
|
28
39
|
non_leaf = mark_non_leaf_candidates(index.root)
|
|
@@ -14,11 +14,10 @@ module Html2rss
|
|
|
14
14
|
# @param base_url [String, Html2rss::Url]
|
|
15
15
|
# @param strategy [Symbol]
|
|
16
16
|
# @param opts [Hash] segmentation options
|
|
17
|
-
# @option opts [Boolean] :permit_unanchored keep containers without a primary link
|
|
18
17
|
# @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
|
|
19
18
|
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
20
19
|
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
21
|
-
# @return [Array<Segment>]
|
|
20
|
+
# @return [Array<Segment>] linked segments only
|
|
22
21
|
# rubocop:disable-next Style/ArgumentsForwarding -- keep named opts for YARD @option
|
|
23
22
|
def self.call(document, base_url:, strategy:, **opts)
|
|
24
23
|
new(document, base_url:, strategy:, **opts).call
|
|
@@ -28,18 +27,15 @@ module Html2rss
|
|
|
28
27
|
# @param base_url [String, Html2rss::Url]
|
|
29
28
|
# @param strategy [Symbol]
|
|
30
29
|
# @param opts [Hash] segmentation options
|
|
31
|
-
# @option opts [Boolean] :permit_unanchored keep containers without a primary link
|
|
32
30
|
# @option opts [Integer] :minimum_selector_frequency list strategy frequency floor
|
|
33
31
|
# @option opts [Integer] :use_top_selectors list strategy selector budget
|
|
34
32
|
# @option opts [Scoring::LinkResolver, nil] :link_resolver page-scoped destination facts cache
|
|
35
33
|
def initialize(document, base_url:, strategy:, **opts)
|
|
36
|
-
raise
|
|
37
|
-
raise ArgumentError, "unknown strategy: #{strategy.inspect}" unless Segment::STRATEGIES.include?(strategy)
|
|
34
|
+
raise Segment::InvalidError, 'document must be SST::Document' unless document.is_a?(SST::Document)
|
|
38
35
|
|
|
39
36
|
@document = document
|
|
40
37
|
@base_url = base_url
|
|
41
|
-
@strategy = strategy
|
|
42
|
-
@permit_unanchored = opts.fetch(:permit_unanchored, false)
|
|
38
|
+
@strategy = Segment.strategy!(strategy)
|
|
43
39
|
@minimum_selector_frequency = opts.fetch(:minimum_selector_frequency, 2)
|
|
44
40
|
@use_top_selectors = opts.fetch(:use_top_selectors, 5)
|
|
45
41
|
@link_resolver = opts[:link_resolver] || Scoring::LinkResolver.new(base_url)
|
|
@@ -55,7 +51,7 @@ module Html2rss
|
|
|
55
51
|
end
|
|
56
52
|
end
|
|
57
53
|
|
|
58
|
-
attr_reader :document, :base_url, :
|
|
54
|
+
attr_reader :document, :base_url, :minimum_selector_frequency,
|
|
59
55
|
:use_top_selectors, :link_resolver, :noise_policy
|
|
60
56
|
|
|
61
57
|
# @return [SST::Index]
|