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
|
@@ -6,7 +6,20 @@ module Html2rss
|
|
|
6
6
|
module FeedBuilder
|
|
7
7
|
##
|
|
8
8
|
# Builds an RSS Feed by providing channel, articles and stylesheets.
|
|
9
|
-
|
|
9
|
+
#
|
|
10
|
+
# A media feed ({ItemPresentation.media_feed?}) also gets the podcast tags clients play from:
|
|
11
|
+
# channel +itunes:image+, +itunes:author+, +itunes:category+ (nesting its subcategories) and
|
|
12
|
+
# +itunes:explicit+ (+true+ or +false+), item +itunes:duration+ (+HH:MM:SS+) and +itunes:image+, each
|
|
13
|
+
# only when known; categories and the explicit flag only when the followed native feed states them.
|
|
14
|
+
#
|
|
15
|
+
# +managingEditor+ carries the channel author only when it is an RSS 2.0 email address
|
|
16
|
+
# (+editor@example.com+ or +editor@example.com (Name)+); a plain name is no managing editor.
|
|
17
|
+
class Rss # rubocop:disable Metrics/ClassLength -- channel, item and podcast projections stay co-located
|
|
18
|
+
# First duration in seconds that +itunes:duration+ cannot spell as +HH:MM:SS+ (100 hours).
|
|
19
|
+
MAX_CLOCK_DURATION = 100 * 3600
|
|
20
|
+
# An RSS 2.0 person: an email address, optionally followed by the name in parentheses.
|
|
21
|
+
EMAIL_PERSON = /\A[^\s@()]+@[^\s@()]+(?: \(.+\))?\z/
|
|
22
|
+
|
|
10
23
|
class << self
|
|
11
24
|
# @param article [Html2rss::Article] source article
|
|
12
25
|
# @param item_maker [RSS::Maker::RSS20::ItemsBase::ItemBase] RSS item builder
|
|
@@ -37,7 +50,7 @@ module Html2rss
|
|
|
37
50
|
item_maker.title = article.title
|
|
38
51
|
item_maker.author = article.author
|
|
39
52
|
description = ItemPresentation.description_for(article)
|
|
40
|
-
item_maker.description = description if description
|
|
53
|
+
item_maker.description = description if description
|
|
41
54
|
item_maker.link = article.url.to_s if article.url
|
|
42
55
|
item_maker.pubDate = article.published_at&.rfc2822
|
|
43
56
|
end
|
|
@@ -60,12 +73,13 @@ module Html2rss
|
|
|
60
73
|
# @param stylesheets [Array<Hash>] An optional array of stylesheet configurations.
|
|
61
74
|
# @param generator [String] required preformatted generator comment (from {Status})
|
|
62
75
|
def initialize(channel:, articles:, generator:, stylesheets: [])
|
|
63
|
-
raise
|
|
76
|
+
raise InvalidOption, 'generator must be a non-blank String' if blank_string?(generator)
|
|
64
77
|
|
|
65
78
|
@channel = channel
|
|
66
79
|
@articles = articles
|
|
67
80
|
@stylesheets = stylesheets
|
|
68
81
|
@generator = generator
|
|
82
|
+
@media_feed = ItemPresentation.media_feed?(articles)
|
|
69
83
|
end
|
|
70
84
|
|
|
71
85
|
# @return [RSS::Rss] RSS 2.0 document instance
|
|
@@ -80,7 +94,7 @@ module Html2rss
|
|
|
80
94
|
|
|
81
95
|
private
|
|
82
96
|
|
|
83
|
-
attr_reader :channel, :articles, :generator
|
|
97
|
+
attr_reader :channel, :articles, :generator, :media_feed
|
|
84
98
|
|
|
85
99
|
def blank_string?(value)
|
|
86
100
|
!value.is_a?(String) || value.strip.empty?
|
|
@@ -98,13 +112,25 @@ module Html2rss
|
|
|
98
112
|
channel_maker.description = channel.description
|
|
99
113
|
channel_maker.ttl = channel.ttl
|
|
100
114
|
|
|
101
|
-
channel_maker.managingEditor = channel.author if channel.author
|
|
102
|
-
channel_maker.author = channel.author if channel.author
|
|
115
|
+
channel_maker.managingEditor = channel.author if EMAIL_PERSON.match?(channel.author)
|
|
103
116
|
channel_maker.link = channel.url.to_s
|
|
104
117
|
channel_maker.generator = generator
|
|
105
118
|
channel_maker.updated = channel.last_build_date
|
|
106
119
|
|
|
107
120
|
make_image(maker.image) if channel.image
|
|
121
|
+
make_podcast_channel(channel_maker) if media_feed
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def make_podcast_channel(channel_maker)
|
|
125
|
+
channel_maker.itunes_image = channel.image
|
|
126
|
+
channel_maker.itunes_author = channel.author
|
|
127
|
+
channel_maker.itunes_explicit = channel.explicit&.to_s
|
|
128
|
+
channel.categories.each { add_itunes_category(channel_maker, *_1) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def add_itunes_category(channel_maker, name, *subcategories)
|
|
132
|
+
category = channel_maker.new_itunes_category(name)
|
|
133
|
+
subcategories.each { category.new_category.text = _1 }
|
|
108
134
|
end
|
|
109
135
|
|
|
110
136
|
def make_image(image_maker)
|
|
@@ -114,9 +140,26 @@ module Html2rss
|
|
|
114
140
|
|
|
115
141
|
def make_items(maker)
|
|
116
142
|
articles.each do |article|
|
|
117
|
-
maker.items.new_item
|
|
143
|
+
maker.items.new_item do |item_maker|
|
|
144
|
+
self.class.add_item(article, item_maker)
|
|
145
|
+
make_podcast_item(article, item_maker) if media_feed
|
|
146
|
+
end
|
|
118
147
|
end
|
|
119
148
|
end
|
|
149
|
+
|
|
150
|
+
def make_podcast_item(article, item_maker)
|
|
151
|
+
duration = ItemPresentation.rss_enclosure_for(article)&.duration
|
|
152
|
+
item_maker.itunes_duration = format_duration(duration) if duration
|
|
153
|
+
item_maker.itunes_image = article.image
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# +HH:MM:SS+; plain seconds from 100 hours on, which +HH+ cannot spell.
|
|
157
|
+
def format_duration(seconds)
|
|
158
|
+
return seconds.to_s if seconds >= MAX_CLOCK_DURATION
|
|
159
|
+
|
|
160
|
+
format('%<hours>02d:%<minutes>02d:%<seconds>02d',
|
|
161
|
+
hours: seconds / 3600, minutes: seconds % 3600 / 60, seconds: seconds % 60)
|
|
162
|
+
end
|
|
120
163
|
end
|
|
121
164
|
end
|
|
122
165
|
end
|
|
@@ -8,7 +8,8 @@ module Html2rss
|
|
|
8
8
|
# FeedResult; +feed_url+ is a render-time JSON Feed-only option.
|
|
9
9
|
#
|
|
10
10
|
# Channel field projection:
|
|
11
|
-
# - Rss: language, title, description, ttl, link, updated
|
|
11
|
+
# - Rss: language, title, description, ttl, link, updated, managingEditor (author in email form only)
|
|
12
|
+
# (+ itunes:image, itunes:author, itunes:category, itunes:explicit on a media feed)
|
|
12
13
|
# - JsonFeed: title, home_page_url, description, language, icon, authors (+ feed_url)
|
|
13
14
|
module FeedBuilder
|
|
14
15
|
end
|
|
@@ -11,7 +11,7 @@ Use `:auto` when you want `default` (HTTPX) first and a browser-backed hop only
|
|
|
11
11
|
1. **Default (HTTPX)** — plain HTTP (faster, cheaper).
|
|
12
12
|
2. **Botasaurus** — attempted when default raises a fallback-eligible error (for example `BlockedSurfaceDetected` or timeout) or extracts zero feed items.
|
|
13
13
|
|
|
14
|
-
Before escalating default → Botasaurus on a **weak** auto-source extract (empty / below floor / high-entropy / app-shell / unsupported — not blocked), `FeedResolution` may probe up to five same-origin listing or native-feed candidates and rewrite the effective scrape URL via {ScrapeTarget}. Botasaurus then uses the resolved URL. Direct RSS/Atom entry URLs are parsed via `Syndication::Parser` without HTML AutoSource.
|
|
14
|
+
Before escalating default → Botasaurus on a **weak** auto-source extract (empty / below floor / high-entropy / app-shell / unsupported — not blocked), `FeedResolution` may probe up to five same-origin listing or native-feed candidates and rewrite the effective scrape URL via {ScrapeTarget}. Botasaurus then uses the resolved URL. Direct RSS/Atom/RDF entry URLs are parsed via `Syndication::Parser` without HTML AutoSource.
|
|
15
15
|
|
|
16
16
|
There is no Browserless / Puppeteer-in-gem tier. Pin `botasaurus` when you want browser rendering without default first. Botasaurus needs `BOTASAURUS_SCRAPER_URL`.
|
|
17
17
|
|
|
@@ -83,7 +83,7 @@ module Html2rss
|
|
|
83
83
|
# @param elapsed_ms [Integer, nil] monotonic hop duration in milliseconds
|
|
84
84
|
# @return [void]
|
|
85
85
|
def record_error(strategy:, error:, elapsed_ms: nil)
|
|
86
|
-
meta = error.is_a?(
|
|
86
|
+
meta = error.transport_meta if error.is_a?(Error)
|
|
87
87
|
append_attempt(strategy:, error_class: error.class.name, transport_meta: meta, elapsed_ms:)
|
|
88
88
|
@last_error = error
|
|
89
89
|
return unless TERMINAL_TRANSPORT_ERRORS.any? { |klass| error.is_a?(klass) }
|
|
@@ -108,26 +108,15 @@ module Html2rss
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
# @param response [RequestService::Response] successful response
|
|
111
|
-
# @param
|
|
112
|
-
# @param dedup_dropped [Integer] articles removed by deduplication
|
|
111
|
+
# @param extraction [Html2rss::FeedPipeline::Extraction] its articles, drops and feed channel facts
|
|
113
112
|
# @param selected_strategy [Symbol] concrete strategy that produced items
|
|
114
113
|
# @param attempt_count [Integer] number of attempts recorded for this chain
|
|
115
|
-
# @param admission_drops [Hash{String => Integer}] Cleanup drop tallies
|
|
116
114
|
# @param scrape_target [Html2rss::ScrapeTarget]
|
|
117
115
|
# @return [void]
|
|
118
|
-
|
|
119
|
-
def succeed!(response:, articles:, dedup_dropped:, selected_strategy:, attempt_count:,
|
|
120
|
-
scrape_target:, admission_drops: {})
|
|
116
|
+
def succeed!(response:, extraction:, selected_strategy:, attempt_count:, scrape_target:)
|
|
121
117
|
@result = PipelineOutcome.new(
|
|
122
|
-
response:,
|
|
123
|
-
|
|
124
|
-
dedup_dropped:,
|
|
125
|
-
selected_strategy:,
|
|
126
|
-
attempt_count:,
|
|
127
|
-
strategy_attempts: attempts,
|
|
128
|
-
admission_drops:,
|
|
129
|
-
scrape_target:,
|
|
130
|
-
entry_resolution:
|
|
118
|
+
response:, selected_strategy:, attempt_count:, strategy_attempts: attempts,
|
|
119
|
+
scrape_target:, entry_resolution:, **extraction.to_h
|
|
131
120
|
)
|
|
132
121
|
end
|
|
133
122
|
|
|
@@ -137,7 +126,7 @@ module Html2rss
|
|
|
137
126
|
# @raise [Html2rss::Error, StandardError]
|
|
138
127
|
def raise_failure!
|
|
139
128
|
if (error = failure_error)
|
|
140
|
-
error.attempts = attempts if error.is_a?(
|
|
129
|
+
error.attempts = attempts if error.is_a?(Error)
|
|
141
130
|
raise error
|
|
142
131
|
end
|
|
143
132
|
|
|
@@ -215,18 +204,18 @@ module Html2rss
|
|
|
215
204
|
rescue *NON_FALLBACK_ERRORS => error
|
|
216
205
|
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
217
206
|
state.abort!
|
|
218
|
-
rescue
|
|
207
|
+
rescue Html2rss::ContractError
|
|
208
|
+
raise
|
|
209
|
+
rescue Html2rss::Error => error
|
|
219
210
|
state.record_error(strategy:, error:, elapsed_ms: monotonic_ms - started_ms)
|
|
220
211
|
log_fallback_error(strategy:, next_strategy:, error:, request_session:) if next_strategy && request_session
|
|
221
212
|
end
|
|
222
213
|
|
|
223
214
|
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists -- extract + resolution delegate
|
|
224
215
|
def process_response(response:, strategy:, next_strategy:, request_session:, state:, elapsed_ms: nil)
|
|
225
|
-
|
|
226
|
-
config:, response:, request_session:
|
|
227
|
-
)
|
|
216
|
+
extraction = pipeline.extract(config:, response:, request_session:)
|
|
228
217
|
state.remember_response(response)
|
|
229
|
-
items_count = articles.size
|
|
218
|
+
items_count = extraction.articles.size
|
|
230
219
|
state.record_items(strategy:, items_count:, transport_meta: response.transport_meta, elapsed_ms:)
|
|
231
220
|
Log.debug("#{self.class}: strategy=#{strategy} items=#{items_count} " \
|
|
232
221
|
"host=#{response.url.host} elapsed=#{format('%.3f', budget.elapsed_seconds)}s " \
|
|
@@ -234,16 +223,13 @@ module Html2rss
|
|
|
234
223
|
|
|
235
224
|
if (outcome = FeedResolution.try_apply!(
|
|
236
225
|
pipeline:, config:, response:, session: request_session, strategy:, resources:,
|
|
237
|
-
articles
|
|
226
|
+
articles: extraction.articles, scrape_target:, state:, budget:
|
|
238
227
|
))
|
|
239
228
|
@scrape_target = outcome.scrape_target
|
|
240
229
|
return
|
|
241
230
|
end
|
|
242
231
|
|
|
243
|
-
if items_count.positive?
|
|
244
|
-
return record_success(response:, strategy:, articles:, dedup_dropped:, admission_drops:,
|
|
245
|
-
state:)
|
|
246
|
-
end
|
|
232
|
+
return record_success(response:, strategy:, extraction:, state:) if items_count.positive?
|
|
247
233
|
|
|
248
234
|
if DETERMINISTIC_HTTP_STATUSES.member?(response&.status)
|
|
249
235
|
state.abort!
|
|
@@ -254,14 +240,10 @@ module Html2rss
|
|
|
254
240
|
log_info_fallback_zero_items(strategy:, next_strategy:, response:) if next_strategy
|
|
255
241
|
end
|
|
256
242
|
|
|
257
|
-
# rubocop:disable-next Metrics/
|
|
258
|
-
def record_success(response:, strategy:,
|
|
243
|
+
# rubocop:disable-next Metrics/MethodLength -- success + structured selected log
|
|
244
|
+
def record_success(response:, strategy:, extraction:, state:)
|
|
259
245
|
attempt_count = state.attempts.size
|
|
260
|
-
state.succeed!(
|
|
261
|
-
response:, articles:, dedup_dropped:, selected_strategy: strategy,
|
|
262
|
-
attempt_count:, admission_drops:,
|
|
263
|
-
scrape_target:
|
|
264
|
-
)
|
|
246
|
+
state.succeed!(response:, extraction:, selected_strategy: strategy, attempt_count:, scrape_target:)
|
|
265
247
|
return unless attempt_count > 1
|
|
266
248
|
|
|
267
249
|
Log.info({
|
|
@@ -33,13 +33,13 @@ module Html2rss
|
|
|
33
33
|
##
|
|
34
34
|
# @param name [Symbol, String] config or configure strategy value
|
|
35
35
|
# @return [Auto, Concrete] resolved feed-level plan
|
|
36
|
-
# @raise [
|
|
36
|
+
# @raise [InvalidOption] when +name+ is neither +:auto+ nor a registered strategy
|
|
37
37
|
def resolve(name)
|
|
38
38
|
normalized = name.to_sym
|
|
39
39
|
return Auto.new if normalized == AUTO_NAME
|
|
40
40
|
|
|
41
41
|
unless RequestService.strategy_registered?(normalized)
|
|
42
|
-
raise
|
|
42
|
+
raise InvalidOption, "unknown strategy plan: #{name.inspect}"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
Concrete.new(strategy: normalized)
|
|
@@ -64,7 +64,7 @@ module Html2rss
|
|
|
64
64
|
|
|
65
65
|
resolve(name)
|
|
66
66
|
true
|
|
67
|
-
rescue
|
|
67
|
+
rescue InvalidOption
|
|
68
68
|
false
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -10,11 +10,19 @@ module Html2rss
|
|
|
10
10
|
# selected_strategy: set on :auto success; nil otherwise.
|
|
11
11
|
# attempt_count: auto attempts attempted; 0 outside :auto.
|
|
12
12
|
# strategy_attempts: auto attempt hashes (with optional transport_meta); empty outside :auto.
|
|
13
|
+
# feed_channel: channel facts of the native feed followed ({Syndication::ChannelFacts}); nil without one.
|
|
13
14
|
PipelineOutcome = Data.define(
|
|
14
15
|
:response, :articles, :dedup_dropped, :selected_strategy, :attempt_count, :strategy_attempts,
|
|
15
|
-
:admission_drops, :scrape_target, :entry_resolution
|
|
16
|
+
:admission_drops, :scrape_target, :entry_resolution, :feed_channel
|
|
16
17
|
)
|
|
17
18
|
|
|
19
|
+
# What one response yields after extraction and dedup ({#extract}); its fields are the
|
|
20
|
+
# {PipelineOutcome} fields of the same names.
|
|
21
|
+
# articles: unique articles. dedup_dropped: articles removed by deduplication.
|
|
22
|
+
# admission_drops: Cleanup drop tallies. feed_channel: the followed native feed's channel facts
|
|
23
|
+
# ({Syndication::ChannelFacts}); nil without one.
|
|
24
|
+
Extraction = Data.define(:articles, :dedup_dropped, :admission_drops, :feed_channel)
|
|
25
|
+
|
|
18
26
|
##
|
|
19
27
|
# @param raw_config [Hash{Symbol => Object}] user-provided feed config
|
|
20
28
|
def initialize(raw_config)
|
|
@@ -71,11 +79,11 @@ module Html2rss
|
|
|
71
79
|
# @param config [Html2rss::Config]
|
|
72
80
|
# @param response [Html2rss::RequestService::Response]
|
|
73
81
|
# @param request_session [Html2rss::RequestSession]
|
|
74
|
-
# @return [
|
|
75
|
-
def
|
|
76
|
-
collected, admission_drops = collect_articles(config:, response:, request_session:)
|
|
82
|
+
# @return [Extraction] unique articles with their dedup and admission drops and feed channel facts
|
|
83
|
+
def extract(config:, response:, request_session:)
|
|
84
|
+
collected, admission_drops, feed_channel = collect_articles(config:, response:, request_session:)
|
|
77
85
|
unique = Article::Deduplicator.new(collected).call
|
|
78
|
-
|
|
86
|
+
Extraction.new(articles: unique, dedup_dropped: collected.size - unique.size, admission_drops:, feed_channel:)
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
private
|
|
@@ -95,13 +103,12 @@ module Html2rss
|
|
|
95
103
|
def run_pipeline_for_strategy(config, strategy:, resources:)
|
|
96
104
|
request_session = request_session_for(config, strategy:, resources:)
|
|
97
105
|
response = request_session.fetch_initial_response
|
|
98
|
-
|
|
99
|
-
raise_empty_auto_source!(strategy:, response:) if config.auto_source && articles.empty?
|
|
106
|
+
extraction = extract(config:, response:, request_session:)
|
|
107
|
+
raise_empty_auto_source!(strategy:, response:) if config.auto_source && extraction.articles.empty?
|
|
100
108
|
|
|
101
109
|
PipelineOutcome.new(
|
|
102
|
-
response:,
|
|
103
|
-
|
|
104
|
-
scrape_target: ScrapeTarget.from_config(config), entry_resolution: nil
|
|
110
|
+
response:, selected_strategy: nil, attempt_count: 0, strategy_attempts: [],
|
|
111
|
+
scrape_target: ScrapeTarget.from_config(config), entry_resolution: nil, **extraction.to_h
|
|
105
112
|
)
|
|
106
113
|
end
|
|
107
114
|
|
|
@@ -127,49 +134,55 @@ module Html2rss
|
|
|
127
134
|
end
|
|
128
135
|
|
|
129
136
|
def collect_articles(config:, response:, request_session:)
|
|
130
|
-
selector = selector_articles(config:, response:, request_session:)
|
|
131
|
-
auto,
|
|
132
|
-
[selector + auto,
|
|
137
|
+
selector, selector_drops = selector_articles(config:, response:, request_session:)
|
|
138
|
+
auto, auto_drops, feed_channel = auto_source_articles(config:, response:, request_session:)
|
|
139
|
+
[selector + auto, selector_drops.merge(auto_drops) { |_reason, left, right| left + right }, feed_channel]
|
|
133
140
|
end
|
|
134
141
|
|
|
135
|
-
#
|
|
142
|
+
# @return [Array(Array<Html2rss::Article>, Hash{String => Integer})] articles and unlinked-item drop tallies
|
|
136
143
|
def selector_articles(config:, response:, request_session:)
|
|
137
|
-
return [] unless (selectors = config.selectors)
|
|
138
|
-
return [] if response.feed_response?
|
|
144
|
+
return [[], {}] unless (selectors = config.selectors)
|
|
145
|
+
return [[], {}] if response.feed_response?
|
|
139
146
|
|
|
140
147
|
page_responses = request_session.page_responses(
|
|
141
148
|
response,
|
|
142
149
|
pagination_config: selectors.dig(:items, :pagination)
|
|
143
150
|
)
|
|
151
|
+
engines = page_responses.lazy.map { |page| Selectors.new(page, selectors:, time_zone: config.time_zone) }
|
|
152
|
+
collect_selector_pages(engines)
|
|
153
|
+
end
|
|
144
154
|
|
|
155
|
+
# Stops at the first empty page after articles were found; drop tallies sum over the pages read.
|
|
156
|
+
def collect_selector_pages(engines)
|
|
145
157
|
articles = []
|
|
146
|
-
|
|
147
|
-
|
|
158
|
+
drops = Hash.new(0)
|
|
159
|
+
engines.each do |engine|
|
|
160
|
+
page_articles = engine.articles
|
|
161
|
+
engine.admission_drops.each { |reason, count| drops[reason] += count }
|
|
148
162
|
break if page_articles.empty? && articles.any?
|
|
149
163
|
|
|
150
164
|
articles.concat(page_articles)
|
|
151
165
|
end
|
|
152
|
-
articles
|
|
166
|
+
[articles, drops]
|
|
153
167
|
end
|
|
154
168
|
|
|
155
|
-
# @return [Array(Array<Html2rss::Article>, Hash{String => Integer})]
|
|
169
|
+
# @return [Array(Array<Html2rss::Article>, Hash{String => Integer}, Html2rss::Syndication::ChannelFacts)]
|
|
156
170
|
def auto_source_articles(config:, response:, request_session:)
|
|
157
|
-
return [[], {}] unless (auto_source = config.auto_source)
|
|
171
|
+
return [[], {}, nil] unless (auto_source = config.auto_source)
|
|
158
172
|
return syndication_articles(response) if response.feed_response?
|
|
159
173
|
|
|
160
174
|
source = AutoSource.new(response, auto_source, request_session:)
|
|
161
|
-
[source.articles, source.admission_drops]
|
|
175
|
+
[source.articles, source.admission_drops, source.feed_channel]
|
|
162
176
|
end
|
|
163
177
|
|
|
164
178
|
def syndication_articles(response)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
[articles, {}]
|
|
179
|
+
feed = Syndication::Parser.parse_response(response)
|
|
180
|
+
articles = feed.articles.map { |hash| Article.new(**hash, scraper: AutoSource::Scraper::NativeFeed) }
|
|
181
|
+
[articles, {}, feed.channel]
|
|
169
182
|
end
|
|
170
183
|
|
|
171
184
|
def feed_result_for(config, outcome) # rubocop:disable Metrics/MethodLength
|
|
172
|
-
channel = Channel.from_response(outcome.response, overrides: config.channel)
|
|
185
|
+
channel = Channel.from_response(outcome.response, overrides: config.channel, feed: outcome.feed_channel)
|
|
173
186
|
status = Status.build(
|
|
174
187
|
articles: outcome.articles,
|
|
175
188
|
dedup_dropped: outcome.dedup_dropped,
|
|
@@ -24,7 +24,7 @@ tournament on item count (feed-as-means) — intentional.
|
|
|
24
24
|
|
|
25
25
|
### CandidateGenerator mix
|
|
26
26
|
|
|
27
|
-
- **Feed slot:** first `Syndication::Discovery.candidate_urls`
|
|
27
|
+
- **Feed slot:** first same-domain `Syndication::Discovery.candidate_urls` entry. Alternates precede path guesses, except audio-hinted ones on a page without podcast signals. An alternate on another domain is skipped.
|
|
28
28
|
- **Listing fill** (cap = `max - 1` when `max > 1`; `max == 1` → feed only): nav (`taxonomy_path +3`, `content_path +1`) → segment first-wins (`:list` → `:cluster` → `:semantic`, ≥2 same-origin primary links) → `LISTING_PATHS`.
|
|
29
29
|
- Same-page / same-registrable-domain filters; feed never pads into listing slots.
|
|
30
30
|
|
|
@@ -46,14 +46,12 @@ module Html2rss
|
|
|
46
46
|
|
|
47
47
|
def listing_cap = max > 1 ? max - 1 : 0
|
|
48
48
|
|
|
49
|
+
# First eligible Discovery candidate: an alternate on another domain (Discovery ranks
|
|
50
|
+
# audio-hinted ones first on a podcast page) must not displace a same-domain feed or path guess.
|
|
49
51
|
def feed_slot
|
|
50
52
|
return unless parsed_html
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
return unless url
|
|
54
|
-
return unless eligible_url?(url)
|
|
55
|
-
|
|
56
|
-
url
|
|
54
|
+
Syndication::Discovery.candidate_urls(page_url: entry_url, parsed_body: parsed_html).find { eligible_url?(_1) }
|
|
57
55
|
end
|
|
58
56
|
|
|
59
57
|
def listing_urls
|
|
@@ -86,6 +84,8 @@ module Html2rss
|
|
|
86
84
|
return [] unless parsed_html
|
|
87
85
|
|
|
88
86
|
sst = SST::Normalizer.call(parsed_html)
|
|
87
|
+
return [] unless sst
|
|
88
|
+
|
|
89
89
|
link_resolver = Scoring::LinkResolver.new(entry_url)
|
|
90
90
|
|
|
91
91
|
SEGMENT_STRATEGIES.each do |strategy|
|
|
@@ -93,8 +93,6 @@ module Html2rss
|
|
|
93
93
|
return urls if urls.size >= 2
|
|
94
94
|
end
|
|
95
95
|
[]
|
|
96
|
-
rescue StandardError
|
|
97
|
-
[]
|
|
98
96
|
end
|
|
99
97
|
|
|
100
98
|
def segment_urls_for(sst, strategy, link_resolver)
|
|
@@ -120,7 +118,7 @@ module Html2rss
|
|
|
120
118
|
return if href.nil? || href.empty? || href.start_with?('#', 'mailto:', 'javascript:')
|
|
121
119
|
|
|
122
120
|
Html2rss::Url.from_relative(href, entry_url)
|
|
123
|
-
rescue
|
|
121
|
+
rescue Html2rss::Url::InvalidUrlError
|
|
124
122
|
nil
|
|
125
123
|
end
|
|
126
124
|
|
|
@@ -8,15 +8,11 @@ module Html2rss
|
|
|
8
8
|
# Typed +auto_source.entry_resolution+ options (one expansion from config Hash).
|
|
9
9
|
class Options
|
|
10
10
|
##
|
|
11
|
-
# @param auto_source [Hash, nil]
|
|
11
|
+
# @param auto_source [Hash, nil] validated +auto_source+ config
|
|
12
12
|
# @return [Options]
|
|
13
13
|
def self.from_auto_source(auto_source)
|
|
14
|
-
raw = auto_source
|
|
15
|
-
raw
|
|
16
|
-
new(
|
|
17
|
-
enabled: raw.fetch(:enabled, FeedResolution::DEFAULT_CONFIG[:enabled]) != false,
|
|
18
|
-
max_probes: Integer(raw.fetch(:max_probes, FeedResolution::DEFAULT_CONFIG[:max_probes]))
|
|
19
|
-
)
|
|
14
|
+
raw = auto_source&.dig(:entry_resolution) || {}
|
|
15
|
+
new(enabled: raw.fetch(:enabled, true), max_probes: raw.fetch(:max_probes, DEFAULT_CONFIG[:max_probes]))
|
|
20
16
|
end
|
|
21
17
|
|
|
22
18
|
##
|
|
@@ -8,40 +8,36 @@ module Html2rss
|
|
|
8
8
|
# Minimum admitted articles before the entry URL is considered strong enough.
|
|
9
9
|
ARTICLE_FLOOR = 3
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
|
|
41
|
-
native * 2 >= articles.size
|
|
11
|
+
class << self
|
|
12
|
+
##
|
|
13
|
+
# @param config [Html2rss::Config]
|
|
14
|
+
# @param articles [Array<Html2rss::Article>] typed extract results (count derived)
|
|
15
|
+
# @param surface_category [Html2rss::SurfaceCategory, Symbol]
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def resolve?(config:, articles:, surface_category:)
|
|
18
|
+
raise InvalidError, 'articles must be an Array' unless articles.is_a?(Array)
|
|
19
|
+
return false unless eligible_config?(config)
|
|
20
|
+
|
|
21
|
+
category = SurfaceCategory[surface_category]
|
|
22
|
+
return false if category.blocked?
|
|
23
|
+
|
|
24
|
+
articles.size < ARTICLE_FLOOR || category.weak? || native_feed_majority?(articles)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def eligible_config?(config)
|
|
30
|
+
return false unless config.auto_source
|
|
31
|
+
return false if config.selectors
|
|
32
|
+
|
|
33
|
+
Options.from_auto_source(config.auto_source).enabled?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def native_feed_majority?(articles)
|
|
37
|
+
native = articles.count { |article| article.scraper == AutoSource::Scraper::NativeFeed }
|
|
38
|
+
native * 2 >= articles.size
|
|
39
|
+
end
|
|
42
40
|
end
|
|
43
|
-
module_function :native_feed_majority?
|
|
44
|
-
private_class_method :native_feed_majority?
|
|
45
41
|
end
|
|
46
42
|
end
|
|
47
43
|
end
|
|
@@ -16,7 +16,7 @@ module Html2rss
|
|
|
16
16
|
# @param origin_url [String, Html2rss::Url]
|
|
17
17
|
def initialize(request_session:, origin_url:)
|
|
18
18
|
@request_session = request_session
|
|
19
|
-
@origin_url =
|
|
19
|
+
@origin_url = Url.from_absolute(origin_url)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
##
|
|
@@ -28,7 +28,9 @@ module Html2rss
|
|
|
28
28
|
relation: :entry_resolution,
|
|
29
29
|
origin_url:
|
|
30
30
|
)
|
|
31
|
-
score_response(
|
|
31
|
+
score_response(response)
|
|
32
|
+
rescue Html2rss::ContractError
|
|
33
|
+
raise
|
|
32
34
|
rescue Html2rss::Error
|
|
33
35
|
nil
|
|
34
36
|
end
|
|
@@ -37,9 +39,9 @@ module Html2rss
|
|
|
37
39
|
|
|
38
40
|
attr_reader :request_session, :origin_url
|
|
39
41
|
|
|
40
|
-
def score_response(
|
|
42
|
+
def score_response(response) # rubocop:disable Metrics/MethodLength -- feed vs HTML branches
|
|
41
43
|
if response.feed_response?
|
|
42
|
-
count = Syndication::Parser.parse_response(response).size
|
|
44
|
+
count = Syndication::Parser.parse_response(response).articles.size
|
|
43
45
|
return Scored.new(
|
|
44
46
|
url: response.url,
|
|
45
47
|
score: Scorer.score_feed(articles_count: count),
|
|
@@ -11,38 +11,38 @@ module Html2rss
|
|
|
11
11
|
# Bonus when the surface looks like a listing rather than a hub/shell.
|
|
12
12
|
LISTING_SURFACE_BONUS = 5
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
class << self
|
|
15
|
+
##
|
|
16
|
+
# @param articles_count [Integer]
|
|
17
|
+
# @return [Integer]
|
|
18
|
+
def score_feed(articles_count:)
|
|
19
|
+
articles_count * ARTICLE_WEIGHT
|
|
20
|
+
end
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
score = assessment.articles_count * ARTICLE_WEIGHT
|
|
28
|
-
score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
|
|
29
|
-
drops = assessment.admission_drops.values.sum
|
|
30
|
-
total = assessment.articles_count + drops
|
|
31
|
-
score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
|
|
32
|
-
score
|
|
33
|
-
end
|
|
22
|
+
##
|
|
23
|
+
# @param assessment [Html2rss::PageRecon::Assessment]
|
|
24
|
+
# @return [Integer]
|
|
25
|
+
def score_assessment(assessment)
|
|
26
|
+
score = assessment.articles_count * ARTICLE_WEIGHT
|
|
27
|
+
score += LISTING_SURFACE_BONUS if assessment.listing_bonus?
|
|
28
|
+
drops = assessment.admission_drops.values.sum
|
|
29
|
+
total = assessment.articles_count + drops
|
|
30
|
+
score -= ((drops.to_f / total) * ARTICLE_WEIGHT).round if total.positive?
|
|
31
|
+
score
|
|
32
|
+
end
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
34
|
+
##
|
|
35
|
+
# @param scored [Array<Probe::Scored>]
|
|
36
|
+
# @param entry_articles_count [Integer]
|
|
37
|
+
# @return [Probe::Scored, nil]
|
|
38
|
+
def pick_winner(scored:, entry_articles_count:)
|
|
39
|
+
winner = scored.max_by(&:score)
|
|
40
|
+
return unless winner
|
|
41
|
+
return if winner.articles_count <= entry_articles_count.to_i
|
|
42
|
+
return if winner.score <= 0
|
|
44
43
|
|
|
45
|
-
|
|
44
|
+
winner
|
|
45
|
+
end
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
end
|