fetch_util 0.3.0 → 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.
@@ -20,13 +20,139 @@ module FetchUtil
20
20
  Regexp::IGNORECASE
21
21
  ).freeze
22
22
  DOCS_PORTAL_TITLE_PATTERN = /documentation|docs|the ultimate server/i
23
- STRIPPED_QUERY_PARAM_PATTERNS = [
23
+ INDEX_OR_SEARCH_PATH_PATTERN = %r{
24
+ /(?:search|s|shop|browse|category|categories|collections?|catalog|keyword|wholesale|
25
+ products?|projects?|jobs?|section|sections|topics?|tags?|archive|archives|latest|headlines|news)/?
26
+ }ix
27
+ AUTH_PATH_PATTERN = %r{
28
+ /(?:log(?:in|-in)|sign(?:in|-in)|auth|oauth|sso|session|sessions|
29
+ accounts?/login|users?/sign_in|password|forgot)(?:/|$)
30
+ }ix
31
+ ARTICLE_PATH_PATTERN = %r{
32
+ /(?:20\d{2}|\d{4}/\d{2}/\d{2}|article|articles|blog|blogs|column|columns|
33
+ entry|entries|post|posts|news/[\w-]+|wiki|dictionary|definition|definitions|thesaurus|\d{5,}[\w-]*\.html?)\b
34
+ }ix
35
+ LINKED_MARKDOWN_HEADING_PATTERN = /(?:^|\s)(?:(?:\d+\.|[-*])\s+)?\#{1,4}\s+\[[^\]]{8,220}\]\(/
36
+ LINKED_MARKDOWN_ITEM_PATTERN = /(?:^|\s)(?:\d+\.|[-*])\s+\[[^\]]{8,220}\]\(/
37
+ INDEX_QUERY_PATTERN = /(?:^|[&?])(?:q|query|search|searchtext|keyword|k)=/i
38
+ PDF_PATH_PATTERN = %r{(?:\.pdf\z|/pdf(?:/|\z)|[?&](?:format|download)=pdf\b)}i
39
+ LEGAL_STATUTE_TITLE_PATTERN = Regexp.new(
40
+ "constitution|constitutional|constitui[cç]?[aã]o|c[oó]digo|codigo|code|statute|act|law|" \
41
+ "regulation|ordinance|decree|treaty|convention",
42
+ Regexp::IGNORECASE
43
+ ).freeze
44
+ LEGAL_PROVISION_MARKER_PATTERN = /(?:^|\s)(?:Art\.?|Article|Section|Sec\.?|§)\s*(?:\d+[ºª]?|[IVXLCDM]+)/i
45
+ LEGAL_PROVISION_STRUCTURAL_PATTERN = /\b(?:title|chapter|part|book|t[ií]tulo|cap[ií]tulo|se[cç][aã]o)\s+(?:[IVXLCDM]+|\d+)/i
46
+ LEGAL_PROVISION_TERMS_PATTERN = Regexp.new(
47
+ "federal republic|rep[úu]blica federativa|republica federativa|civil rights|fundamental rights|" \
48
+ "legal provisions?|official gazette|promulgat|enacted|amended|paragraph|par[áa]grafo|paragrafo|" \
49
+ "inciso|subsection",
50
+ Regexp::IGNORECASE
51
+ ).freeze
52
+ TRACKING_QUERY_PARAM_PATTERNS = [
24
53
  /\A(?:__goaway_|__cf_chl_)/,
25
- /\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,
26
55
  /\A__gr(?:sc|ts|ua|rn)\z/
27
56
  ].freeze
57
+ TITLE_SLUG_STOPWORDS = %w[
58
+ about all and article articles blog book books browse category categories chapter
59
+ collection collections content docs edition editions en for from guide home html
60
+ index latest new news page pages post posts product products search show shop st
61
+ street tag tags the this topic topics unit with work works www your
62
+ ].freeze
63
+ SEARCH_OR_LIST_PATH_SEGMENTS = %w[
64
+ archive archives browse catalog categories category collection collections headlines
65
+ jobs keyword latest news product products projects s search section sections shop
66
+ tag tags topic topics wholesale
67
+ ].freeze
68
+ CONTENT_ROUTE_SEGMENTS = %w[article articles content paper papers preprint preprints].freeze
28
69
  SECOND_LEVEL_COUNTRY_TLDS = /\A(co|com|org|net|gov|edu|ac)\z/
29
70
  GOOGLE_HOST_PATTERN = /\Agoogle\.[a-z.]+\z/
71
+ NETWORK_ERROR_PATTERN = Regexp.new(
72
+ "\\b(?:net::ERR_|ERR_NAME_NOT_RESOLVED|pending connections|DNS|resolve|resolution|ENOTFOUND|" \
73
+ "EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out|timeout|" \
74
+ "connection (?:refused|reset|closed)|disconnected|network)\\b",
75
+ Regexp::IGNORECASE
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
30
156
 
31
157
  def initialize(browser: nil, extractor: nil, **options)
32
158
  @timeout = options.fetch(:timeout, 20)
@@ -42,93 +168,120 @@ module FetchUtil
42
168
 
43
169
  def fetch(url)
44
170
  t0 = monotonic_now
45
- result = @browser.with_page(url) do |page|
46
- payload = @extractor.extract(page)
47
- build_result(url, page.current_url, payload)
48
- end
49
- fallback = docs_fallback_candidate?(url, result) && poor_docs_result?(result) ? @raw_docs_fallback.fetch(url) : nil
50
- result = fallback_result(url, fallback) if fallback
51
- log_request(url, t0)
52
- result
53
- rescue BrowserError, ExtractionError => e
54
- fallback = docs_fallback_candidate?(url) ? @raw_docs_fallback.fetch(url) : nil
55
- if fallback
56
- 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
57
182
  log_request(url, t0)
58
- return result
59
- 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
197
+
198
+ log_request(url, t0)
199
+ return network_error_result(url, e) if e.is_a?(BrowserError) && network_error?(e)
60
200
 
61
- log_request(url, t0)
62
- raise e
201
+ raise e
202
+ end
63
203
  end
64
204
 
65
205
  private
66
206
 
67
207
  def build_result(url, final_url, payload)
208
+ raw_final_url = final_url
209
+ raw_canonical_url = payload["canonicalUrl"]
68
210
  final_url = normalized_result_url(final_url)
69
- 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
+ )
70
216
  homepage_like = homepage_like?(final_url)
71
- content_type = resolved_content_type(homepage_like, payload)
72
- warnings = resolved_warnings(content_type, homepage_like, payload, requested_url: url, final_url: final_url)
217
+ content_type = resolved_content_type(homepage_like, snapshot)
218
+ warnings = resolved_warnings(
219
+ content_type, homepage_like, snapshot
220
+ )
73
221
  suspect = warnings.any?
74
- completeness_ratio = payload["contentCompletenessRatio"]&.to_f || 1.0
75
- content_format = payload["contentFormat"]
76
- paywall_state = payload["paywallState"]
77
-
78
- metadata = {
79
- title: payload["title"],
80
- byline: payload["byline"],
81
- excerpt: payload["excerpt"],
82
- site_name: payload["siteName"],
83
- published_time: payload["publishedTime"],
84
- canonical_url: canonical_url,
85
- language: payload["language"],
86
- content_url: final_url,
87
- reader_mode: payload["readerMode"],
88
- content_type: content_type,
89
- suspect: suspect,
90
- warnings: warnings,
91
- content_completeness_ratio: completeness_ratio,
92
- content_format: content_format,
93
- paywall_state: paywall_state
94
- }.freeze
95
222
 
96
- Result.new(
223
+ Result.from_payload(
97
224
  url: url,
98
225
  final_url: final_url,
99
- title: payload["title"],
100
- byline: payload["byline"],
101
- excerpt: payload["excerpt"],
102
- site_name: payload["siteName"],
103
- published_time: payload["publishedTime"],
226
+ payload: payload,
104
227
  canonical_url: canonical_url,
105
- language: payload["language"],
106
- html: payload["html"],
107
- markdown: payload["markdown"],
108
- metadata: metadata,
109
- reader_mode: payload["readerMode"],
110
228
  content_type: content_type,
111
229
  suspect: suspect,
112
- warnings: warnings,
113
- content_completeness_ratio: completeness_ratio,
114
- content_format: content_format,
115
- paywall_state: paywall_state
230
+ warnings: warnings
116
231
  )
117
232
  end
118
233
 
119
- def resolved_content_type(homepage_like, payload)
234
+ def resolved_content_type(homepage_like, snapshot)
235
+ payload = snapshot.payload
236
+ final_url = snapshot.final_url
120
237
  content_type = payload["contentType"] || "article"
238
+ return "article" if content_type == "list" && scholarly_article_markdown?(final_url, snapshot)
239
+
121
240
  return content_type unless content_type == "article"
122
- return "list" if homepage_like && homepage_index_markdown?(payload["title"], payload["markdown"])
241
+ return content_type if payload["legalProvision"]
242
+ return content_type if payload["hostAware"]
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)
123
251
 
124
252
  content_type
125
253
  end
126
254
 
127
- def resolved_warnings(content_type, homepage_like, payload, requested_url: nil, final_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
260
+ trusted_same_organization_redirect = trusted_same_organization_redirect?(
261
+ content_type, snapshot
262
+ )
263
+ trusted_cross_domain_redirect = trusted_publisher_doi_redirect?(content_type, snapshot)
128
264
  warnings = Array(payload["warnings"]).dup
129
- warnings << "homepage_index_page" if content_type == "list" && homepage_like
130
- warnings << "cross_domain_redirect" if cross_domain_redirect?(requested_url, final_url)
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
270
+ if content_type == "list" && homepage_like && !payload["statusPage"] &&
271
+ !substantial_homepage_landing?(snapshot) && !government_service_portal?(final_url, snapshot) &&
272
+ !research_database_landing?(snapshot)
273
+ warnings.delete("url_content_mismatch")
274
+ warnings << "homepage_index_page"
275
+ end
276
+ warnings << "cross_domain_redirect" if cross_domain_redirect?(requested_url, final_url) && !trusted_cross_domain_redirect
131
277
  warnings << "aggregator_redirect_url" if aggregator_url?(requested_url)
278
+ warnings << "auth_or_login_interstitial" if auth_redirect_interstitial?(snapshot)
279
+ warnings << "pdf_document" if pdf_document?(requested_url, final_url, payload)
280
+ warnings << "not_found_interstitial" if generic_redirect_not_found?(snapshot)
281
+ if !trusted_same_organization_redirect &&
282
+ redirected_title_content_mismatch?(content_type, homepage_like, snapshot)
283
+ warnings << "url_content_mismatch"
284
+ end
132
285
  warnings.uniq
133
286
  end
134
287
 
@@ -139,17 +292,476 @@ module FetchUtil
139
292
  false
140
293
  end
141
294
 
142
- def homepage_index_markdown?(title, markdown)
143
- snippet = [title, markdown].compact.join(" ")
144
- return false unless snippet.match?(HOMEPAGE_INDEX_PATTERN)
295
+ def homepage_index_markdown?(snapshot)
296
+ return false unless snapshot.context.match?(HOMEPAGE_INDEX_PATTERN)
297
+
298
+ snapshot.plain_list_item_count >= 3
299
+ end
300
+
301
+ def government_service_portal?(url, snapshot)
302
+ normalized = snapshot.normalized_markdown
303
+ return false if normalized.length < 250
304
+ return false unless government_domain?(url) || government_service_language?(snapshot)
305
+
306
+ context = snapshot.context_downcase
307
+ service_pattern = /\b(?:service|services|servi[cç]os?|servicio|servicios|service category|categories|
308
+ categorias?|citizens?|business(?:es)?|benefits?|permits?|licen[cs]es?)\b/ix
309
+ service_terms = context.scan(service_pattern).length
310
+ linked_items = snapshot.linked_heading_count + snapshot.linked_item_count
311
+ plain_items = snapshot.plain_list_item_count
312
+
313
+ service_terms >= 3 && (linked_items >= 4 || plain_items >= 4 || snapshot.markdown_link_count >= 6)
314
+ end
315
+
316
+ def government_domain?(url)
317
+ host = FetchUtil.strip_www_host(url)
318
+ labels = host.split(".")
319
+
320
+ host.end_with?(".gov") || labels.include?("gov")
321
+ rescue URI::InvalidURIError
322
+ false
323
+ end
324
+
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)
327
+ end
328
+
329
+ def institutional_case_record_list?(url, snapshot)
330
+ normalized = snapshot.normalized_markdown
331
+ return false if normalized.length < 500
332
+
333
+ path = URI.parse(url).path.to_s.downcase
334
+ context = snapshot.first_20_context
335
+ return false unless path.match?(%r{/(?:cases?|defendants?|records?|dockets?|matters?)/?\z}) ||
336
+ context.match?(/\b\d{1,4}\s+(?:cases?|defendants?|records?|matters?)\b/i)
337
+
338
+ linked_case_headings = snapshot.markdown.scan(
339
+ %r{^\s*\#{1,6}\s+\[[^\]]{3,180}\]\([^)]*/(?:cases?|defendants?|situations?|darfur|mali|kenya|libya|uganda|congo|afghanistan|ukraine|records?|dockets?)[^)]*\)}i
340
+ ).count
341
+ case_terms = normalized.scan(
342
+ /\b(?:prosecutor|trial chamber|pre-trial chamber|charges?|warrant|summons|custody|convicted|acquitted|case closed|at large|defence|reparations|court record)\b/i
343
+ ).count
344
+
345
+ linked_case_headings >= 4 && case_terms >= 6
346
+ rescue URI::InvalidURIError
347
+ false
348
+ end
349
+
350
+ def substantial_homepage_landing?(snapshot)
351
+ normalized = snapshot.normalized_markdown
352
+ return false if normalized.length < 1_200
353
+
354
+ context = snapshot.context_downcase
355
+ landing_pattern = /\b(docs?|documentation|api|reference|guide|guides|developer|framework|next\.js|mdx|
356
+ static websites?|components?|themes?|product|platform)\b/x
357
+ return false unless context.match?(landing_pattern)
358
+
359
+ snapshot.long_prose_line_count_one_twenty.positive?
360
+ end
361
+
362
+ def research_database_landing?(snapshot)
363
+ normalized = snapshot.normalized_markdown
364
+ return false if normalized.length < 250
365
+
366
+ context = snapshot.context_downcase
367
+ research_terms = /\b(?:database|data resource|repository|multi-omics|proteomics|transcriptomics|
368
+ phenomics|genomics|metabolomics|life science research|scientific resource)\b/ix
369
+ return false unless context.match?(research_terms)
370
+ return false if context.match?(HOMEPAGE_INDEX_PATTERN)
371
+
372
+ snapshot.long_prose_line_count_one_hundred.positive?
373
+ end
374
+
375
+ def index_list_markdown?(url, snapshot)
376
+ return false unless index_or_search_url?(url)
377
+ return false if article_like_url?(url)
378
+
379
+ return false if legal_judgment_markdown?(snapshot) || legal_statute_markdown?(snapshot)
380
+
381
+ linked_headlines = snapshot.linked_heading_count
382
+ linked_items = snapshot.linked_item_count
383
+
384
+ linked_headlines + linked_items >= 4
385
+ end
386
+
387
+ def scholarly_article_markdown?(url, snapshot)
388
+ payload = snapshot.payload
389
+ normalized = snapshot.normalized_markdown
390
+ return false if normalized.length < 5_000
391
+ return false unless article_like_url?(url) || doi_article_url?(url) || doi_article_url?(payload["canonicalUrl"])
392
+ return false if payload["byline"].to_s.strip.empty? && payload["publishedTime"].to_s.strip.empty?
393
+
394
+ context = snapshot.first_80_context
395
+ scholarly_context = context.match?(/\b(?:abstract|introduction|methods?|results?|discussion|references|doi|open access|peer[- ]reviewed|journal|article)\b/i)
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
400
+
401
+ return true if scholarly_context && section_headings >= 3 && long_prose_lines >= 5
402
+
403
+ doi_article_url?(url) && scholarly_context && normalized.length >= 8_000 && long_prose_lines >= 8
404
+ end
405
+
406
+ def doi_article_url?(url)
407
+ return false if url.nil? || url.empty?
408
+
409
+ URI.parse(url).path.to_s.match?(%r{/(?:articles?/)?10\.\d{4,9}/}i)
410
+ rescue URI::InvalidURIError
411
+ false
412
+ end
413
+
414
+ def thin_index_page?(url, snapshot)
415
+ payload = snapshot.payload
416
+ return false unless index_or_search_url?(url)
417
+ return false if article_like_url?(url)
418
+ return false if payload["byline"].to_s.strip != "" || payload["publishedTime"].to_s.strip != ""
419
+
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)
423
+
424
+ markdown.length < 2400
425
+ end
426
+
427
+ def reference_table_article_markdown?(snapshot)
428
+ raw = snapshot.markdown
429
+ text = snapshot.normalized_markdown
430
+ return false if text.length < 700
431
+ return false unless raw.match?(/^\|\s*[-: ]+\|/)
432
+ return false if snapshot.list_link_count >= 3
433
+
434
+ prose_blocks = raw.split(/\n{2,}/).count do |block|
435
+ stripped = block.strip
436
+ normalized = FetchUtil.normalize_whitespace(stripped)
437
+ !stripped.empty? && !stripped.start_with?("|", "#") && normalized.length >= 80 && normalized.match?(/[.!?)]\z/)
438
+ end
439
+ table_rows = snapshot.table_row_count
440
+ headings = snapshot.heading_count
441
+
442
+ headings >= 1 && prose_blocks >= 2 && table_rows.between?(4, 40)
443
+ end
444
+
445
+ def legal_judgment_markdown?(snapshot)
446
+ text = snapshot.normalized_markdown
447
+ return false if text.length < 5_000
448
+ return false if text.match?(/\bresults?\s+\d+\s*[-–]\s*\d+\s+(?:of|sur|von|de)\s+\d+\b/i)
449
+
450
+ signals = 0
451
+ signals += 1 if text.match?(/\b(?:high court|supreme court|court of appeal|federal court|district court|tribunal)\b/i)
452
+ signals += 1 if text.match?(/\b(?:judg(?:e)?ment|opinion of the court|reasons for judgment|delivered by)\b/i)
453
+ signals += 1 if text.match?(/\b(?:appellant|respondent|plaintiff|defendant|petitioner|counsel|solicitor|certiorari)\b/i)
454
+ signals += 1 if text.match?(/\b[A-Z][A-Za-z'.-]+\s+v\.?\s+[A-Z][A-Za-z'.-]+\b/)
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)
456
+ return false if signals < 3
457
+
458
+ snapshot.long_prose_line_count_one_twenty >= 5 || text.length >= 20_000
459
+ end
460
+
461
+ def legal_statute_markdown?(snapshot)
462
+ text = snapshot.normalized_markdown
463
+ return false if text.length < 5_000
464
+ return false if text.match?(/\bresults?\s+\d+\s*[-–]\s*\d+\s+(?:of|sur|von|de)\s+\d+\b/i)
465
+
466
+ context = text[0, 4_000]
467
+ legal_title = context.match?(LEGAL_STATUTE_TITLE_PATTERN)
468
+ provision_markers = text.scan(LEGAL_PROVISION_MARKER_PATTERN).count
469
+ structural_markers = text.scan(LEGAL_PROVISION_STRUCTURAL_PATTERN).count
470
+ legal_terms = text.match?(LEGAL_PROVISION_TERMS_PATTERN)
471
+
472
+ legal_title && provision_markers >= 8 && (structural_markers >= 3 || legal_terms || text.length >= 20_000)
473
+ end
474
+
475
+ def index_or_search_url?(url)
476
+ uri = URI.parse(url)
477
+ path = uri.path.to_s
478
+ return true if path.match?(INDEX_OR_SEARCH_PATH_PATTERN)
479
+ return true if uri.query.to_s.match?(INDEX_QUERY_PATTERN)
480
+
481
+ segments = path.split("/").reject(&:empty?)
482
+ segments.length.between?(1, 2) && !opaque_detail_path_segments?(segments) && !segments.last.to_s.include?("-") &&
483
+ !path.match?(/\.(?:html?|php|aspx?|jsp)\z/i)
484
+ rescue URI::InvalidURIError
485
+ false
486
+ end
487
+
488
+ def opaque_detail_path_segments?(segments)
489
+ return false unless segments.length == 2
490
+ return false if SEARCH_OR_LIST_PATH_SEGMENTS.include?(segments.first.to_s.downcase)
491
+
492
+ last = segments.last.to_s
493
+ last.length >= 6 && last.match?(/[[:alpha:]]/) && last.match?(/\d/) && last.match?(/\A[a-z0-9_-]+\z/i)
494
+ end
495
+
496
+ def article_like_url?(url)
497
+ URI.parse(url).path.to_s.match?(ARTICLE_PATH_PATTERN)
498
+ rescue URI::InvalidURIError
499
+ false
500
+ end
501
+
502
+ def auth_redirect_interstitial?(snapshot)
503
+ requested_url = snapshot.requested_url
504
+ final_url = snapshot.final_url
505
+ return false if requested_url.nil? || final_url.nil?
506
+ return false unless auth_path?(final_url)
507
+ return false if auth_path?(requested_url)
508
+ return false unless index_or_search_url?(requested_url)
509
+
510
+ text = snapshot.context_with_excerpt_downcase
511
+ text.match?(/\b(?:log in|login|sign in|sign-in)\b/) &&
512
+ text.match?(/\b(?:github|gitlab|google|oauth|sso|single sign-on|password|account)\b/)
513
+ end
514
+
515
+ def auth_path?(url)
516
+ URI.parse(url).path.to_s.match?(AUTH_PATH_PATTERN)
517
+ rescue URI::InvalidURIError
518
+ false
519
+ end
520
+
521
+ def pdf_document?(requested_url, final_url, payload)
522
+ return true if payload["contentType"].to_s == "pdf"
523
+
524
+ [requested_url, final_url, payload["canonicalUrl"]].compact.any? do |url|
525
+ parsed = URI.parse(url)
526
+ [parsed.path, parsed.query].compact.join("?").match?(PDF_PATH_PATTERN)
527
+ end
528
+ rescue URI::InvalidURIError
529
+ false
530
+ end
531
+
532
+ def generic_redirect_not_found?(snapshot)
533
+ requested_url = snapshot.requested_url
534
+ final_url = snapshot.final_url
535
+ return false if requested_url.nil? || final_url.nil?
536
+ return false unless same_effective_domain?(requested_url, final_url)
537
+
538
+ requested_path = snapshot.path_key(requested_url)
539
+ final_path = snapshot.path_key(final_url)
540
+ return false if requested_path.empty? || requested_path == final_path
541
+ return false unless specific_content_path?(requested_path)
542
+ return false unless generic_redirect_path?(final_path)
543
+ return false if redirected_payload_matches_requested_path?(requested_path, snapshot.payload)
544
+
545
+ true
546
+ end
547
+
548
+ def specific_content_path?(path)
549
+ return true if path.match?(%r{/10\.\d{4,9}/}i)
550
+ return true if path.match?(%r{/(?:content|article|articles|preprint|papers?)/.+[a-z0-9]}i)
551
+
552
+ false
553
+ end
554
+
555
+ def generic_redirect_path?(path)
556
+ path.empty? || path.match?(%r{\A/(?:node|index(?:\.html?)?|home)?\z}i)
557
+ end
558
+
559
+ def redirected_payload_matches_requested_path?(requested_path, payload)
560
+ requested_path.split(%r{[/._-]+}).select { |token| token.length >= 6 }.any? do |token|
561
+ next false if CONTENT_ROUTE_SEGMENTS.include?(token.downcase)
562
+
563
+ FetchUtil.normalize_whitespace([payload["title"], payload["markdown"], payload["canonicalUrl"]].compact.join(" "))
564
+ .downcase.include?(token.downcase)
565
+ end
566
+ end
567
+
568
+ def article_body_fallback_candidate?(result)
569
+ return false unless result.content_type == "list"
570
+ return false if result.byline.to_s.strip.empty? && result.published_time.to_s.strip.empty?
571
+ return false unless article_like_url?(result.final_url) || slug_article_url?(result.final_url)
572
+
573
+ markdown = result.markdown.to_s
574
+ markdown.length < 1_500 && markdown.lines.grep(/^\s*[-*]\s+\[/).count >= 4
575
+ end
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
+
590
+ def slug_article_url?(url)
591
+ segments = URI.parse(url).path.to_s.split("/").reject(&:empty?)
592
+ segments.length == 1 && segments.first.include?("-")
593
+ rescue URI::InvalidURIError
594
+ false
595
+ end
596
+
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
601
+ return false unless content_type == "article"
602
+ return false if homepage_like || payload["docsLike"]
603
+ return false if requested_url.nil? || final_url.nil?
604
+ return false if snapshot.resource_urls.any? { |url| snapshot.search_or_list_resource_url?(url) }
605
+
606
+ resource_url = mismatched_resource_url(snapshot)
607
+ return false if resource_url.nil?
608
+
609
+ requested_keywords = title_slug_keywords(requested_url)
610
+ return false if requested_keywords.length < 2
611
+
612
+ resolved_keywords = significant_slug_tokens([resource_url, payload["title"]].compact.join(" "))
613
+ return false if resolved_keywords.empty?
614
+
615
+ (requested_keywords & resolved_keywords).empty?
616
+ end
617
+
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
622
+ return false unless content_type == "article"
623
+ return false if requested_url.nil? || final_url.nil?
624
+ return false unless same_effective_domain?(requested_url, final_url)
625
+ return false if FetchUtil.strip_www_host(requested_url) == FetchUtil.strip_www_host(final_url)
626
+ return false if snapshot.resource_urls.any? { |url| snapshot.search_or_list_resource_url?(url) }
627
+ return true if matching_apex_instrument_redirect?(payload, requested_url, final_url)
628
+
629
+ tokens = requested_identifier_tokens(requested_url)
630
+ return false if tokens.empty?
631
+
632
+ content = snapshot.content_downcase
633
+ matches = tokens.select { |token| content.include?(token) }
634
+
635
+ matches.any? { |token| code_like_identifier?(token) } || matches.length >= 2
636
+ rescue URI::InvalidURIError
637
+ false
638
+ end
639
+
640
+ def trusted_publisher_doi_redirect?(content_type, snapshot)
641
+ requested_url = snapshot.requested_url
642
+ final_url = snapshot.final_url
643
+ return false unless content_type == "article"
644
+ return false unless cross_domain_redirect?(requested_url, final_url)
645
+ return false unless scholarly_article_markdown?(final_url, snapshot)
646
+
647
+ dois = snapshot.resource_urls.filter_map { |url| doi_from_url(url) }.uniq
648
+ dois.length == 1
649
+ end
650
+
651
+ def doi_from_url(url)
652
+ return nil if url.nil? || url.empty?
653
+
654
+ path = URI.parse(url).path.to_s
655
+ match = path.match(%r{/(10\.\d{4,9}/[^?#/\s]+(?:/[^?#\s]+)*)}i)
656
+ match && match[1].downcase.delete_suffix("/")
657
+ rescue URI::InvalidURIError
658
+ nil
659
+ end
660
+
661
+ def requested_identifier_tokens(url)
662
+ uri = URI.parse(url)
663
+ raw = [uri.path, uri.query].compact.join(" ")
664
+ URI.decode_www_form_component(raw.tr("+", " ")).downcase
665
+ .gsub(/[^a-z0-9]+/, " ").split.select do |token|
666
+ token.length >= 3 && token.match?(/[a-z]/) && !TITLE_SLUG_STOPWORDS.include?(token) &&
667
+ !CONTENT_ROUTE_SEGMENTS.include?(token) && token != "www"
668
+ end.uniq
669
+ rescue ArgumentError, URI::InvalidURIError
670
+ []
671
+ end
672
+
673
+ def matching_apex_instrument_redirect?(payload, requested_url, final_url)
674
+ requested_instrument = apex_instrument_id(requested_url)
675
+ final_instrument = apex_instrument_id(final_url)
676
+ return false if requested_instrument.nil? || final_instrument.nil?
677
+ return false unless requested_instrument == final_instrument
678
+
679
+ content = FetchUtil.normalize_whitespace([payload["title"], payload["markdown"]].compact.join(" "))
680
+ content.match?(/\b(?:Convention|Recommendation|Protocol)\s+[A-Z]\d{2,4}\b/i) &&
681
+ content.match?(/\b(?:International Labour|Labou?r Organisation|General Conference|Article\s+1)\b/i)
682
+ end
683
+
684
+ def apex_instrument_id(url)
685
+ query = URI.parse(url).query.to_s
686
+ decoded = URI.decode_www_form_component(query.tr("+", " "))
687
+ decoded[/\bP\d+_INSTRUMENT_ID[:=](\d+)\b/i, 1]
688
+ rescue ArgumentError, URI::InvalidURIError
689
+ nil
690
+ end
691
+
692
+ def code_like_identifier?(token)
693
+ token.match?(/\A(?=.*[a-z])(?=.*\d)[a-z0-9]{3,16}\z/)
694
+ end
695
+
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)
701
+ return nil if requested_path.empty?
702
+
703
+ final_path = snapshot.path_key(final_url)
704
+ return final_url if !final_path.empty? && final_path != requested_path
705
+
706
+ return nil unless same_effective_domain?(final_url, canonical_url)
707
+
708
+ canonical_path = snapshot.path_key(canonical_url)
709
+ return canonical_url if !canonical_path.empty? && canonical_path != requested_path
710
+
711
+ nil
712
+ end
713
+
714
+ def same_effective_domain?(left_url, right_url)
715
+ return false if left_url.nil? || right_url.nil?
716
+
717
+ left_domain = effective_domain(left_url)
718
+ right_domain = effective_domain(right_url)
719
+ !left_domain.nil? && left_domain == right_domain
720
+ end
721
+
722
+ def title_slug_keywords(url)
723
+ URI.parse(url).path.to_s.split("/").reject(&:empty?).reverse_each do |segment|
724
+ tokens = significant_slug_tokens(segment)
725
+ return tokens if tokens.length >= 2
726
+ end
145
727
 
146
- markdown.to_s.lines.grep(/^\s*(?:\d+\.\s+|[-*]\s+)/).count >= 3
728
+ []
729
+ rescue URI::InvalidURIError
730
+ []
731
+ end
732
+
733
+ def significant_slug_tokens(text)
734
+ decoded = URI.decode_www_form_component(text.to_s.tr("+", " "))
735
+ decoded.downcase.gsub(/[^a-z0-9]+/, " ").split.select do |token|
736
+ token.length >= 3 && token.match?(/[a-z]/) && !token.match?(/\d/) &&
737
+ !TITLE_SLUG_STOPWORDS.include?(token)
738
+ end.uniq
739
+ rescue ArgumentError
740
+ []
147
741
  end
148
742
 
149
743
  def fallback_result(url, fallback)
150
744
  build_result(url, *fallback)
151
745
  end
152
746
 
747
+ def network_error_result(url, error)
748
+ message = error.message.to_s.strip
749
+ warning = dns_resolution_error?(message) ? "dns_resolution_failed" : "network_error"
750
+ Result.error(url: url, warning: warning, message: message)
751
+ end
752
+
753
+ def network_error?(error)
754
+ error.message.to_s.match?(NETWORK_ERROR_PATTERN)
755
+ end
756
+
757
+ def pending_connections_error?(error)
758
+ error.message.to_s.match?(/pending connections/i)
759
+ end
760
+
761
+ def dns_resolution_error?(message)
762
+ message.match?(/ERR_NAME_NOT_RESOLVED|DNS|resolve|resolution|ENOTFOUND|EAI_AGAIN/i)
763
+ end
764
+
153
765
  def docs_fallback_candidate?(requested_url, result = nil)
154
766
  candidates = [requested_url]
155
767
  if result
@@ -228,15 +840,32 @@ module FetchUtil
228
840
  end
229
841
 
230
842
  def normalized_result_url(url)
843
+ strip_tracking_params(url)
844
+ end
845
+
846
+ def strip_tracking_params(url)
231
847
  return url if url.nil? || url.empty?
232
848
 
233
849
  uri = URI.parse(url)
234
850
  params = URI.decode_www_form(uri.query.to_s)
235
- params.reject! { |key, _value| STRIPPED_QUERY_PARAM_PATTERNS.any? { |pattern| key.match?(pattern) } }
851
+ params.reject! { |key, _value| tracking_query_param?(key) }
236
852
  uri.query = params.empty? ? nil : URI.encode_www_form(params)
237
853
  uri.to_s
238
854
  rescue URI::InvalidURIError
239
855
  url
240
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
241
870
  end
242
871
  end