fetch_util 0.3.1 → 0.4.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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "uri"
4
+ require "net/http"
4
5
 
5
6
  module FetchUtil
6
7
  class Fetcher
@@ -36,6 +37,8 @@ module FetchUtil
36
37
  LINKED_MARKDOWN_ITEM_PATTERN = /(?:^|\s)(?:\d+\.|[-*])\s+\[[^\]]{8,220}\]\(/
37
38
  INDEX_QUERY_PATTERN = /(?:^|[&?])(?:q|query|search|searchtext|keyword|k)=/i
38
39
  PDF_PATH_PATTERN = %r{(?:\.pdf\z|/pdf(?:/|\z)|[?&](?:format|download)=pdf\b)}i
40
+ PDF_CONTENT_TYPE_PATTERN = %r{\Aapplication/(?:pdf|x-pdf)\b}i
41
+ PDF_REDIRECT_LIMIT = 3
39
42
  LEGAL_STATUTE_TITLE_PATTERN = Regexp.new(
40
43
  "constitution|constitutional|constitui[cç]?[aã]o|c[oó]digo|codigo|code|statute|act|law|" \
41
44
  "regulation|ordinance|decree|treaty|convention",
@@ -49,9 +52,9 @@ module FetchUtil
49
52
  "inciso|subsection",
50
53
  Regexp::IGNORECASE
51
54
  ).freeze
52
- STRIPPED_QUERY_PARAM_PATTERNS = [
55
+ TRACKING_QUERY_PARAM_PATTERNS = [
53
56
  /\A(?:__goaway_|__cf_chl_)/,
54
- /\A(?:utm_[a-z]+|fbclid|gclid|mc_cid|mc_eid)\z/,
57
+ /\A(?:utm_[a-z]+|fbclid|gclid|lp|mc_cid|mc_eid|ref|source)\z/i,
55
58
  /\A__gr(?:sc|ts|ua|rn)\z/
56
59
  ].freeze
57
60
  TITLE_SLUG_STOPWORDS = %w[
@@ -69,11 +72,90 @@ module FetchUtil
69
72
  SECOND_LEVEL_COUNTRY_TLDS = /\A(co|com|org|net|gov|edu|ac)\z/
70
73
  GOOGLE_HOST_PATTERN = /\Agoogle\.[a-z.]+\z/
71
74
  NETWORK_ERROR_PATTERN = Regexp.new(
72
- "\\b(?:net::ERR_|ERR_NAME_NOT_RESOLVED|DNS|resolve|resolution|ENOTFOUND|" \
75
+ "\\b(?:net::ERR_|ERR_NAME_NOT_RESOLVED|pending connections|DNS|resolve|resolution|ENOTFOUND|" \
73
76
  "EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out|timeout|" \
74
77
  "connection (?:refused|reset|closed)|disconnected|network)\\b",
75
78
  Regexp::IGNORECASE
76
79
  ).freeze
80
+ PENDING_CONNECTIONS_FETCH_RETRIES = 1
81
+ PENDING_CONNECTIONS_FETCH_RETRY_WAIT = 1.0
82
+
83
+ class PayloadSnapshot
84
+ attr_reader :payload, :requested_url, :final_url, :canonical_url, :raw_final_url, :raw_canonical_url,
85
+ :markdown, :normalized_markdown, :content_downcase, :context, :context_downcase,
86
+ :context_with_excerpt_downcase, :first_20_context,
87
+ :first_80_context, :plain_list_item_count, :linked_heading_count, :linked_item_count,
88
+ :markdown_link_count, :prose_lines, :long_prose_line_count_one_hundred,
89
+ :long_prose_line_count_one_twenty, :long_prose_line_count_one_forty, :list_link_count,
90
+ :table_row_count, :heading_count, :common_tokens, :resource_urls, :raw_resource_urls,
91
+ :resource_url_facets
92
+
93
+ def initialize(payload:, requested_url:, final_url:, canonical_url:, raw_final_url:, raw_canonical_url:)
94
+ @payload = payload
95
+ @requested_url = requested_url
96
+ @final_url = final_url
97
+ @canonical_url = canonical_url
98
+ @raw_final_url = raw_final_url
99
+ @raw_canonical_url = raw_canonical_url
100
+ @markdown = payload["markdown"].to_s
101
+ @normalized_markdown = FetchUtil.normalize_whitespace(@markdown)
102
+ @content_downcase = FetchUtil.normalize_whitespace([payload["title"], @markdown].compact.join(" ")).downcase
103
+ @context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], @markdown].join(" "))
104
+ @context_downcase = @context.downcase
105
+ @context_with_excerpt_downcase = FetchUtil.normalize_whitespace(
106
+ [payload["title"], @markdown, payload["excerpt"]].compact.join(" ")
107
+ ).downcase
108
+ @first_20_context = first_lines_context(20)
109
+ @first_80_context = first_lines_context(80)
110
+ @plain_list_item_count = @markdown.lines.grep(/^\s*(?:\d+\.\s+|[-*]\s+)/).count
111
+ @linked_heading_count = @markdown.scan(Fetcher::LINKED_MARKDOWN_HEADING_PATTERN).count
112
+ @linked_item_count = @markdown.scan(Fetcher::LINKED_MARKDOWN_ITEM_PATTERN).count
113
+ @markdown_link_count = @markdown.scan(%r{\]\(https?://}).count
114
+ @prose_lines = @markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
115
+ @long_prose_line_count_one_hundred = long_prose_line_count(100)
116
+ @long_prose_line_count_one_twenty = long_prose_line_count(120)
117
+ @long_prose_line_count_one_forty = long_prose_line_count(140)
118
+ @list_link_count = @markdown.lines.grep(/^\s*- \[/).count
119
+ @table_row_count = @markdown.lines.grep(/^\|/).count
120
+ @heading_count = @markdown.lines.grep(/^(?:#){1,3}\s+/).count
121
+ @common_tokens = @context_downcase.scan(/[a-z0-9]{3,}/).uniq
122
+ @resource_urls = [requested_url, final_url, canonical_url].compact
123
+ @raw_resource_urls = [requested_url, raw_final_url, raw_canonical_url].compact
124
+ @resource_url_facets = @resource_urls.to_h { |url| [url, url_facets(url)] }
125
+ end
126
+
127
+ def search_or_list_resource_url?(url)
128
+ facet = resource_url_facets.fetch(url) { url_facets(url) }
129
+ facet[:index_query] || facet[:search_or_list_path]
130
+ end
131
+
132
+ def path_key(url)
133
+ resource_url_facets.fetch(url) { url_facets(url) }[:path_key]
134
+ end
135
+
136
+ private
137
+
138
+ def first_lines_context(count)
139
+ FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown.lines.first(count).join(" ")].join(" "))
140
+ end
141
+
142
+ def long_prose_line_count(length)
143
+ prose_lines.count { |line| FetchUtil.normalize_whitespace(line).length >= length }
144
+ end
145
+
146
+ def url_facets(url)
147
+ uri = URI.parse(url)
148
+ path = uri.path.to_s
149
+ segments = path.split("/").reject(&:empty?)
150
+ {
151
+ path_key: path.downcase.gsub(%r{/+}, "/").then { |value| value == "/" ? value : value.delete_suffix("/") },
152
+ index_query: uri.query.to_s.match?(Fetcher::INDEX_QUERY_PATTERN),
153
+ search_or_list_path: segments.any? { |segment| Fetcher::SEARCH_OR_LIST_PATH_SEGMENTS.include?(segment.downcase) }
154
+ }
155
+ rescue URI::InvalidURIError
156
+ { path_key: "", index_query: false, search_or_list_path: false }
157
+ end
158
+ end
77
159
 
78
160
  def initialize(browser: nil, extractor: nil, **options)
79
161
  @timeout = options.fetch(:timeout, 20)
@@ -81,6 +163,7 @@ module FetchUtil
81
163
  @extractor = extractor || Extractor.new(reader_mode: options.fetch(:reader_mode, true))
82
164
  @raw_docs_fallback = options[:raw_docs_fallback] || RawDocsFallback.new(timeout: @timeout)
83
165
  @request_log = options[:request_log]
166
+ @pdf_header_probe = options[:pdf_header_probe]
84
167
  end
85
168
 
86
169
  def quit
@@ -89,39 +172,72 @@ module FetchUtil
89
172
 
90
173
  def fetch(url)
91
174
  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)
175
+ pending_connection_retries = 0
176
+
177
+ begin
178
+ if (pdf_result = direct_pdf_result(url))
179
+ log_request(url, t0)
180
+ return pdf_result
181
+ end
182
+
183
+ result = @browser.with_page(url) do |page|
184
+ payload = @extractor.extract(page)
185
+ if telegram_focal_preview?(url) && payload["contentType"] == "article"
186
+ sleep Browser::PRE_EXTRACTION_SETTLE_WAIT
187
+ payload = @extractor.extract(page)
188
+ end
189
+ build_result(url, page.current_url, payload)
190
+ end
191
+ fallback = seznam_cmp_redirect_fallback_candidate?(url, result) ? @raw_docs_fallback.fetch(url) : nil
192
+ fallback ||= docs_fallback_candidate?(url, result) && poor_docs_result?(result) ? @raw_docs_fallback.fetch(url) : nil
193
+ fallback ||= article_body_fallback_candidate?(result) ? @raw_docs_fallback.fetch(result.final_url) : nil
194
+ result = fallback_result(url, fallback) if fallback
105
195
  log_request(url, t0)
106
- return result
107
- end
196
+ result
197
+ rescue BrowserError, ExtractionError => e
198
+ if e.is_a?(BrowserError) && pending_connections_error?(e) && pending_connection_retries < PENDING_CONNECTIONS_FETCH_RETRIES
199
+ pending_connection_retries += 1
200
+ sleep PENDING_CONNECTIONS_FETCH_RETRY_WAIT
201
+ retry
202
+ end
203
+
204
+ fallback = docs_fallback_candidate?(url) ? @raw_docs_fallback.fetch(url) : nil
205
+ if fallback
206
+ result = fallback_result(url, fallback)
207
+ log_request(url, t0)
208
+ return result
209
+ end
108
210
 
109
- log_request(url, t0)
110
- return network_error_result(url, e) if e.is_a?(BrowserError) && network_error?(e)
211
+ log_request(url, t0)
212
+ return network_error_result(url, e) if e.is_a?(BrowserError) && network_error?(e)
111
213
 
112
- raise e
214
+ raise e
215
+ end
113
216
  end
114
217
 
115
218
  private
116
219
 
220
+ def telegram_focal_preview?(url)
221
+ uri = URI.parse(url)
222
+ host = FetchUtil.strip_www_host(url)
223
+ host == "t.me" && uri.path.match?(%r{\A/s/[^/]+/\d+/?\z})
224
+ rescue URI::InvalidURIError
225
+ false
226
+ end
227
+
117
228
  def build_result(url, final_url, payload)
229
+ raw_final_url = final_url
230
+ raw_canonical_url = payload["canonicalUrl"]
118
231
  final_url = normalized_result_url(final_url)
119
- canonical_url = normalized_result_url(payload["canonicalUrl"])
232
+ canonical_url = normalized_result_url(raw_canonical_url)
233
+ snapshot = PayloadSnapshot.new(
234
+ payload: payload, requested_url: url, final_url: final_url, canonical_url: canonical_url,
235
+ raw_final_url: raw_final_url, raw_canonical_url: raw_canonical_url
236
+ )
120
237
  homepage_like = homepage_like?(final_url)
121
- content_type = resolved_content_type(final_url, homepage_like, payload)
238
+ content_type = resolved_content_type(homepage_like, snapshot)
122
239
  warnings = resolved_warnings(
123
- content_type, homepage_like, payload,
124
- requested_url: url, final_url: final_url, canonical_url: canonical_url
240
+ content_type, homepage_like, snapshot
125
241
  )
126
242
  suspect = warnings.any?
127
243
 
@@ -136,49 +252,76 @@ module FetchUtil
136
252
  )
137
253
  end
138
254
 
139
- def resolved_content_type(final_url, homepage_like, payload)
255
+ def resolved_content_type(homepage_like, snapshot)
256
+ payload = snapshot.payload
257
+ final_url = snapshot.final_url
140
258
  content_type = payload["contentType"] || "article"
141
- return "article" if content_type == "list" && scholarly_article_markdown?(final_url, payload)
259
+ return "article" if content_type == "list" && scholarly_article_markdown?(final_url, snapshot)
260
+ return "article" if content_type == "list" && credible_article_route?(final_url, snapshot)
142
261
 
143
262
  return content_type unless content_type == "article"
144
263
  return content_type if payload["legalProvision"]
145
264
  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)
265
+ return "list" if institutional_case_record_list?(final_url, snapshot)
266
+ return content_type if legal_judgment_markdown?(snapshot) || legal_statute_markdown?(snapshot)
267
+ return content_type if scholarly_article_markdown?(final_url, snapshot)
268
+ return content_type if reference_table_article_markdown?(snapshot)
269
+ return content_type if credible_article_route?(final_url, snapshot)
270
+ return "list" if government_service_portal?(final_url, snapshot)
271
+ return "list" if homepage_like && homepage_index_markdown?(snapshot)
272
+ return "list" if index_list_markdown?(final_url, snapshot)
273
+ return "list" if thin_index_page?(final_url, snapshot)
154
274
 
155
275
  content_type
156
276
  end
157
277
 
158
- def resolved_warnings(content_type, homepage_like, payload, requested_url: nil, final_url: nil, canonical_url: nil)
278
+ def resolved_warnings(content_type, homepage_like, snapshot)
279
+ payload = snapshot.payload
280
+ requested_url = snapshot.requested_url
281
+ final_url = snapshot.final_url
282
+ canonical_url = snapshot.canonical_url
159
283
  trusted_same_organization_redirect = trusted_same_organization_redirect?(
160
- content_type, payload, requested_url, final_url, canonical_url
284
+ content_type, snapshot
161
285
  )
162
- trusted_cross_domain_redirect = trusted_publisher_doi_redirect?(content_type, payload, requested_url, final_url, canonical_url)
286
+ trusted_cross_domain_redirect = trusted_publisher_doi_redirect?(content_type, snapshot)
163
287
  warnings = Array(payload["warnings"]).dup
164
288
  warnings.delete("url_content_mismatch") if trusted_same_organization_redirect
289
+ if stripped_query_only_url_mismatch?(requested_url, final_url, canonical_url, snapshot.raw_final_url,
290
+ snapshot.raw_canonical_url)
291
+ warnings.delete("url_content_mismatch")
292
+ end
293
+ credible_homepage_list = credible_homepage_list_evidence?(content_type, homepage_like, snapshot, warnings)
294
+ warnings.delete("url_content_mismatch") if credible_homepage_list
165
295
  if content_type == "list" && homepage_like && !payload["statusPage"] &&
166
- !substantial_homepage_landing?(payload) && !government_service_portal?(final_url, payload) &&
167
- !research_database_landing?(payload)
296
+ !credible_homepage_list && !substantial_homepage_landing?(snapshot) &&
297
+ !government_service_portal?(final_url, snapshot) && !research_database_landing?(snapshot)
298
+ warnings.delete("url_content_mismatch")
168
299
  warnings << "homepage_index_page"
169
300
  end
170
301
  warnings << "cross_domain_redirect" if cross_domain_redirect?(requested_url, final_url) && !trusted_cross_domain_redirect
171
302
  warnings << "aggregator_redirect_url" if aggregator_url?(requested_url)
172
- warnings << "auth_or_login_interstitial" if auth_redirect_interstitial?(requested_url, final_url, payload)
303
+ warnings << "auth_or_login_interstitial" if auth_redirect_interstitial?(snapshot)
173
304
  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)
305
+ warnings << "not_found_interstitial" if generic_redirect_not_found?(snapshot)
175
306
  if !trusted_same_organization_redirect &&
176
- redirected_title_content_mismatch?(content_type, homepage_like, payload, requested_url, final_url, canonical_url)
307
+ redirected_title_content_mismatch?(content_type, homepage_like, snapshot)
177
308
  warnings << "url_content_mismatch"
178
309
  end
179
310
  warnings.uniq
180
311
  end
181
312
 
313
+ def credible_homepage_list_evidence?(content_type, homepage_like, snapshot, warnings)
314
+ return false unless content_type == "list" && homepage_like
315
+ return false if snapshot.payload["statusPage"]
316
+ return false if warnings.any? { |warning| warning.match?(/(?:access|auth|bot|challenge|consent|empty|error|interstitial|not_found|paywall|short|wall)/i) }
317
+ return false if generic_redirect_not_found?(snapshot) || auth_redirect_interstitial?(snapshot)
318
+ return false if snapshot.normalized_markdown.empty?
319
+
320
+ evidence = snapshot.payload["portalRootEvidence"]
321
+ evidence.is_a?(Hash) && evidence["namedSectionCount"].to_i >= 2 &&
322
+ evidence["canonicalCardCount"].to_i >= 4
323
+ end
324
+
182
325
  def homepage_like?(url)
183
326
  path = URI.parse(url).path
184
327
  path.nil? || path.empty? || path == "/"
@@ -186,27 +329,25 @@ module FetchUtil
186
329
  false
187
330
  end
188
331
 
189
- def homepage_index_markdown?(title, markdown)
190
- snippet = [title, markdown].compact.join(" ")
191
- return false unless snippet.match?(HOMEPAGE_INDEX_PATTERN)
332
+ def homepage_index_markdown?(snapshot)
333
+ return false unless snapshot.context.match?(HOMEPAGE_INDEX_PATTERN)
192
334
 
193
- markdown.to_s.lines.grep(/^\s*(?:\d+\.\s+|[-*]\s+)/).count >= 3
335
+ snapshot.plain_list_item_count >= 3
194
336
  end
195
337
 
196
- def government_service_portal?(url, payload)
197
- markdown = payload["markdown"].to_s
198
- normalized = FetchUtil.normalize_whitespace(markdown)
338
+ def government_service_portal?(url, snapshot)
339
+ normalized = snapshot.normalized_markdown
199
340
  return false if normalized.length < 250
200
- return false unless government_domain?(url) || government_service_language?(payload)
341
+ return false unless government_domain?(url) || government_service_language?(snapshot)
201
342
 
202
- context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
343
+ context = snapshot.context_downcase
203
344
  service_pattern = /\b(?:service|services|servi[cç]os?|servicio|servicios|service category|categories|
204
345
  categorias?|citizens?|business(?:es)?|benefits?|permits?|licen[cs]es?)\b/ix
205
346
  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
347
+ linked_items = snapshot.linked_heading_count + snapshot.linked_item_count
348
+ plain_items = snapshot.plain_list_item_count
208
349
 
209
- service_terms >= 3 && (linked_items >= 4 || plain_items >= 4 || markdown.scan(%r{\]\(https?://}).count >= 6)
350
+ service_terms >= 3 && (linked_items >= 4 || plain_items >= 4 || snapshot.markdown_link_count >= 6)
210
351
  end
211
352
 
212
353
  def government_domain?(url)
@@ -218,22 +359,20 @@ module FetchUtil
218
359
  false
219
360
  end
220
361
 
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)
362
+ def government_service_language?(snapshot)
363
+ snapshot.context_downcase.match?(/\b(?:government|governance|public services?|servi[]os? p[úu]blicos?|national portal|citizen services?)\b/i)
224
364
  end
225
365
 
226
- def institutional_case_record_list?(url, payload)
227
- markdown = payload["markdown"].to_s
228
- normalized = FetchUtil.normalize_whitespace(markdown)
366
+ def institutional_case_record_list?(url, snapshot)
367
+ normalized = snapshot.normalized_markdown
229
368
  return false if normalized.length < 500
230
369
 
231
370
  path = URI.parse(url).path.to_s.downcase
232
- context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown.lines.first(20).join(" ")].join(" "))
371
+ context = snapshot.first_20_context
233
372
  return false unless path.match?(%r{/(?:cases?|defendants?|records?|dockets?|matters?)/?\z}) ||
234
373
  context.match?(/\b\d{1,4}\s+(?:cases?|defendants?|records?|matters?)\b/i)
235
374
 
236
- linked_case_headings = markdown.scan(
375
+ linked_case_headings = snapshot.markdown.scan(
237
376
  %r{^\s*\#{1,6}\s+\[[^\]]{3,180}\]\([^)]*/(?:cases?|defendants?|situations?|darfur|mali|kenya|libya|uganda|congo|afghanistan|ukraine|records?|dockets?)[^)]*\)}i
238
377
  ).count
239
378
  case_terms = normalized.scan(
@@ -245,60 +384,72 @@ module FetchUtil
245
384
  false
246
385
  end
247
386
 
248
- def substantial_homepage_landing?(payload)
249
- markdown = payload["markdown"].to_s
250
- normalized = FetchUtil.normalize_whitespace(markdown)
387
+ def substantial_homepage_landing?(snapshot)
388
+ normalized = snapshot.normalized_markdown
251
389
  return false if normalized.length < 1_200
252
390
 
253
- context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
391
+ context = snapshot.context_downcase
254
392
  landing_pattern = /\b(docs?|documentation|api|reference|guide|guides|developer|framework|next\.js|mdx|
255
393
  static websites?|components?|themes?|product|platform)\b/x
256
394
  return false unless context.match?(landing_pattern)
257
395
 
258
- prose_lines = markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
259
- prose_lines.any? { |line| FetchUtil.normalize_whitespace(line).length >= 120 }
396
+ snapshot.long_prose_line_count_one_twenty.positive?
260
397
  end
261
398
 
262
- def research_database_landing?(payload)
263
- markdown = payload["markdown"].to_s
264
- normalized = FetchUtil.normalize_whitespace(markdown)
399
+ def research_database_landing?(snapshot)
400
+ normalized = snapshot.normalized_markdown
265
401
  return false if normalized.length < 250
266
402
 
267
- context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
403
+ context = snapshot.context_downcase
268
404
  research_terms = /\b(?:database|data resource|repository|multi-omics|proteomics|transcriptomics|
269
405
  phenomics|genomics|metabolomics|life science research|scientific resource)\b/ix
270
406
  return false unless context.match?(research_terms)
271
407
  return false if context.match?(HOMEPAGE_INDEX_PATTERN)
272
408
 
273
- prose_lines = markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
274
- prose_lines.any? { |line| FetchUtil.normalize_whitespace(line).length >= 100 }
409
+ snapshot.long_prose_line_count_one_hundred.positive?
275
410
  end
276
411
 
277
- def index_list_markdown?(url, payload)
412
+ def index_list_markdown?(url, snapshot)
278
413
  return false unless index_or_search_url?(url)
279
414
  return false if article_like_url?(url)
280
415
 
281
- markdown = payload["markdown"].to_s
282
- return false if legal_judgment_markdown?(markdown) || legal_statute_markdown?(markdown)
416
+ return false if legal_judgment_markdown?(snapshot) || legal_statute_markdown?(snapshot)
283
417
 
284
- linked_headlines = markdown.scan(LINKED_MARKDOWN_HEADING_PATTERN).count
285
- linked_items = markdown.scan(LINKED_MARKDOWN_ITEM_PATTERN).count
418
+ linked_headlines = snapshot.linked_heading_count
419
+ linked_items = snapshot.linked_item_count
286
420
 
287
421
  linked_headlines + linked_items >= 4
288
422
  end
289
423
 
290
- def scholarly_article_markdown?(url, payload)
291
- markdown = payload["markdown"].to_s
292
- normalized = FetchUtil.normalize_whitespace(markdown)
424
+ def credible_article_route?(url, snapshot)
425
+ path = URI.parse(url).path.to_s
426
+ a_detail = path.match?(%r{(?:^|/)a-[a-z0-9]+(?:/|$)}i)
427
+ bbc_detail = path.match?(%r{/(?:articles?)/[a-z0-9]+(?:/|$)}i)
428
+ detail_path = a_detail || bbc_detail
429
+ return false unless detail_path
430
+ return false unless snapshot.heading_count >= 1
431
+ return false unless snapshot.normalized_markdown.length >= 420
432
+ return false unless snapshot.long_prose_line_count_one_twenty >= 2
433
+ return false if a_detail && snapshot.payload["byline"].to_s.strip.empty? && snapshot.payload["publishedTime"].to_s.strip.empty?
434
+
435
+ true
436
+ rescue URI::InvalidURIError
437
+ false
438
+ end
439
+
440
+ def scholarly_article_markdown?(url, snapshot)
441
+ payload = snapshot.payload
442
+ normalized = snapshot.normalized_markdown
293
443
  return false if normalized.length < 5_000
294
444
  return false unless article_like_url?(url) || doi_article_url?(url) || doi_article_url?(payload["canonicalUrl"])
295
445
  return false if payload["byline"].to_s.strip.empty? && payload["publishedTime"].to_s.strip.empty?
296
446
 
297
- context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown.lines.first(80).join(" ")].join(" "))
447
+ context = snapshot.first_80_context
298
448
  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 }
449
+ section_headings = snapshot.markdown.scan(
450
+ /^\s*\#{1,4}\s+(?:Abstract|Introduction|Methods?|Materials and methods|Results?|Discussion|Conclusion|References)\b/i
451
+ ).count
452
+ long_prose_lines = snapshot.long_prose_line_count_one_forty
302
453
 
303
454
  return true if scholarly_context && section_headings >= 3 && long_prose_lines >= 5
304
455
 
@@ -313,38 +464,39 @@ module FetchUtil
313
464
  false
314
465
  end
315
466
 
316
- def thin_index_page?(url, payload)
467
+ def thin_index_page?(url, snapshot)
468
+ payload = snapshot.payload
317
469
  return false unless index_or_search_url?(url)
318
470
  return false if article_like_url?(url)
319
471
  return false if payload["byline"].to_s.strip != "" || payload["publishedTime"].to_s.strip != ""
320
472
 
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"])
473
+ markdown = snapshot.normalized_markdown
474
+ return false if legal_judgment_markdown?(snapshot) || legal_statute_markdown?(snapshot)
475
+ return false if reference_table_article_markdown?(snapshot)
324
476
 
325
477
  markdown.length < 2400
326
478
  end
327
479
 
328
- def reference_table_article_markdown?(markdown)
329
- raw = markdown.to_s
330
- text = FetchUtil.normalize_whitespace(raw)
480
+ def reference_table_article_markdown?(snapshot)
481
+ raw = snapshot.markdown
482
+ text = snapshot.normalized_markdown
331
483
  return false if text.length < 700
332
484
  return false unless raw.match?(/^\|\s*[-: ]+\|/)
333
- return false if raw.lines.grep(/^\s*- \[/).count >= 3
485
+ return false if snapshot.list_link_count >= 3
334
486
 
335
487
  prose_blocks = raw.split(/\n{2,}/).count do |block|
336
488
  stripped = block.strip
337
489
  normalized = FetchUtil.normalize_whitespace(stripped)
338
490
  !stripped.empty? && !stripped.start_with?("|", "#") && normalized.length >= 80 && normalized.match?(/[.!?)]\z/)
339
491
  end
340
- table_rows = raw.lines.grep(/^\|/).count
341
- headings = raw.lines.grep(/^(?:#){1,3}\s+/).count
492
+ table_rows = snapshot.table_row_count
493
+ headings = snapshot.heading_count
342
494
 
343
495
  headings >= 1 && prose_blocks >= 2 && table_rows.between?(4, 40)
344
496
  end
345
497
 
346
- def legal_judgment_markdown?(markdown)
347
- text = FetchUtil.normalize_whitespace(markdown.to_s)
498
+ def legal_judgment_markdown?(snapshot)
499
+ text = snapshot.normalized_markdown
348
500
  return false if text.length < 5_000
349
501
  return false if text.match?(/\bresults?\s+\d+\s*[-–]\s*\d+\s+(?:of|sur|von|de)\s+\d+\b/i)
350
502
 
@@ -356,12 +508,11 @@ module FetchUtil
356
508
  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
509
  return false if signals < 3
358
510
 
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
511
+ snapshot.long_prose_line_count_one_twenty >= 5 || text.length >= 20_000
361
512
  end
362
513
 
363
- def legal_statute_markdown?(markdown)
364
- text = FetchUtil.normalize_whitespace(markdown.to_s)
514
+ def legal_statute_markdown?(snapshot)
515
+ text = snapshot.normalized_markdown
365
516
  return false if text.length < 5_000
366
517
  return false if text.match?(/\bresults?\s+\d+\s*[-–]\s*\d+\s+(?:of|sur|von|de)\s+\d+\b/i)
367
518
 
@@ -401,13 +552,15 @@ module FetchUtil
401
552
  false
402
553
  end
403
554
 
404
- def auth_redirect_interstitial?(requested_url, final_url, payload)
555
+ def auth_redirect_interstitial?(snapshot)
556
+ requested_url = snapshot.requested_url
557
+ final_url = snapshot.final_url
405
558
  return false if requested_url.nil? || final_url.nil?
406
559
  return false unless auth_path?(final_url)
407
560
  return false if auth_path?(requested_url)
408
561
  return false unless index_or_search_url?(requested_url)
409
562
 
410
- text = FetchUtil.normalize_whitespace([payload["title"], payload["markdown"], payload["excerpt"]].compact.join(" ")).downcase
563
+ text = snapshot.context_with_excerpt_downcase
411
564
  text.match?(/\b(?:log in|login|sign in|sign-in)\b/) &&
412
565
  text.match?(/\b(?:github|gitlab|google|oauth|sso|single sign-on|password|account)\b/)
413
566
  end
@@ -429,16 +582,18 @@ module FetchUtil
429
582
  false
430
583
  end
431
584
 
432
- def generic_redirect_not_found?(requested_url, final_url, payload)
585
+ def generic_redirect_not_found?(snapshot)
586
+ requested_url = snapshot.requested_url
587
+ final_url = snapshot.final_url
433
588
  return false if requested_url.nil? || final_url.nil?
434
589
  return false unless same_effective_domain?(requested_url, final_url)
435
590
 
436
- requested_path = normalized_path_key(requested_url)
437
- final_path = normalized_path_key(final_url)
591
+ requested_path = snapshot.path_key(requested_url)
592
+ final_path = snapshot.path_key(final_url)
438
593
  return false if requested_path.empty? || requested_path == final_path
439
594
  return false unless specific_content_path?(requested_path)
440
595
  return false unless generic_redirect_path?(final_path)
441
- return false if redirected_payload_matches_requested_path?(requested_path, payload)
596
+ return false if redirected_payload_matches_requested_path?(requested_path, snapshot.payload)
442
597
 
443
598
  true
444
599
  end
@@ -472,6 +627,19 @@ module FetchUtil
472
627
  markdown.length < 1_500 && markdown.lines.grep(/^\s*[-*]\s+\[/).count >= 4
473
628
  end
474
629
 
630
+ def seznam_cmp_redirect_fallback_candidate?(requested_url, result)
631
+ requested_host = FetchUtil.strip_www_host(requested_url)
632
+ return false unless requested_host == "novinky.cz"
633
+
634
+ final_uri = URI.parse(result.final_url)
635
+ return false unless final_uri.host == "cmp.seznam.cz" && final_uri.path.start_with?("/nastaveni-souhlasu")
636
+
637
+ consent_text = FetchUtil.normalize_whitespace([result.title, result.markdown].compact.join(" "))
638
+ consent_text.match?(/nastavení souhlasu|souhlas s personalizací|unable to load/i)
639
+ rescue URI::InvalidURIError
640
+ false
641
+ end
642
+
475
643
  def slug_article_url?(url)
476
644
  segments = URI.parse(url).path.to_s.split("/").reject(&:empty?)
477
645
  segments.length == 1 && segments.first.include?("-")
@@ -479,13 +647,16 @@ module FetchUtil
479
647
  false
480
648
  end
481
649
 
482
- def redirected_title_content_mismatch?(content_type, homepage_like, payload, requested_url, final_url, canonical_url)
650
+ def redirected_title_content_mismatch?(content_type, homepage_like, snapshot)
651
+ payload = snapshot.payload
652
+ requested_url = snapshot.requested_url
653
+ final_url = snapshot.final_url
483
654
  return false unless content_type == "article"
484
655
  return false if homepage_like || payload["docsLike"]
485
656
  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) }
657
+ return false if snapshot.resource_urls.any? { |url| snapshot.search_or_list_resource_url?(url) }
487
658
 
488
- resource_url = mismatched_resource_url(requested_url, final_url, canonical_url)
659
+ resource_url = mismatched_resource_url(snapshot)
489
660
  return false if resource_url.nil?
490
661
 
491
662
  requested_keywords = title_slug_keywords(requested_url)
@@ -497,20 +668,21 @@ module FetchUtil
497
668
  (requested_keywords & resolved_keywords).empty?
498
669
  end
499
670
 
500
- def trusted_same_organization_redirect?(content_type, payload, requested_url, final_url, canonical_url)
671
+ def trusted_same_organization_redirect?(content_type, snapshot)
672
+ payload = snapshot.payload
673
+ requested_url = snapshot.requested_url
674
+ final_url = snapshot.final_url
501
675
  return false unless content_type == "article"
502
676
  return false if requested_url.nil? || final_url.nil?
503
677
  return false unless same_effective_domain?(requested_url, final_url)
504
678
  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) }
679
+ return false if snapshot.resource_urls.any? { |url| snapshot.search_or_list_resource_url?(url) }
506
680
  return true if matching_apex_instrument_redirect?(payload, requested_url, final_url)
507
681
 
508
682
  tokens = requested_identifier_tokens(requested_url)
509
683
  return false if tokens.empty?
510
684
 
511
- content = FetchUtil.normalize_whitespace(
512
- [payload["title"], payload["markdown"]].compact.join(" ")
513
- ).downcase
685
+ content = snapshot.content_downcase
514
686
  matches = tokens.select { |token| content.include?(token) }
515
687
 
516
688
  matches.any? { |token| code_like_identifier?(token) } || matches.length >= 2
@@ -518,12 +690,14 @@ module FetchUtil
518
690
  false
519
691
  end
520
692
 
521
- def trusted_publisher_doi_redirect?(content_type, payload, requested_url, final_url, canonical_url)
693
+ def trusted_publisher_doi_redirect?(content_type, snapshot)
694
+ requested_url = snapshot.requested_url
695
+ final_url = snapshot.final_url
522
696
  return false unless content_type == "article"
523
697
  return false unless cross_domain_redirect?(requested_url, final_url)
524
- return false unless scholarly_article_markdown?(final_url, payload)
698
+ return false unless scholarly_article_markdown?(final_url, snapshot)
525
699
 
526
- dois = [requested_url, final_url, canonical_url].filter_map { |url| doi_from_url(url) }.uniq
700
+ dois = snapshot.resource_urls.filter_map { |url| doi_from_url(url) }.uniq
527
701
  dois.length == 1
528
702
  end
529
703
 
@@ -572,16 +746,19 @@ module FetchUtil
572
746
  token.match?(/\A(?=.*[a-z])(?=.*\d)[a-z0-9]{3,16}\z/)
573
747
  end
574
748
 
575
- def mismatched_resource_url(requested_url, final_url, canonical_url)
576
- requested_path = normalized_path_key(requested_url)
749
+ def mismatched_resource_url(snapshot)
750
+ requested_url = snapshot.requested_url
751
+ final_url = snapshot.final_url
752
+ canonical_url = snapshot.canonical_url
753
+ requested_path = snapshot.path_key(requested_url)
577
754
  return nil if requested_path.empty?
578
755
 
579
- final_path = normalized_path_key(final_url)
756
+ final_path = snapshot.path_key(final_url)
580
757
  return final_url if !final_path.empty? && final_path != requested_path
581
758
 
582
759
  return nil unless same_effective_domain?(final_url, canonical_url)
583
760
 
584
- canonical_path = normalized_path_key(canonical_url)
761
+ canonical_path = snapshot.path_key(canonical_url)
585
762
  return canonical_url if !canonical_path.empty? && canonical_path != requested_path
586
763
 
587
764
  nil
@@ -595,25 +772,6 @@ module FetchUtil
595
772
  !left_domain.nil? && left_domain == right_domain
596
773
  end
597
774
 
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
775
  def title_slug_keywords(url)
618
776
  URI.parse(url).path.to_s.split("/").reject(&:empty?).reverse_each do |segment|
619
777
  tokens = significant_slug_tokens(segment)
@@ -645,10 +803,108 @@ module FetchUtil
645
803
  Result.error(url: url, warning: warning, message: message)
646
804
  end
647
805
 
806
+ def direct_pdf_result(url)
807
+ pdf_info = if pdf_url?(url)
808
+ { final_url: url, headers: {} }
809
+ else
810
+ pdf_header_info(url)
811
+ end
812
+ return nil unless pdf_info
813
+
814
+ final_url = normalized_result_url(pdf_info.fetch(:final_url, url))
815
+ headers = pdf_info.fetch(:headers, {})
816
+ Result.from_payload(
817
+ url: url,
818
+ final_url: final_url,
819
+ canonical_url: nil,
820
+ payload: {
821
+ "title" => pdf_title(final_url, headers),
822
+ "markdown" => "",
823
+ "contentType" => "pdf_document",
824
+ "warnings" => ["pdf_document"]
825
+ },
826
+ content_type: "pdf_document",
827
+ suspect: true,
828
+ warnings: ["pdf_document"]
829
+ )
830
+ end
831
+
832
+ def pdf_header_info(url)
833
+ info = @pdf_header_probe ? @pdf_header_probe.call(url) : probe_pdf_headers(url)
834
+ return nil unless pdf_content_type?(info[:headers])
835
+
836
+ info
837
+ rescue URI::InvalidURIError, ArgumentError, FetchUtil::Error, IOError, SocketError, SystemCallError, Timeout::Error
838
+ nil
839
+ end
840
+
841
+ def probe_pdf_headers(url, limit = PDF_REDIRECT_LIMIT)
842
+ uri = parse_http_uri(url)
843
+ response = request_head(uri)
844
+ if response.is_a?(Net::HTTPRedirection) && limit.positive? && response["location"].to_s.strip != ""
845
+ return probe_pdf_headers(uri.merge(response["location"]).to_s, limit - 1)
846
+ end
847
+
848
+ { final_url: uri.to_s, headers: response.to_hash.transform_keys(&:downcase) }
849
+ end
850
+
851
+ def request_head(uri)
852
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |http|
853
+ request = Net::HTTP::Head.new(uri.request_uri.empty? ? "/" : uri.request_uri)
854
+ http.request(request)
855
+ end
856
+ end
857
+
858
+ def parse_http_uri(url)
859
+ uri = URI.parse(url.to_s)
860
+ raise URI::InvalidURIError, "unsupported url: #{url}" unless uri.is_a?(URI::HTTP) && uri.host
861
+
862
+ uri
863
+ end
864
+
865
+ def pdf_url?(url)
866
+ URI.parse(url.to_s).then do |uri|
867
+ [uri.path, uri.query && "?#{uri.query}"].compact.join.match?(PDF_PATH_PATTERN)
868
+ end
869
+ rescue URI::InvalidURIError
870
+ false
871
+ end
872
+
873
+ def pdf_content_type?(headers)
874
+ Array(headers&.fetch("content-type", nil)).any? { |value| value.to_s.match?(PDF_CONTENT_TYPE_PATTERN) }
875
+ end
876
+
877
+ def pdf_title(url, headers)
878
+ title_from_content_disposition(headers) || title_from_url(url)
879
+ end
880
+
881
+ def title_from_content_disposition(headers)
882
+ value = Array(headers["content-disposition"]).first.to_s
883
+ filename = value[/filename\*=UTF-8''([^;]+)/i, 1] || value[/filename="?([^";]+)"?/i, 1]
884
+ return nil if filename.to_s.strip.empty?
885
+
886
+ URI.decode_www_form_component(filename).strip
887
+ rescue ArgumentError
888
+ filename.to_s.strip
889
+ end
890
+
891
+ def title_from_url(url)
892
+ path = URI.parse(url.to_s).path.to_s
893
+ basename = File.basename(path)
894
+ title = basename.empty? || basename == "/" ? URI.parse(url.to_s).host.to_s : basename
895
+ URI.decode_www_form_component(title.tr("+", " ")).strip
896
+ rescue URI::InvalidURIError, ArgumentError
897
+ url.to_s
898
+ end
899
+
648
900
  def network_error?(error)
649
901
  error.message.to_s.match?(NETWORK_ERROR_PATTERN)
650
902
  end
651
903
 
904
+ def pending_connections_error?(error)
905
+ error.message.to_s.match?(/pending connections/i)
906
+ end
907
+
652
908
  def dns_resolution_error?(message)
653
909
  message.match?(/ERR_NAME_NOT_RESOLVED|DNS|resolve|resolution|ENOTFOUND|EAI_AGAIN/i)
654
910
  end
@@ -731,15 +987,32 @@ module FetchUtil
731
987
  end
732
988
 
733
989
  def normalized_result_url(url)
990
+ strip_tracking_params(url)
991
+ end
992
+
993
+ def strip_tracking_params(url)
734
994
  return url if url.nil? || url.empty?
735
995
 
736
996
  uri = URI.parse(url)
737
997
  params = URI.decode_www_form(uri.query.to_s)
738
- params.reject! { |key, _value| STRIPPED_QUERY_PARAM_PATTERNS.any? { |pattern| key.match?(pattern) } }
998
+ params.reject! { |key, _value| tracking_query_param?(key) }
739
999
  uri.query = params.empty? ? nil : URI.encode_www_form(params)
740
1000
  uri.to_s
741
1001
  rescue URI::InvalidURIError
742
1002
  url
743
1003
  end
1004
+
1005
+ def tracking_query_param?(key)
1006
+ TRACKING_QUERY_PARAM_PATTERNS.any? { |pattern| key.match?(pattern) }
1007
+ end
1008
+
1009
+ def stripped_query_only_url_mismatch?(requested_url, final_url, canonical_url, raw_final_url, raw_canonical_url)
1010
+ normalized_requested_url = normalized_result_url(requested_url)
1011
+ return false if normalized_requested_url.nil? || final_url.nil?
1012
+ return false unless normalized_requested_url == final_url
1013
+ return false if canonical_url && canonical_url != normalized_requested_url
1014
+
1015
+ [raw_final_url, raw_canonical_url].compact.any? { |url| normalized_result_url(url) != url }
1016
+ end
744
1017
  end
745
1018
  end