fetch_util 0.3.1 → 0.3.2
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/CHANGELOG.md +25 -0
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +187 -104
- data/lib/fetch_util/browser/interaction_helpers/dom_interaction.rb +14 -2
- data/lib/fetch_util/browser/navigation/headers_and_readiness.rb +0 -7
- data/lib/fetch_util/browser/navigation/navigator_patch.rb +3 -1
- data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +28 -27
- data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +25 -32
- data/lib/fetch_util/browser/stabilization/page_flow.rb +93 -11
- data/lib/fetch_util/browser.rb +55 -16
- data/lib/fetch_util/extractor.rb +27 -3
- data/lib/fetch_util/fetcher.rb +270 -144
- data/lib/fetch_util/parallel_fetcher.rb +15 -20
- data/lib/fetch_util/raw_docs_fallback.rb +8 -21
- data/lib/fetch_util/regulatory/http_client.rb +111 -43
- data/lib/fetch_util/version.rb +1 -1
- data/lib/fetch_util.rb +12 -2
- metadata +2 -2
data/lib/fetch_util/fetcher.rb
CHANGED
|
@@ -49,9 +49,9 @@ module FetchUtil
|
|
|
49
49
|
"inciso|subsection",
|
|
50
50
|
Regexp::IGNORECASE
|
|
51
51
|
).freeze
|
|
52
|
-
|
|
52
|
+
TRACKING_QUERY_PARAM_PATTERNS = [
|
|
53
53
|
/\A(?:__goaway_|__cf_chl_)/,
|
|
54
|
-
/\A(?:utm_[a-z]+|fbclid|gclid|mc_cid|mc_eid)\z
|
|
54
|
+
/\A(?:utm_[a-z]+|fbclid|gclid|lp|mc_cid|mc_eid|ref|source)\z/i,
|
|
55
55
|
/\A__gr(?:sc|ts|ua|rn)\z/
|
|
56
56
|
].freeze
|
|
57
57
|
TITLE_SLUG_STOPWORDS = %w[
|
|
@@ -69,11 +69,90 @@ module FetchUtil
|
|
|
69
69
|
SECOND_LEVEL_COUNTRY_TLDS = /\A(co|com|org|net|gov|edu|ac)\z/
|
|
70
70
|
GOOGLE_HOST_PATTERN = /\Agoogle\.[a-z.]+\z/
|
|
71
71
|
NETWORK_ERROR_PATTERN = Regexp.new(
|
|
72
|
-
"\\b(?:net::ERR_|ERR_NAME_NOT_RESOLVED|DNS|resolve|resolution|ENOTFOUND|" \
|
|
72
|
+
"\\b(?:net::ERR_|ERR_NAME_NOT_RESOLVED|pending connections|DNS|resolve|resolution|ENOTFOUND|" \
|
|
73
73
|
"EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out|timeout|" \
|
|
74
74
|
"connection (?:refused|reset|closed)|disconnected|network)\\b",
|
|
75
75
|
Regexp::IGNORECASE
|
|
76
76
|
).freeze
|
|
77
|
+
PENDING_CONNECTIONS_FETCH_RETRIES = 1
|
|
78
|
+
PENDING_CONNECTIONS_FETCH_RETRY_WAIT = 1.0
|
|
79
|
+
|
|
80
|
+
class PayloadSnapshot
|
|
81
|
+
attr_reader :payload, :requested_url, :final_url, :canonical_url, :raw_final_url, :raw_canonical_url,
|
|
82
|
+
:markdown, :normalized_markdown, :content_downcase, :context, :context_downcase,
|
|
83
|
+
:context_with_excerpt_downcase, :first_20_context,
|
|
84
|
+
:first_80_context, :plain_list_item_count, :linked_heading_count, :linked_item_count,
|
|
85
|
+
:markdown_link_count, :prose_lines, :long_prose_line_count_one_hundred,
|
|
86
|
+
:long_prose_line_count_one_twenty, :long_prose_line_count_one_forty, :list_link_count,
|
|
87
|
+
:table_row_count, :heading_count, :common_tokens, :resource_urls, :raw_resource_urls,
|
|
88
|
+
:resource_url_facets
|
|
89
|
+
|
|
90
|
+
def initialize(payload:, requested_url:, final_url:, canonical_url:, raw_final_url:, raw_canonical_url:)
|
|
91
|
+
@payload = payload
|
|
92
|
+
@requested_url = requested_url
|
|
93
|
+
@final_url = final_url
|
|
94
|
+
@canonical_url = canonical_url
|
|
95
|
+
@raw_final_url = raw_final_url
|
|
96
|
+
@raw_canonical_url = raw_canonical_url
|
|
97
|
+
@markdown = payload["markdown"].to_s
|
|
98
|
+
@normalized_markdown = FetchUtil.normalize_whitespace(@markdown)
|
|
99
|
+
@content_downcase = FetchUtil.normalize_whitespace([payload["title"], @markdown].compact.join(" ")).downcase
|
|
100
|
+
@context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], @markdown].join(" "))
|
|
101
|
+
@context_downcase = @context.downcase
|
|
102
|
+
@context_with_excerpt_downcase = FetchUtil.normalize_whitespace(
|
|
103
|
+
[payload["title"], @markdown, payload["excerpt"]].compact.join(" ")
|
|
104
|
+
).downcase
|
|
105
|
+
@first_20_context = first_lines_context(20)
|
|
106
|
+
@first_80_context = first_lines_context(80)
|
|
107
|
+
@plain_list_item_count = @markdown.lines.grep(/^\s*(?:\d+\.\s+|[-*]\s+)/).count
|
|
108
|
+
@linked_heading_count = @markdown.scan(Fetcher::LINKED_MARKDOWN_HEADING_PATTERN).count
|
|
109
|
+
@linked_item_count = @markdown.scan(Fetcher::LINKED_MARKDOWN_ITEM_PATTERN).count
|
|
110
|
+
@markdown_link_count = @markdown.scan(%r{\]\(https?://}).count
|
|
111
|
+
@prose_lines = @markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
|
|
112
|
+
@long_prose_line_count_one_hundred = long_prose_line_count(100)
|
|
113
|
+
@long_prose_line_count_one_twenty = long_prose_line_count(120)
|
|
114
|
+
@long_prose_line_count_one_forty = long_prose_line_count(140)
|
|
115
|
+
@list_link_count = @markdown.lines.grep(/^\s*- \[/).count
|
|
116
|
+
@table_row_count = @markdown.lines.grep(/^\|/).count
|
|
117
|
+
@heading_count = @markdown.lines.grep(/^(?:#){1,3}\s+/).count
|
|
118
|
+
@common_tokens = @context_downcase.scan(/[a-z0-9]{3,}/).uniq
|
|
119
|
+
@resource_urls = [requested_url, final_url, canonical_url].compact
|
|
120
|
+
@raw_resource_urls = [requested_url, raw_final_url, raw_canonical_url].compact
|
|
121
|
+
@resource_url_facets = @resource_urls.to_h { |url| [url, url_facets(url)] }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def search_or_list_resource_url?(url)
|
|
125
|
+
facet = resource_url_facets.fetch(url) { url_facets(url) }
|
|
126
|
+
facet[:index_query] || facet[:search_or_list_path]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def path_key(url)
|
|
130
|
+
resource_url_facets.fetch(url) { url_facets(url) }[:path_key]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def first_lines_context(count)
|
|
136
|
+
FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown.lines.first(count).join(" ")].join(" "))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def long_prose_line_count(length)
|
|
140
|
+
prose_lines.count { |line| FetchUtil.normalize_whitespace(line).length >= length }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def url_facets(url)
|
|
144
|
+
uri = URI.parse(url)
|
|
145
|
+
path = uri.path.to_s
|
|
146
|
+
segments = path.split("/").reject(&:empty?)
|
|
147
|
+
{
|
|
148
|
+
path_key: path.downcase.gsub(%r{/+}, "/").then { |value| value == "/" ? value : value.delete_suffix("/") },
|
|
149
|
+
index_query: uri.query.to_s.match?(Fetcher::INDEX_QUERY_PATTERN),
|
|
150
|
+
search_or_list_path: segments.any? { |segment| Fetcher::SEARCH_OR_LIST_PATH_SEGMENTS.include?(segment.downcase) }
|
|
151
|
+
}
|
|
152
|
+
rescue URI::InvalidURIError
|
|
153
|
+
{ path_key: "", index_query: false, search_or_list_path: false }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
77
156
|
|
|
78
157
|
def initialize(browser: nil, extractor: nil, **options)
|
|
79
158
|
@timeout = options.fetch(:timeout, 20)
|
|
@@ -89,39 +168,55 @@ module FetchUtil
|
|
|
89
168
|
|
|
90
169
|
def fetch(url)
|
|
91
170
|
t0 = monotonic_now
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if fallback
|
|
104
|
-
result = fallback_result(url, fallback)
|
|
171
|
+
pending_connection_retries = 0
|
|
172
|
+
|
|
173
|
+
begin
|
|
174
|
+
result = @browser.with_page(url) do |page|
|
|
175
|
+
payload = @extractor.extract(page)
|
|
176
|
+
build_result(url, page.current_url, payload)
|
|
177
|
+
end
|
|
178
|
+
fallback = seznam_cmp_redirect_fallback_candidate?(url, result) ? @raw_docs_fallback.fetch(url) : nil
|
|
179
|
+
fallback ||= docs_fallback_candidate?(url, result) && poor_docs_result?(result) ? @raw_docs_fallback.fetch(url) : nil
|
|
180
|
+
fallback ||= article_body_fallback_candidate?(result) ? @raw_docs_fallback.fetch(result.final_url) : nil
|
|
181
|
+
result = fallback_result(url, fallback) if fallback
|
|
105
182
|
log_request(url, t0)
|
|
106
|
-
|
|
107
|
-
|
|
183
|
+
result
|
|
184
|
+
rescue BrowserError, ExtractionError => e
|
|
185
|
+
if e.is_a?(BrowserError) && pending_connections_error?(e) && pending_connection_retries < PENDING_CONNECTIONS_FETCH_RETRIES
|
|
186
|
+
pending_connection_retries += 1
|
|
187
|
+
sleep PENDING_CONNECTIONS_FETCH_RETRY_WAIT
|
|
188
|
+
retry
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
fallback = docs_fallback_candidate?(url) ? @raw_docs_fallback.fetch(url) : nil
|
|
192
|
+
if fallback
|
|
193
|
+
result = fallback_result(url, fallback)
|
|
194
|
+
log_request(url, t0)
|
|
195
|
+
return result
|
|
196
|
+
end
|
|
108
197
|
|
|
109
|
-
|
|
110
|
-
|
|
198
|
+
log_request(url, t0)
|
|
199
|
+
return network_error_result(url, e) if e.is_a?(BrowserError) && network_error?(e)
|
|
111
200
|
|
|
112
|
-
|
|
201
|
+
raise e
|
|
202
|
+
end
|
|
113
203
|
end
|
|
114
204
|
|
|
115
205
|
private
|
|
116
206
|
|
|
117
207
|
def build_result(url, final_url, payload)
|
|
208
|
+
raw_final_url = final_url
|
|
209
|
+
raw_canonical_url = payload["canonicalUrl"]
|
|
118
210
|
final_url = normalized_result_url(final_url)
|
|
119
|
-
canonical_url = normalized_result_url(
|
|
211
|
+
canonical_url = normalized_result_url(raw_canonical_url)
|
|
212
|
+
snapshot = PayloadSnapshot.new(
|
|
213
|
+
payload: payload, requested_url: url, final_url: final_url, canonical_url: canonical_url,
|
|
214
|
+
raw_final_url: raw_final_url, raw_canonical_url: raw_canonical_url
|
|
215
|
+
)
|
|
120
216
|
homepage_like = homepage_like?(final_url)
|
|
121
|
-
content_type = resolved_content_type(
|
|
217
|
+
content_type = resolved_content_type(homepage_like, snapshot)
|
|
122
218
|
warnings = resolved_warnings(
|
|
123
|
-
content_type, homepage_like,
|
|
124
|
-
requested_url: url, final_url: final_url, canonical_url: canonical_url
|
|
219
|
+
content_type, homepage_like, snapshot
|
|
125
220
|
)
|
|
126
221
|
suspect = warnings.any?
|
|
127
222
|
|
|
@@ -136,44 +231,55 @@ module FetchUtil
|
|
|
136
231
|
)
|
|
137
232
|
end
|
|
138
233
|
|
|
139
|
-
def resolved_content_type(
|
|
234
|
+
def resolved_content_type(homepage_like, snapshot)
|
|
235
|
+
payload = snapshot.payload
|
|
236
|
+
final_url = snapshot.final_url
|
|
140
237
|
content_type = payload["contentType"] || "article"
|
|
141
|
-
return "article" if content_type == "list" && scholarly_article_markdown?(final_url,
|
|
238
|
+
return "article" if content_type == "list" && scholarly_article_markdown?(final_url, snapshot)
|
|
142
239
|
|
|
143
240
|
return content_type unless content_type == "article"
|
|
144
241
|
return content_type if payload["legalProvision"]
|
|
145
242
|
return content_type if payload["hostAware"]
|
|
146
|
-
return "list" if institutional_case_record_list?(final_url,
|
|
147
|
-
return content_type if legal_judgment_markdown?(
|
|
148
|
-
return content_type if scholarly_article_markdown?(final_url,
|
|
149
|
-
return content_type if reference_table_article_markdown?(
|
|
150
|
-
return "list" if government_service_portal?(final_url,
|
|
151
|
-
return "list" if homepage_like && homepage_index_markdown?(
|
|
152
|
-
return "list" if index_list_markdown?(final_url,
|
|
153
|
-
return "list" if thin_index_page?(final_url,
|
|
243
|
+
return "list" if institutional_case_record_list?(final_url, snapshot)
|
|
244
|
+
return content_type if legal_judgment_markdown?(snapshot) || legal_statute_markdown?(snapshot)
|
|
245
|
+
return content_type if scholarly_article_markdown?(final_url, snapshot)
|
|
246
|
+
return content_type if reference_table_article_markdown?(snapshot)
|
|
247
|
+
return "list" if government_service_portal?(final_url, snapshot)
|
|
248
|
+
return "list" if homepage_like && homepage_index_markdown?(snapshot)
|
|
249
|
+
return "list" if index_list_markdown?(final_url, snapshot)
|
|
250
|
+
return "list" if thin_index_page?(final_url, snapshot)
|
|
154
251
|
|
|
155
252
|
content_type
|
|
156
253
|
end
|
|
157
254
|
|
|
158
|
-
def resolved_warnings(content_type, homepage_like,
|
|
255
|
+
def resolved_warnings(content_type, homepage_like, snapshot)
|
|
256
|
+
payload = snapshot.payload
|
|
257
|
+
requested_url = snapshot.requested_url
|
|
258
|
+
final_url = snapshot.final_url
|
|
259
|
+
canonical_url = snapshot.canonical_url
|
|
159
260
|
trusted_same_organization_redirect = trusted_same_organization_redirect?(
|
|
160
|
-
content_type,
|
|
261
|
+
content_type, snapshot
|
|
161
262
|
)
|
|
162
|
-
trusted_cross_domain_redirect = trusted_publisher_doi_redirect?(content_type,
|
|
263
|
+
trusted_cross_domain_redirect = trusted_publisher_doi_redirect?(content_type, snapshot)
|
|
163
264
|
warnings = Array(payload["warnings"]).dup
|
|
164
265
|
warnings.delete("url_content_mismatch") if trusted_same_organization_redirect
|
|
266
|
+
if stripped_query_only_url_mismatch?(requested_url, final_url, canonical_url, snapshot.raw_final_url,
|
|
267
|
+
snapshot.raw_canonical_url)
|
|
268
|
+
warnings.delete("url_content_mismatch")
|
|
269
|
+
end
|
|
165
270
|
if content_type == "list" && homepage_like && !payload["statusPage"] &&
|
|
166
|
-
!substantial_homepage_landing?(
|
|
167
|
-
!research_database_landing?(
|
|
271
|
+
!substantial_homepage_landing?(snapshot) && !government_service_portal?(final_url, snapshot) &&
|
|
272
|
+
!research_database_landing?(snapshot)
|
|
273
|
+
warnings.delete("url_content_mismatch")
|
|
168
274
|
warnings << "homepage_index_page"
|
|
169
275
|
end
|
|
170
276
|
warnings << "cross_domain_redirect" if cross_domain_redirect?(requested_url, final_url) && !trusted_cross_domain_redirect
|
|
171
277
|
warnings << "aggregator_redirect_url" if aggregator_url?(requested_url)
|
|
172
|
-
warnings << "auth_or_login_interstitial" if auth_redirect_interstitial?(
|
|
278
|
+
warnings << "auth_or_login_interstitial" if auth_redirect_interstitial?(snapshot)
|
|
173
279
|
warnings << "pdf_document" if pdf_document?(requested_url, final_url, payload)
|
|
174
|
-
warnings << "not_found_interstitial" if generic_redirect_not_found?(
|
|
280
|
+
warnings << "not_found_interstitial" if generic_redirect_not_found?(snapshot)
|
|
175
281
|
if !trusted_same_organization_redirect &&
|
|
176
|
-
redirected_title_content_mismatch?(content_type, homepage_like,
|
|
282
|
+
redirected_title_content_mismatch?(content_type, homepage_like, snapshot)
|
|
177
283
|
warnings << "url_content_mismatch"
|
|
178
284
|
end
|
|
179
285
|
warnings.uniq
|
|
@@ -186,27 +292,25 @@ module FetchUtil
|
|
|
186
292
|
false
|
|
187
293
|
end
|
|
188
294
|
|
|
189
|
-
def homepage_index_markdown?(
|
|
190
|
-
|
|
191
|
-
return false unless snippet.match?(HOMEPAGE_INDEX_PATTERN)
|
|
295
|
+
def homepage_index_markdown?(snapshot)
|
|
296
|
+
return false unless snapshot.context.match?(HOMEPAGE_INDEX_PATTERN)
|
|
192
297
|
|
|
193
|
-
|
|
298
|
+
snapshot.plain_list_item_count >= 3
|
|
194
299
|
end
|
|
195
300
|
|
|
196
|
-
def government_service_portal?(url,
|
|
197
|
-
|
|
198
|
-
normalized = FetchUtil.normalize_whitespace(markdown)
|
|
301
|
+
def government_service_portal?(url, snapshot)
|
|
302
|
+
normalized = snapshot.normalized_markdown
|
|
199
303
|
return false if normalized.length < 250
|
|
200
|
-
return false unless government_domain?(url) || government_service_language?(
|
|
304
|
+
return false unless government_domain?(url) || government_service_language?(snapshot)
|
|
201
305
|
|
|
202
|
-
context =
|
|
306
|
+
context = snapshot.context_downcase
|
|
203
307
|
service_pattern = /\b(?:service|services|servi[cç]os?|servicio|servicios|service category|categories|
|
|
204
308
|
categorias?|citizens?|business(?:es)?|benefits?|permits?|licen[cs]es?)\b/ix
|
|
205
309
|
service_terms = context.scan(service_pattern).length
|
|
206
|
-
linked_items =
|
|
207
|
-
plain_items =
|
|
310
|
+
linked_items = snapshot.linked_heading_count + snapshot.linked_item_count
|
|
311
|
+
plain_items = snapshot.plain_list_item_count
|
|
208
312
|
|
|
209
|
-
service_terms >= 3 && (linked_items >= 4 || plain_items >= 4 ||
|
|
313
|
+
service_terms >= 3 && (linked_items >= 4 || plain_items >= 4 || snapshot.markdown_link_count >= 6)
|
|
210
314
|
end
|
|
211
315
|
|
|
212
316
|
def government_domain?(url)
|
|
@@ -218,22 +322,20 @@ module FetchUtil
|
|
|
218
322
|
false
|
|
219
323
|
end
|
|
220
324
|
|
|
221
|
-
def government_service_language?(
|
|
222
|
-
|
|
223
|
-
context.match?(/\b(?:government|governance|public services?|servi[cç]os? p[úu]blicos?|national portal|citizen services?)\b/i)
|
|
325
|
+
def government_service_language?(snapshot)
|
|
326
|
+
snapshot.context_downcase.match?(/\b(?:government|governance|public services?|servi[cç]os? p[úu]blicos?|national portal|citizen services?)\b/i)
|
|
224
327
|
end
|
|
225
328
|
|
|
226
|
-
def institutional_case_record_list?(url,
|
|
227
|
-
|
|
228
|
-
normalized = FetchUtil.normalize_whitespace(markdown)
|
|
329
|
+
def institutional_case_record_list?(url, snapshot)
|
|
330
|
+
normalized = snapshot.normalized_markdown
|
|
229
331
|
return false if normalized.length < 500
|
|
230
332
|
|
|
231
333
|
path = URI.parse(url).path.to_s.downcase
|
|
232
|
-
context =
|
|
334
|
+
context = snapshot.first_20_context
|
|
233
335
|
return false unless path.match?(%r{/(?:cases?|defendants?|records?|dockets?|matters?)/?\z}) ||
|
|
234
336
|
context.match?(/\b\d{1,4}\s+(?:cases?|defendants?|records?|matters?)\b/i)
|
|
235
337
|
|
|
236
|
-
linked_case_headings = markdown.scan(
|
|
338
|
+
linked_case_headings = snapshot.markdown.scan(
|
|
237
339
|
%r{^\s*\#{1,6}\s+\[[^\]]{3,180}\]\([^)]*/(?:cases?|defendants?|situations?|darfur|mali|kenya|libya|uganda|congo|afghanistan|ukraine|records?|dockets?)[^)]*\)}i
|
|
238
340
|
).count
|
|
239
341
|
case_terms = normalized.scan(
|
|
@@ -245,60 +347,56 @@ module FetchUtil
|
|
|
245
347
|
false
|
|
246
348
|
end
|
|
247
349
|
|
|
248
|
-
def substantial_homepage_landing?(
|
|
249
|
-
|
|
250
|
-
normalized = FetchUtil.normalize_whitespace(markdown)
|
|
350
|
+
def substantial_homepage_landing?(snapshot)
|
|
351
|
+
normalized = snapshot.normalized_markdown
|
|
251
352
|
return false if normalized.length < 1_200
|
|
252
353
|
|
|
253
|
-
context =
|
|
354
|
+
context = snapshot.context_downcase
|
|
254
355
|
landing_pattern = /\b(docs?|documentation|api|reference|guide|guides|developer|framework|next\.js|mdx|
|
|
255
356
|
static websites?|components?|themes?|product|platform)\b/x
|
|
256
357
|
return false unless context.match?(landing_pattern)
|
|
257
358
|
|
|
258
|
-
|
|
259
|
-
prose_lines.any? { |line| FetchUtil.normalize_whitespace(line).length >= 120 }
|
|
359
|
+
snapshot.long_prose_line_count_one_twenty.positive?
|
|
260
360
|
end
|
|
261
361
|
|
|
262
|
-
def research_database_landing?(
|
|
263
|
-
|
|
264
|
-
normalized = FetchUtil.normalize_whitespace(markdown)
|
|
362
|
+
def research_database_landing?(snapshot)
|
|
363
|
+
normalized = snapshot.normalized_markdown
|
|
265
364
|
return false if normalized.length < 250
|
|
266
365
|
|
|
267
|
-
context =
|
|
366
|
+
context = snapshot.context_downcase
|
|
268
367
|
research_terms = /\b(?:database|data resource|repository|multi-omics|proteomics|transcriptomics|
|
|
269
368
|
phenomics|genomics|metabolomics|life science research|scientific resource)\b/ix
|
|
270
369
|
return false unless context.match?(research_terms)
|
|
271
370
|
return false if context.match?(HOMEPAGE_INDEX_PATTERN)
|
|
272
371
|
|
|
273
|
-
|
|
274
|
-
prose_lines.any? { |line| FetchUtil.normalize_whitespace(line).length >= 100 }
|
|
372
|
+
snapshot.long_prose_line_count_one_hundred.positive?
|
|
275
373
|
end
|
|
276
374
|
|
|
277
|
-
def index_list_markdown?(url,
|
|
375
|
+
def index_list_markdown?(url, snapshot)
|
|
278
376
|
return false unless index_or_search_url?(url)
|
|
279
377
|
return false if article_like_url?(url)
|
|
280
378
|
|
|
281
|
-
|
|
282
|
-
return false if legal_judgment_markdown?(markdown) || legal_statute_markdown?(markdown)
|
|
379
|
+
return false if legal_judgment_markdown?(snapshot) || legal_statute_markdown?(snapshot)
|
|
283
380
|
|
|
284
|
-
linked_headlines =
|
|
285
|
-
linked_items =
|
|
381
|
+
linked_headlines = snapshot.linked_heading_count
|
|
382
|
+
linked_items = snapshot.linked_item_count
|
|
286
383
|
|
|
287
384
|
linked_headlines + linked_items >= 4
|
|
288
385
|
end
|
|
289
386
|
|
|
290
|
-
def scholarly_article_markdown?(url,
|
|
291
|
-
|
|
292
|
-
normalized =
|
|
387
|
+
def scholarly_article_markdown?(url, snapshot)
|
|
388
|
+
payload = snapshot.payload
|
|
389
|
+
normalized = snapshot.normalized_markdown
|
|
293
390
|
return false if normalized.length < 5_000
|
|
294
391
|
return false unless article_like_url?(url) || doi_article_url?(url) || doi_article_url?(payload["canonicalUrl"])
|
|
295
392
|
return false if payload["byline"].to_s.strip.empty? && payload["publishedTime"].to_s.strip.empty?
|
|
296
393
|
|
|
297
|
-
context =
|
|
394
|
+
context = snapshot.first_80_context
|
|
298
395
|
scholarly_context = context.match?(/\b(?:abstract|introduction|methods?|results?|discussion|references|doi|open access|peer[- ]reviewed|journal|article)\b/i)
|
|
299
|
-
section_headings = markdown.scan(
|
|
300
|
-
|
|
301
|
-
|
|
396
|
+
section_headings = snapshot.markdown.scan(
|
|
397
|
+
/^\s*\#{1,4}\s+(?:Abstract|Introduction|Methods?|Materials and methods|Results?|Discussion|Conclusion|References)\b/i
|
|
398
|
+
).count
|
|
399
|
+
long_prose_lines = snapshot.long_prose_line_count_one_forty
|
|
302
400
|
|
|
303
401
|
return true if scholarly_context && section_headings >= 3 && long_prose_lines >= 5
|
|
304
402
|
|
|
@@ -313,38 +411,39 @@ module FetchUtil
|
|
|
313
411
|
false
|
|
314
412
|
end
|
|
315
413
|
|
|
316
|
-
def thin_index_page?(url,
|
|
414
|
+
def thin_index_page?(url, snapshot)
|
|
415
|
+
payload = snapshot.payload
|
|
317
416
|
return false unless index_or_search_url?(url)
|
|
318
417
|
return false if article_like_url?(url)
|
|
319
418
|
return false if payload["byline"].to_s.strip != "" || payload["publishedTime"].to_s.strip != ""
|
|
320
419
|
|
|
321
|
-
markdown =
|
|
322
|
-
return false if legal_judgment_markdown?(
|
|
323
|
-
return false if reference_table_article_markdown?(
|
|
420
|
+
markdown = snapshot.normalized_markdown
|
|
421
|
+
return false if legal_judgment_markdown?(snapshot) || legal_statute_markdown?(snapshot)
|
|
422
|
+
return false if reference_table_article_markdown?(snapshot)
|
|
324
423
|
|
|
325
424
|
markdown.length < 2400
|
|
326
425
|
end
|
|
327
426
|
|
|
328
|
-
def reference_table_article_markdown?(
|
|
329
|
-
raw = markdown
|
|
330
|
-
text =
|
|
427
|
+
def reference_table_article_markdown?(snapshot)
|
|
428
|
+
raw = snapshot.markdown
|
|
429
|
+
text = snapshot.normalized_markdown
|
|
331
430
|
return false if text.length < 700
|
|
332
431
|
return false unless raw.match?(/^\|\s*[-: ]+\|/)
|
|
333
|
-
return false if
|
|
432
|
+
return false if snapshot.list_link_count >= 3
|
|
334
433
|
|
|
335
434
|
prose_blocks = raw.split(/\n{2,}/).count do |block|
|
|
336
435
|
stripped = block.strip
|
|
337
436
|
normalized = FetchUtil.normalize_whitespace(stripped)
|
|
338
437
|
!stripped.empty? && !stripped.start_with?("|", "#") && normalized.length >= 80 && normalized.match?(/[.!?)]\z/)
|
|
339
438
|
end
|
|
340
|
-
table_rows =
|
|
341
|
-
headings =
|
|
439
|
+
table_rows = snapshot.table_row_count
|
|
440
|
+
headings = snapshot.heading_count
|
|
342
441
|
|
|
343
442
|
headings >= 1 && prose_blocks >= 2 && table_rows.between?(4, 40)
|
|
344
443
|
end
|
|
345
444
|
|
|
346
|
-
def legal_judgment_markdown?(
|
|
347
|
-
text =
|
|
445
|
+
def legal_judgment_markdown?(snapshot)
|
|
446
|
+
text = snapshot.normalized_markdown
|
|
348
447
|
return false if text.length < 5_000
|
|
349
448
|
return false if text.match?(/\bresults?\s+\d+\s*[-–]\s*\d+\s+(?:of|sur|von|de)\s+\d+\b/i)
|
|
350
449
|
|
|
@@ -356,12 +455,11 @@ module FetchUtil
|
|
|
356
455
|
signals += 1 if text.match?(/\[[12][0-9]{3}\]\s+[A-Z][A-Z0-9.]{1,12}\s+\d+|\([12][0-9]{3}\)\s+\d+\s+[A-Z][A-Z0-9.]{1,12}\s+\d+/i)
|
|
357
456
|
return false if signals < 3
|
|
358
457
|
|
|
359
|
-
|
|
360
|
-
prose_lines.count { |line| FetchUtil.normalize_whitespace(line).length >= 120 } >= 5 || text.length >= 20_000
|
|
458
|
+
snapshot.long_prose_line_count_one_twenty >= 5 || text.length >= 20_000
|
|
361
459
|
end
|
|
362
460
|
|
|
363
|
-
def legal_statute_markdown?(
|
|
364
|
-
text =
|
|
461
|
+
def legal_statute_markdown?(snapshot)
|
|
462
|
+
text = snapshot.normalized_markdown
|
|
365
463
|
return false if text.length < 5_000
|
|
366
464
|
return false if text.match?(/\bresults?\s+\d+\s*[-–]\s*\d+\s+(?:of|sur|von|de)\s+\d+\b/i)
|
|
367
465
|
|
|
@@ -401,13 +499,15 @@ module FetchUtil
|
|
|
401
499
|
false
|
|
402
500
|
end
|
|
403
501
|
|
|
404
|
-
def auth_redirect_interstitial?(
|
|
502
|
+
def auth_redirect_interstitial?(snapshot)
|
|
503
|
+
requested_url = snapshot.requested_url
|
|
504
|
+
final_url = snapshot.final_url
|
|
405
505
|
return false if requested_url.nil? || final_url.nil?
|
|
406
506
|
return false unless auth_path?(final_url)
|
|
407
507
|
return false if auth_path?(requested_url)
|
|
408
508
|
return false unless index_or_search_url?(requested_url)
|
|
409
509
|
|
|
410
|
-
text =
|
|
510
|
+
text = snapshot.context_with_excerpt_downcase
|
|
411
511
|
text.match?(/\b(?:log in|login|sign in|sign-in)\b/) &&
|
|
412
512
|
text.match?(/\b(?:github|gitlab|google|oauth|sso|single sign-on|password|account)\b/)
|
|
413
513
|
end
|
|
@@ -429,16 +529,18 @@ module FetchUtil
|
|
|
429
529
|
false
|
|
430
530
|
end
|
|
431
531
|
|
|
432
|
-
def generic_redirect_not_found?(
|
|
532
|
+
def generic_redirect_not_found?(snapshot)
|
|
533
|
+
requested_url = snapshot.requested_url
|
|
534
|
+
final_url = snapshot.final_url
|
|
433
535
|
return false if requested_url.nil? || final_url.nil?
|
|
434
536
|
return false unless same_effective_domain?(requested_url, final_url)
|
|
435
537
|
|
|
436
|
-
requested_path =
|
|
437
|
-
final_path =
|
|
538
|
+
requested_path = snapshot.path_key(requested_url)
|
|
539
|
+
final_path = snapshot.path_key(final_url)
|
|
438
540
|
return false if requested_path.empty? || requested_path == final_path
|
|
439
541
|
return false unless specific_content_path?(requested_path)
|
|
440
542
|
return false unless generic_redirect_path?(final_path)
|
|
441
|
-
return false if redirected_payload_matches_requested_path?(requested_path, payload)
|
|
543
|
+
return false if redirected_payload_matches_requested_path?(requested_path, snapshot.payload)
|
|
442
544
|
|
|
443
545
|
true
|
|
444
546
|
end
|
|
@@ -472,6 +574,19 @@ module FetchUtil
|
|
|
472
574
|
markdown.length < 1_500 && markdown.lines.grep(/^\s*[-*]\s+\[/).count >= 4
|
|
473
575
|
end
|
|
474
576
|
|
|
577
|
+
def seznam_cmp_redirect_fallback_candidate?(requested_url, result)
|
|
578
|
+
requested_host = FetchUtil.strip_www_host(requested_url)
|
|
579
|
+
return false unless requested_host == "novinky.cz"
|
|
580
|
+
|
|
581
|
+
final_uri = URI.parse(result.final_url)
|
|
582
|
+
return false unless final_uri.host == "cmp.seznam.cz" && final_uri.path.start_with?("/nastaveni-souhlasu")
|
|
583
|
+
|
|
584
|
+
consent_text = FetchUtil.normalize_whitespace([result.title, result.markdown].compact.join(" "))
|
|
585
|
+
consent_text.match?(/nastavení souhlasu|souhlas s personalizací|unable to load/i)
|
|
586
|
+
rescue URI::InvalidURIError
|
|
587
|
+
false
|
|
588
|
+
end
|
|
589
|
+
|
|
475
590
|
def slug_article_url?(url)
|
|
476
591
|
segments = URI.parse(url).path.to_s.split("/").reject(&:empty?)
|
|
477
592
|
segments.length == 1 && segments.first.include?("-")
|
|
@@ -479,13 +594,16 @@ module FetchUtil
|
|
|
479
594
|
false
|
|
480
595
|
end
|
|
481
596
|
|
|
482
|
-
def redirected_title_content_mismatch?(content_type, homepage_like,
|
|
597
|
+
def redirected_title_content_mismatch?(content_type, homepage_like, snapshot)
|
|
598
|
+
payload = snapshot.payload
|
|
599
|
+
requested_url = snapshot.requested_url
|
|
600
|
+
final_url = snapshot.final_url
|
|
483
601
|
return false unless content_type == "article"
|
|
484
602
|
return false if homepage_like || payload["docsLike"]
|
|
485
603
|
return false if requested_url.nil? || final_url.nil?
|
|
486
|
-
return false if
|
|
604
|
+
return false if snapshot.resource_urls.any? { |url| snapshot.search_or_list_resource_url?(url) }
|
|
487
605
|
|
|
488
|
-
resource_url = mismatched_resource_url(
|
|
606
|
+
resource_url = mismatched_resource_url(snapshot)
|
|
489
607
|
return false if resource_url.nil?
|
|
490
608
|
|
|
491
609
|
requested_keywords = title_slug_keywords(requested_url)
|
|
@@ -497,20 +615,21 @@ module FetchUtil
|
|
|
497
615
|
(requested_keywords & resolved_keywords).empty?
|
|
498
616
|
end
|
|
499
617
|
|
|
500
|
-
def trusted_same_organization_redirect?(content_type,
|
|
618
|
+
def trusted_same_organization_redirect?(content_type, snapshot)
|
|
619
|
+
payload = snapshot.payload
|
|
620
|
+
requested_url = snapshot.requested_url
|
|
621
|
+
final_url = snapshot.final_url
|
|
501
622
|
return false unless content_type == "article"
|
|
502
623
|
return false if requested_url.nil? || final_url.nil?
|
|
503
624
|
return false unless same_effective_domain?(requested_url, final_url)
|
|
504
625
|
return false if FetchUtil.strip_www_host(requested_url) == FetchUtil.strip_www_host(final_url)
|
|
505
|
-
return false if
|
|
626
|
+
return false if snapshot.resource_urls.any? { |url| snapshot.search_or_list_resource_url?(url) }
|
|
506
627
|
return true if matching_apex_instrument_redirect?(payload, requested_url, final_url)
|
|
507
628
|
|
|
508
629
|
tokens = requested_identifier_tokens(requested_url)
|
|
509
630
|
return false if tokens.empty?
|
|
510
631
|
|
|
511
|
-
content =
|
|
512
|
-
[payload["title"], payload["markdown"]].compact.join(" ")
|
|
513
|
-
).downcase
|
|
632
|
+
content = snapshot.content_downcase
|
|
514
633
|
matches = tokens.select { |token| content.include?(token) }
|
|
515
634
|
|
|
516
635
|
matches.any? { |token| code_like_identifier?(token) } || matches.length >= 2
|
|
@@ -518,12 +637,14 @@ module FetchUtil
|
|
|
518
637
|
false
|
|
519
638
|
end
|
|
520
639
|
|
|
521
|
-
def trusted_publisher_doi_redirect?(content_type,
|
|
640
|
+
def trusted_publisher_doi_redirect?(content_type, snapshot)
|
|
641
|
+
requested_url = snapshot.requested_url
|
|
642
|
+
final_url = snapshot.final_url
|
|
522
643
|
return false unless content_type == "article"
|
|
523
644
|
return false unless cross_domain_redirect?(requested_url, final_url)
|
|
524
|
-
return false unless scholarly_article_markdown?(final_url,
|
|
645
|
+
return false unless scholarly_article_markdown?(final_url, snapshot)
|
|
525
646
|
|
|
526
|
-
dois =
|
|
647
|
+
dois = snapshot.resource_urls.filter_map { |url| doi_from_url(url) }.uniq
|
|
527
648
|
dois.length == 1
|
|
528
649
|
end
|
|
529
650
|
|
|
@@ -572,16 +693,19 @@ module FetchUtil
|
|
|
572
693
|
token.match?(/\A(?=.*[a-z])(?=.*\d)[a-z0-9]{3,16}\z/)
|
|
573
694
|
end
|
|
574
695
|
|
|
575
|
-
def mismatched_resource_url(
|
|
576
|
-
|
|
696
|
+
def mismatched_resource_url(snapshot)
|
|
697
|
+
requested_url = snapshot.requested_url
|
|
698
|
+
final_url = snapshot.final_url
|
|
699
|
+
canonical_url = snapshot.canonical_url
|
|
700
|
+
requested_path = snapshot.path_key(requested_url)
|
|
577
701
|
return nil if requested_path.empty?
|
|
578
702
|
|
|
579
|
-
final_path =
|
|
703
|
+
final_path = snapshot.path_key(final_url)
|
|
580
704
|
return final_url if !final_path.empty? && final_path != requested_path
|
|
581
705
|
|
|
582
706
|
return nil unless same_effective_domain?(final_url, canonical_url)
|
|
583
707
|
|
|
584
|
-
canonical_path =
|
|
708
|
+
canonical_path = snapshot.path_key(canonical_url)
|
|
585
709
|
return canonical_url if !canonical_path.empty? && canonical_path != requested_path
|
|
586
710
|
|
|
587
711
|
nil
|
|
@@ -595,25 +719,6 @@ module FetchUtil
|
|
|
595
719
|
!left_domain.nil? && left_domain == right_domain
|
|
596
720
|
end
|
|
597
721
|
|
|
598
|
-
def search_or_list_resource_url?(url)
|
|
599
|
-
uri = URI.parse(url)
|
|
600
|
-
return true if uri.query.to_s.match?(INDEX_QUERY_PATTERN)
|
|
601
|
-
|
|
602
|
-
uri.path.to_s.split("/").reject(&:empty?).any? do |segment|
|
|
603
|
-
SEARCH_OR_LIST_PATH_SEGMENTS.include?(segment.downcase)
|
|
604
|
-
end
|
|
605
|
-
rescue URI::InvalidURIError
|
|
606
|
-
false
|
|
607
|
-
end
|
|
608
|
-
|
|
609
|
-
def normalized_path_key(url)
|
|
610
|
-
path = URI.parse(url).path.to_s.downcase.gsub(%r{/+}, "/")
|
|
611
|
-
path = path.delete_suffix("/") unless path == "/"
|
|
612
|
-
path
|
|
613
|
-
rescue URI::InvalidURIError
|
|
614
|
-
""
|
|
615
|
-
end
|
|
616
|
-
|
|
617
722
|
def title_slug_keywords(url)
|
|
618
723
|
URI.parse(url).path.to_s.split("/").reject(&:empty?).reverse_each do |segment|
|
|
619
724
|
tokens = significant_slug_tokens(segment)
|
|
@@ -649,6 +754,10 @@ module FetchUtil
|
|
|
649
754
|
error.message.to_s.match?(NETWORK_ERROR_PATTERN)
|
|
650
755
|
end
|
|
651
756
|
|
|
757
|
+
def pending_connections_error?(error)
|
|
758
|
+
error.message.to_s.match?(/pending connections/i)
|
|
759
|
+
end
|
|
760
|
+
|
|
652
761
|
def dns_resolution_error?(message)
|
|
653
762
|
message.match?(/ERR_NAME_NOT_RESOLVED|DNS|resolve|resolution|ENOTFOUND|EAI_AGAIN/i)
|
|
654
763
|
end
|
|
@@ -731,15 +840,32 @@ module FetchUtil
|
|
|
731
840
|
end
|
|
732
841
|
|
|
733
842
|
def normalized_result_url(url)
|
|
843
|
+
strip_tracking_params(url)
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def strip_tracking_params(url)
|
|
734
847
|
return url if url.nil? || url.empty?
|
|
735
848
|
|
|
736
849
|
uri = URI.parse(url)
|
|
737
850
|
params = URI.decode_www_form(uri.query.to_s)
|
|
738
|
-
params.reject! { |key, _value|
|
|
851
|
+
params.reject! { |key, _value| tracking_query_param?(key) }
|
|
739
852
|
uri.query = params.empty? ? nil : URI.encode_www_form(params)
|
|
740
853
|
uri.to_s
|
|
741
854
|
rescue URI::InvalidURIError
|
|
742
855
|
url
|
|
743
856
|
end
|
|
857
|
+
|
|
858
|
+
def tracking_query_param?(key)
|
|
859
|
+
TRACKING_QUERY_PARAM_PATTERNS.any? { |pattern| key.match?(pattern) }
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def stripped_query_only_url_mismatch?(requested_url, final_url, canonical_url, raw_final_url, raw_canonical_url)
|
|
863
|
+
normalized_requested_url = normalized_result_url(requested_url)
|
|
864
|
+
return false if normalized_requested_url.nil? || final_url.nil?
|
|
865
|
+
return false unless normalized_requested_url == final_url
|
|
866
|
+
return false if canonical_url && canonical_url != normalized_requested_url
|
|
867
|
+
|
|
868
|
+
[raw_final_url, raw_canonical_url].compact.any? { |url| normalized_result_url(url) != url }
|
|
869
|
+
end
|
|
744
870
|
end
|
|
745
871
|
end
|