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.
@@ -49,9 +49,9 @@ module FetchUtil
49
49
  "inciso|subsection",
50
50
  Regexp::IGNORECASE
51
51
  ).freeze
52
- STRIPPED_QUERY_PARAM_PATTERNS = [
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
- result = @browser.with_page(url) do |page|
93
- payload = @extractor.extract(page)
94
- build_result(url, page.current_url, payload)
95
- end
96
- fallback = docs_fallback_candidate?(url, result) && poor_docs_result?(result) ? @raw_docs_fallback.fetch(url) : nil
97
- fallback ||= article_body_fallback_candidate?(result) ? @raw_docs_fallback.fetch(result.final_url) : nil
98
- result = fallback_result(url, fallback) if fallback
99
- log_request(url, t0)
100
- result
101
- rescue BrowserError, ExtractionError => e
102
- fallback = docs_fallback_candidate?(url) ? @raw_docs_fallback.fetch(url) : nil
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
- return result
107
- end
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
- log_request(url, t0)
110
- return network_error_result(url, e) if e.is_a?(BrowserError) && network_error?(e)
198
+ log_request(url, t0)
199
+ return network_error_result(url, e) if e.is_a?(BrowserError) && network_error?(e)
111
200
 
112
- raise e
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(payload["canonicalUrl"])
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(final_url, homepage_like, payload)
217
+ content_type = resolved_content_type(homepage_like, snapshot)
122
218
  warnings = resolved_warnings(
123
- content_type, homepage_like, payload,
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(final_url, homepage_like, payload)
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, payload)
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, payload)
147
- return content_type if legal_judgment_markdown?(payload["markdown"]) || legal_statute_markdown?(payload["markdown"])
148
- return content_type if scholarly_article_markdown?(final_url, payload)
149
- return content_type if reference_table_article_markdown?(payload["markdown"])
150
- return "list" if government_service_portal?(final_url, payload)
151
- return "list" if homepage_like && homepage_index_markdown?(payload["title"], payload["markdown"])
152
- return "list" if index_list_markdown?(final_url, payload)
153
- return "list" if thin_index_page?(final_url, payload)
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, payload, requested_url: nil, final_url: nil, canonical_url: nil)
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, payload, requested_url, final_url, canonical_url
261
+ content_type, snapshot
161
262
  )
162
- trusted_cross_domain_redirect = trusted_publisher_doi_redirect?(content_type, payload, requested_url, final_url, canonical_url)
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?(payload) && !government_service_portal?(final_url, payload) &&
167
- !research_database_landing?(payload)
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?(requested_url, final_url, payload)
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?(requested_url, final_url, payload)
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, payload, requested_url, final_url, canonical_url)
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?(title, markdown)
190
- snippet = [title, markdown].compact.join(" ")
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
- markdown.to_s.lines.grep(/^\s*(?:\d+\.\s+|[-*]\s+)/).count >= 3
298
+ snapshot.plain_list_item_count >= 3
194
299
  end
195
300
 
196
- def government_service_portal?(url, payload)
197
- markdown = payload["markdown"].to_s
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?(payload)
304
+ return false unless government_domain?(url) || government_service_language?(snapshot)
201
305
 
202
- context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
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 = markdown.scan(LINKED_MARKDOWN_HEADING_PATTERN).count + markdown.scan(LINKED_MARKDOWN_ITEM_PATTERN).count
207
- plain_items = markdown.lines.grep(/^\s*(?:\d+\.\s+|[-*]\s+)/).count
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 || markdown.scan(%r{\]\(https?://}).count >= 6)
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?(payload)
222
- context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], payload["markdown"]].join(" ")).downcase
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[]os? p[úu]blicos?|national portal|citizen services?)\b/i)
224
327
  end
225
328
 
226
- def institutional_case_record_list?(url, payload)
227
- markdown = payload["markdown"].to_s
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 = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown.lines.first(20).join(" ")].join(" "))
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?(payload)
249
- markdown = payload["markdown"].to_s
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 = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
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
- prose_lines = markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
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?(payload)
263
- markdown = payload["markdown"].to_s
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 = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
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
- prose_lines = markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
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, payload)
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
- markdown = payload["markdown"].to_s
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 = markdown.scan(LINKED_MARKDOWN_HEADING_PATTERN).count
285
- linked_items = markdown.scan(LINKED_MARKDOWN_ITEM_PATTERN).count
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, payload)
291
- markdown = payload["markdown"].to_s
292
- normalized = FetchUtil.normalize_whitespace(markdown)
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 = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown.lines.first(80).join(" ")].join(" "))
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(/^\s*\#{1,4}\s+(?:Abstract|Introduction|Methods?|Materials and methods|Results?|Discussion|Conclusion|References)\b/i).count
300
- prose_lines = markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
301
- long_prose_lines = prose_lines.count { |line| FetchUtil.normalize_whitespace(line).length >= 140 }
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, payload)
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 = FetchUtil.normalize_whitespace(payload["markdown"].to_s)
322
- return false if legal_judgment_markdown?(markdown) || legal_statute_markdown?(markdown)
323
- return false if reference_table_article_markdown?(payload["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?(markdown)
329
- raw = markdown.to_s
330
- text = FetchUtil.normalize_whitespace(raw)
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 raw.lines.grep(/^\s*- \[/).count >= 3
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 = raw.lines.grep(/^\|/).count
341
- headings = raw.lines.grep(/^(?:#){1,3}\s+/).count
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?(markdown)
347
- text = FetchUtil.normalize_whitespace(markdown.to_s)
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
- prose_lines = markdown.to_s.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
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?(markdown)
364
- text = FetchUtil.normalize_whitespace(markdown.to_s)
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?(requested_url, final_url, payload)
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 = FetchUtil.normalize_whitespace([payload["title"], payload["markdown"], payload["excerpt"]].compact.join(" ")).downcase
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?(requested_url, final_url, payload)
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 = normalized_path_key(requested_url)
437
- final_path = normalized_path_key(final_url)
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, payload, requested_url, final_url, canonical_url)
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 [requested_url, final_url, canonical_url].compact.any? { |url| search_or_list_resource_url?(url) }
604
+ return false if snapshot.resource_urls.any? { |url| snapshot.search_or_list_resource_url?(url) }
487
605
 
488
- resource_url = mismatched_resource_url(requested_url, final_url, canonical_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, payload, requested_url, final_url, canonical_url)
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 [requested_url, final_url, canonical_url].compact.any? { |url| search_or_list_resource_url?(url) }
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 = FetchUtil.normalize_whitespace(
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, payload, requested_url, final_url, canonical_url)
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, payload)
645
+ return false unless scholarly_article_markdown?(final_url, snapshot)
525
646
 
526
- dois = [requested_url, final_url, canonical_url].filter_map { |url| doi_from_url(url) }.uniq
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(requested_url, final_url, canonical_url)
576
- requested_path = normalized_path_key(requested_url)
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 = normalized_path_key(final_url)
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 = normalized_path_key(canonical_url)
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| STRIPPED_QUERY_PARAM_PATTERNS.any? { |pattern| key.match?(pattern) } }
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