fetch_util 0.3.0 → 0.3.1

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,60 @@ module FetchUtil
20
20
  Regexp::IGNORECASE
21
21
  ).freeze
22
22
  DOCS_PORTAL_TITLE_PATTERN = /documentation|docs|the ultimate server/i
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
23
52
  STRIPPED_QUERY_PARAM_PATTERNS = [
24
53
  /\A(?:__goaway_|__cf_chl_)/,
25
54
  /\A(?:utm_[a-z]+|fbclid|gclid|mc_cid|mc_eid)\z/,
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|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
30
77
 
31
78
  def initialize(browser: nil, extractor: nil, **options)
32
79
  @timeout = options.fetch(:timeout, 20)
@@ -47,6 +94,7 @@ module FetchUtil
47
94
  build_result(url, page.current_url, payload)
48
95
  end
49
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
50
98
  result = fallback_result(url, fallback) if fallback
51
99
  log_request(url, t0)
52
100
  result
@@ -59,6 +107,8 @@ module FetchUtil
59
107
  end
60
108
 
61
109
  log_request(url, t0)
110
+ return network_error_result(url, e) if e.is_a?(BrowserError) && network_error?(e)
111
+
62
112
  raise e
63
113
  end
64
114
 
@@ -68,67 +118,64 @@ module FetchUtil
68
118
  final_url = normalized_result_url(final_url)
69
119
  canonical_url = normalized_result_url(payload["canonicalUrl"])
70
120
  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)
121
+ content_type = resolved_content_type(final_url, homepage_like, payload)
122
+ warnings = resolved_warnings(
123
+ content_type, homepage_like, payload,
124
+ requested_url: url, final_url: final_url, canonical_url: canonical_url
125
+ )
73
126
  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
127
 
96
- Result.new(
128
+ Result.from_payload(
97
129
  url: url,
98
130
  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"],
131
+ payload: payload,
104
132
  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
133
  content_type: content_type,
111
134
  suspect: suspect,
112
- warnings: warnings,
113
- content_completeness_ratio: completeness_ratio,
114
- content_format: content_format,
115
- paywall_state: paywall_state
135
+ warnings: warnings
116
136
  )
117
137
  end
118
138
 
119
- def resolved_content_type(homepage_like, payload)
139
+ def resolved_content_type(final_url, homepage_like, payload)
120
140
  content_type = payload["contentType"] || "article"
141
+ return "article" if content_type == "list" && scholarly_article_markdown?(final_url, payload)
142
+
121
143
  return content_type unless content_type == "article"
144
+ return content_type if payload["legalProvision"]
145
+ 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)
122
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)
123
154
 
124
155
  content_type
125
156
  end
126
157
 
127
- def resolved_warnings(content_type, homepage_like, payload, requested_url: nil, final_url: nil)
158
+ def resolved_warnings(content_type, homepage_like, payload, requested_url: nil, final_url: nil, canonical_url: nil)
159
+ trusted_same_organization_redirect = trusted_same_organization_redirect?(
160
+ content_type, payload, requested_url, final_url, canonical_url
161
+ )
162
+ trusted_cross_domain_redirect = trusted_publisher_doi_redirect?(content_type, payload, requested_url, final_url, canonical_url)
128
163
  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)
164
+ warnings.delete("url_content_mismatch") if trusted_same_organization_redirect
165
+ if content_type == "list" && homepage_like && !payload["statusPage"] &&
166
+ !substantial_homepage_landing?(payload) && !government_service_portal?(final_url, payload) &&
167
+ !research_database_landing?(payload)
168
+ warnings << "homepage_index_page"
169
+ end
170
+ warnings << "cross_domain_redirect" if cross_domain_redirect?(requested_url, final_url) && !trusted_cross_domain_redirect
131
171
  warnings << "aggregator_redirect_url" if aggregator_url?(requested_url)
172
+ warnings << "auth_or_login_interstitial" if auth_redirect_interstitial?(requested_url, final_url, payload)
173
+ 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)
175
+ if !trusted_same_organization_redirect &&
176
+ redirected_title_content_mismatch?(content_type, homepage_like, payload, requested_url, final_url, canonical_url)
177
+ warnings << "url_content_mismatch"
178
+ end
132
179
  warnings.uniq
133
180
  end
134
181
 
@@ -146,10 +193,466 @@ module FetchUtil
146
193
  markdown.to_s.lines.grep(/^\s*(?:\d+\.\s+|[-*]\s+)/).count >= 3
147
194
  end
148
195
 
196
+ def government_service_portal?(url, payload)
197
+ markdown = payload["markdown"].to_s
198
+ normalized = FetchUtil.normalize_whitespace(markdown)
199
+ return false if normalized.length < 250
200
+ return false unless government_domain?(url) || government_service_language?(payload)
201
+
202
+ context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
203
+ service_pattern = /\b(?:service|services|servi[cç]os?|servicio|servicios|service category|categories|
204
+ categorias?|citizens?|business(?:es)?|benefits?|permits?|licen[cs]es?)\b/ix
205
+ 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
208
+
209
+ service_terms >= 3 && (linked_items >= 4 || plain_items >= 4 || markdown.scan(%r{\]\(https?://}).count >= 6)
210
+ end
211
+
212
+ def government_domain?(url)
213
+ host = FetchUtil.strip_www_host(url)
214
+ labels = host.split(".")
215
+
216
+ host.end_with?(".gov") || labels.include?("gov")
217
+ rescue URI::InvalidURIError
218
+ false
219
+ end
220
+
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)
224
+ end
225
+
226
+ def institutional_case_record_list?(url, payload)
227
+ markdown = payload["markdown"].to_s
228
+ normalized = FetchUtil.normalize_whitespace(markdown)
229
+ return false if normalized.length < 500
230
+
231
+ path = URI.parse(url).path.to_s.downcase
232
+ context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown.lines.first(20).join(" ")].join(" "))
233
+ return false unless path.match?(%r{/(?:cases?|defendants?|records?|dockets?|matters?)/?\z}) ||
234
+ context.match?(/\b\d{1,4}\s+(?:cases?|defendants?|records?|matters?)\b/i)
235
+
236
+ linked_case_headings = markdown.scan(
237
+ %r{^\s*\#{1,6}\s+\[[^\]]{3,180}\]\([^)]*/(?:cases?|defendants?|situations?|darfur|mali|kenya|libya|uganda|congo|afghanistan|ukraine|records?|dockets?)[^)]*\)}i
238
+ ).count
239
+ case_terms = normalized.scan(
240
+ /\b(?:prosecutor|trial chamber|pre-trial chamber|charges?|warrant|summons|custody|convicted|acquitted|case closed|at large|defence|reparations|court record)\b/i
241
+ ).count
242
+
243
+ linked_case_headings >= 4 && case_terms >= 6
244
+ rescue URI::InvalidURIError
245
+ false
246
+ end
247
+
248
+ def substantial_homepage_landing?(payload)
249
+ markdown = payload["markdown"].to_s
250
+ normalized = FetchUtil.normalize_whitespace(markdown)
251
+ return false if normalized.length < 1_200
252
+
253
+ context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
254
+ landing_pattern = /\b(docs?|documentation|api|reference|guide|guides|developer|framework|next\.js|mdx|
255
+ static websites?|components?|themes?|product|platform)\b/x
256
+ return false unless context.match?(landing_pattern)
257
+
258
+ prose_lines = markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
259
+ prose_lines.any? { |line| FetchUtil.normalize_whitespace(line).length >= 120 }
260
+ end
261
+
262
+ def research_database_landing?(payload)
263
+ markdown = payload["markdown"].to_s
264
+ normalized = FetchUtil.normalize_whitespace(markdown)
265
+ return false if normalized.length < 250
266
+
267
+ context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown].join(" ")).downcase
268
+ research_terms = /\b(?:database|data resource|repository|multi-omics|proteomics|transcriptomics|
269
+ phenomics|genomics|metabolomics|life science research|scientific resource)\b/ix
270
+ return false unless context.match?(research_terms)
271
+ return false if context.match?(HOMEPAGE_INDEX_PATTERN)
272
+
273
+ prose_lines = markdown.lines.reject { |line| line.match?(/^\s*(?:#|[-*]\s+|\d+\.\s+)/) }
274
+ prose_lines.any? { |line| FetchUtil.normalize_whitespace(line).length >= 100 }
275
+ end
276
+
277
+ def index_list_markdown?(url, payload)
278
+ return false unless index_or_search_url?(url)
279
+ return false if article_like_url?(url)
280
+
281
+ markdown = payload["markdown"].to_s
282
+ return false if legal_judgment_markdown?(markdown) || legal_statute_markdown?(markdown)
283
+
284
+ linked_headlines = markdown.scan(LINKED_MARKDOWN_HEADING_PATTERN).count
285
+ linked_items = markdown.scan(LINKED_MARKDOWN_ITEM_PATTERN).count
286
+
287
+ linked_headlines + linked_items >= 4
288
+ end
289
+
290
+ def scholarly_article_markdown?(url, payload)
291
+ markdown = payload["markdown"].to_s
292
+ normalized = FetchUtil.normalize_whitespace(markdown)
293
+ return false if normalized.length < 5_000
294
+ return false unless article_like_url?(url) || doi_article_url?(url) || doi_article_url?(payload["canonicalUrl"])
295
+ return false if payload["byline"].to_s.strip.empty? && payload["publishedTime"].to_s.strip.empty?
296
+
297
+ context = FetchUtil.normalize_whitespace([payload["title"], payload["siteName"], markdown.lines.first(80).join(" ")].join(" "))
298
+ 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 }
302
+
303
+ return true if scholarly_context && section_headings >= 3 && long_prose_lines >= 5
304
+
305
+ doi_article_url?(url) && scholarly_context && normalized.length >= 8_000 && long_prose_lines >= 8
306
+ end
307
+
308
+ def doi_article_url?(url)
309
+ return false if url.nil? || url.empty?
310
+
311
+ URI.parse(url).path.to_s.match?(%r{/(?:articles?/)?10\.\d{4,9}/}i)
312
+ rescue URI::InvalidURIError
313
+ false
314
+ end
315
+
316
+ def thin_index_page?(url, payload)
317
+ return false unless index_or_search_url?(url)
318
+ return false if article_like_url?(url)
319
+ return false if payload["byline"].to_s.strip != "" || payload["publishedTime"].to_s.strip != ""
320
+
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"])
324
+
325
+ markdown.length < 2400
326
+ end
327
+
328
+ def reference_table_article_markdown?(markdown)
329
+ raw = markdown.to_s
330
+ text = FetchUtil.normalize_whitespace(raw)
331
+ return false if text.length < 700
332
+ return false unless raw.match?(/^\|\s*[-: ]+\|/)
333
+ return false if raw.lines.grep(/^\s*- \[/).count >= 3
334
+
335
+ prose_blocks = raw.split(/\n{2,}/).count do |block|
336
+ stripped = block.strip
337
+ normalized = FetchUtil.normalize_whitespace(stripped)
338
+ !stripped.empty? && !stripped.start_with?("|", "#") && normalized.length >= 80 && normalized.match?(/[.!?)]\z/)
339
+ end
340
+ table_rows = raw.lines.grep(/^\|/).count
341
+ headings = raw.lines.grep(/^(?:#){1,3}\s+/).count
342
+
343
+ headings >= 1 && prose_blocks >= 2 && table_rows.between?(4, 40)
344
+ end
345
+
346
+ def legal_judgment_markdown?(markdown)
347
+ text = FetchUtil.normalize_whitespace(markdown.to_s)
348
+ return false if text.length < 5_000
349
+ return false if text.match?(/\bresults?\s+\d+\s*[-–]\s*\d+\s+(?:of|sur|von|de)\s+\d+\b/i)
350
+
351
+ signals = 0
352
+ signals += 1 if text.match?(/\b(?:high court|supreme court|court of appeal|federal court|district court|tribunal)\b/i)
353
+ signals += 1 if text.match?(/\b(?:judg(?:e)?ment|opinion of the court|reasons for judgment|delivered by)\b/i)
354
+ signals += 1 if text.match?(/\b(?:appellant|respondent|plaintiff|defendant|petitioner|counsel|solicitor|certiorari)\b/i)
355
+ signals += 1 if text.match?(/\b[A-Z][A-Za-z'.-]+\s+v\.?\s+[A-Z][A-Za-z'.-]+\b/)
356
+ 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
+ return false if signals < 3
358
+
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
361
+ end
362
+
363
+ def legal_statute_markdown?(markdown)
364
+ text = FetchUtil.normalize_whitespace(markdown.to_s)
365
+ return false if text.length < 5_000
366
+ return false if text.match?(/\bresults?\s+\d+\s*[-–]\s*\d+\s+(?:of|sur|von|de)\s+\d+\b/i)
367
+
368
+ context = text[0, 4_000]
369
+ legal_title = context.match?(LEGAL_STATUTE_TITLE_PATTERN)
370
+ provision_markers = text.scan(LEGAL_PROVISION_MARKER_PATTERN).count
371
+ structural_markers = text.scan(LEGAL_PROVISION_STRUCTURAL_PATTERN).count
372
+ legal_terms = text.match?(LEGAL_PROVISION_TERMS_PATTERN)
373
+
374
+ legal_title && provision_markers >= 8 && (structural_markers >= 3 || legal_terms || text.length >= 20_000)
375
+ end
376
+
377
+ def index_or_search_url?(url)
378
+ uri = URI.parse(url)
379
+ path = uri.path.to_s
380
+ return true if path.match?(INDEX_OR_SEARCH_PATH_PATTERN)
381
+ return true if uri.query.to_s.match?(INDEX_QUERY_PATTERN)
382
+
383
+ segments = path.split("/").reject(&:empty?)
384
+ segments.length.between?(1, 2) && !opaque_detail_path_segments?(segments) && !segments.last.to_s.include?("-") &&
385
+ !path.match?(/\.(?:html?|php|aspx?|jsp)\z/i)
386
+ rescue URI::InvalidURIError
387
+ false
388
+ end
389
+
390
+ def opaque_detail_path_segments?(segments)
391
+ return false unless segments.length == 2
392
+ return false if SEARCH_OR_LIST_PATH_SEGMENTS.include?(segments.first.to_s.downcase)
393
+
394
+ last = segments.last.to_s
395
+ last.length >= 6 && last.match?(/[[:alpha:]]/) && last.match?(/\d/) && last.match?(/\A[a-z0-9_-]+\z/i)
396
+ end
397
+
398
+ def article_like_url?(url)
399
+ URI.parse(url).path.to_s.match?(ARTICLE_PATH_PATTERN)
400
+ rescue URI::InvalidURIError
401
+ false
402
+ end
403
+
404
+ def auth_redirect_interstitial?(requested_url, final_url, payload)
405
+ return false if requested_url.nil? || final_url.nil?
406
+ return false unless auth_path?(final_url)
407
+ return false if auth_path?(requested_url)
408
+ return false unless index_or_search_url?(requested_url)
409
+
410
+ text = FetchUtil.normalize_whitespace([payload["title"], payload["markdown"], payload["excerpt"]].compact.join(" ")).downcase
411
+ text.match?(/\b(?:log in|login|sign in|sign-in)\b/) &&
412
+ text.match?(/\b(?:github|gitlab|google|oauth|sso|single sign-on|password|account)\b/)
413
+ end
414
+
415
+ def auth_path?(url)
416
+ URI.parse(url).path.to_s.match?(AUTH_PATH_PATTERN)
417
+ rescue URI::InvalidURIError
418
+ false
419
+ end
420
+
421
+ def pdf_document?(requested_url, final_url, payload)
422
+ return true if payload["contentType"].to_s == "pdf"
423
+
424
+ [requested_url, final_url, payload["canonicalUrl"]].compact.any? do |url|
425
+ parsed = URI.parse(url)
426
+ [parsed.path, parsed.query].compact.join("?").match?(PDF_PATH_PATTERN)
427
+ end
428
+ rescue URI::InvalidURIError
429
+ false
430
+ end
431
+
432
+ def generic_redirect_not_found?(requested_url, final_url, payload)
433
+ return false if requested_url.nil? || final_url.nil?
434
+ return false unless same_effective_domain?(requested_url, final_url)
435
+
436
+ requested_path = normalized_path_key(requested_url)
437
+ final_path = normalized_path_key(final_url)
438
+ return false if requested_path.empty? || requested_path == final_path
439
+ return false unless specific_content_path?(requested_path)
440
+ return false unless generic_redirect_path?(final_path)
441
+ return false if redirected_payload_matches_requested_path?(requested_path, payload)
442
+
443
+ true
444
+ end
445
+
446
+ def specific_content_path?(path)
447
+ return true if path.match?(%r{/10\.\d{4,9}/}i)
448
+ return true if path.match?(%r{/(?:content|article|articles|preprint|papers?)/.+[a-z0-9]}i)
449
+
450
+ false
451
+ end
452
+
453
+ def generic_redirect_path?(path)
454
+ path.empty? || path.match?(%r{\A/(?:node|index(?:\.html?)?|home)?\z}i)
455
+ end
456
+
457
+ def redirected_payload_matches_requested_path?(requested_path, payload)
458
+ requested_path.split(%r{[/._-]+}).select { |token| token.length >= 6 }.any? do |token|
459
+ next false if CONTENT_ROUTE_SEGMENTS.include?(token.downcase)
460
+
461
+ FetchUtil.normalize_whitespace([payload["title"], payload["markdown"], payload["canonicalUrl"]].compact.join(" "))
462
+ .downcase.include?(token.downcase)
463
+ end
464
+ end
465
+
466
+ def article_body_fallback_candidate?(result)
467
+ return false unless result.content_type == "list"
468
+ return false if result.byline.to_s.strip.empty? && result.published_time.to_s.strip.empty?
469
+ return false unless article_like_url?(result.final_url) || slug_article_url?(result.final_url)
470
+
471
+ markdown = result.markdown.to_s
472
+ markdown.length < 1_500 && markdown.lines.grep(/^\s*[-*]\s+\[/).count >= 4
473
+ end
474
+
475
+ def slug_article_url?(url)
476
+ segments = URI.parse(url).path.to_s.split("/").reject(&:empty?)
477
+ segments.length == 1 && segments.first.include?("-")
478
+ rescue URI::InvalidURIError
479
+ false
480
+ end
481
+
482
+ def redirected_title_content_mismatch?(content_type, homepage_like, payload, requested_url, final_url, canonical_url)
483
+ return false unless content_type == "article"
484
+ return false if homepage_like || payload["docsLike"]
485
+ 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) }
487
+
488
+ resource_url = mismatched_resource_url(requested_url, final_url, canonical_url)
489
+ return false if resource_url.nil?
490
+
491
+ requested_keywords = title_slug_keywords(requested_url)
492
+ return false if requested_keywords.length < 2
493
+
494
+ resolved_keywords = significant_slug_tokens([resource_url, payload["title"]].compact.join(" "))
495
+ return false if resolved_keywords.empty?
496
+
497
+ (requested_keywords & resolved_keywords).empty?
498
+ end
499
+
500
+ def trusted_same_organization_redirect?(content_type, payload, requested_url, final_url, canonical_url)
501
+ return false unless content_type == "article"
502
+ return false if requested_url.nil? || final_url.nil?
503
+ return false unless same_effective_domain?(requested_url, final_url)
504
+ 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) }
506
+ return true if matching_apex_instrument_redirect?(payload, requested_url, final_url)
507
+
508
+ tokens = requested_identifier_tokens(requested_url)
509
+ return false if tokens.empty?
510
+
511
+ content = FetchUtil.normalize_whitespace(
512
+ [payload["title"], payload["markdown"]].compact.join(" ")
513
+ ).downcase
514
+ matches = tokens.select { |token| content.include?(token) }
515
+
516
+ matches.any? { |token| code_like_identifier?(token) } || matches.length >= 2
517
+ rescue URI::InvalidURIError
518
+ false
519
+ end
520
+
521
+ def trusted_publisher_doi_redirect?(content_type, payload, requested_url, final_url, canonical_url)
522
+ return false unless content_type == "article"
523
+ return false unless cross_domain_redirect?(requested_url, final_url)
524
+ return false unless scholarly_article_markdown?(final_url, payload)
525
+
526
+ dois = [requested_url, final_url, canonical_url].filter_map { |url| doi_from_url(url) }.uniq
527
+ dois.length == 1
528
+ end
529
+
530
+ def doi_from_url(url)
531
+ return nil if url.nil? || url.empty?
532
+
533
+ path = URI.parse(url).path.to_s
534
+ match = path.match(%r{/(10\.\d{4,9}/[^?#/\s]+(?:/[^?#\s]+)*)}i)
535
+ match && match[1].downcase.delete_suffix("/")
536
+ rescue URI::InvalidURIError
537
+ nil
538
+ end
539
+
540
+ def requested_identifier_tokens(url)
541
+ uri = URI.parse(url)
542
+ raw = [uri.path, uri.query].compact.join(" ")
543
+ URI.decode_www_form_component(raw.tr("+", " ")).downcase
544
+ .gsub(/[^a-z0-9]+/, " ").split.select do |token|
545
+ token.length >= 3 && token.match?(/[a-z]/) && !TITLE_SLUG_STOPWORDS.include?(token) &&
546
+ !CONTENT_ROUTE_SEGMENTS.include?(token) && token != "www"
547
+ end.uniq
548
+ rescue ArgumentError, URI::InvalidURIError
549
+ []
550
+ end
551
+
552
+ def matching_apex_instrument_redirect?(payload, requested_url, final_url)
553
+ requested_instrument = apex_instrument_id(requested_url)
554
+ final_instrument = apex_instrument_id(final_url)
555
+ return false if requested_instrument.nil? || final_instrument.nil?
556
+ return false unless requested_instrument == final_instrument
557
+
558
+ content = FetchUtil.normalize_whitespace([payload["title"], payload["markdown"]].compact.join(" "))
559
+ content.match?(/\b(?:Convention|Recommendation|Protocol)\s+[A-Z]\d{2,4}\b/i) &&
560
+ content.match?(/\b(?:International Labour|Labou?r Organisation|General Conference|Article\s+1)\b/i)
561
+ end
562
+
563
+ def apex_instrument_id(url)
564
+ query = URI.parse(url).query.to_s
565
+ decoded = URI.decode_www_form_component(query.tr("+", " "))
566
+ decoded[/\bP\d+_INSTRUMENT_ID[:=](\d+)\b/i, 1]
567
+ rescue ArgumentError, URI::InvalidURIError
568
+ nil
569
+ end
570
+
571
+ def code_like_identifier?(token)
572
+ token.match?(/\A(?=.*[a-z])(?=.*\d)[a-z0-9]{3,16}\z/)
573
+ end
574
+
575
+ def mismatched_resource_url(requested_url, final_url, canonical_url)
576
+ requested_path = normalized_path_key(requested_url)
577
+ return nil if requested_path.empty?
578
+
579
+ final_path = normalized_path_key(final_url)
580
+ return final_url if !final_path.empty? && final_path != requested_path
581
+
582
+ return nil unless same_effective_domain?(final_url, canonical_url)
583
+
584
+ canonical_path = normalized_path_key(canonical_url)
585
+ return canonical_url if !canonical_path.empty? && canonical_path != requested_path
586
+
587
+ nil
588
+ end
589
+
590
+ def same_effective_domain?(left_url, right_url)
591
+ return false if left_url.nil? || right_url.nil?
592
+
593
+ left_domain = effective_domain(left_url)
594
+ right_domain = effective_domain(right_url)
595
+ !left_domain.nil? && left_domain == right_domain
596
+ end
597
+
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
+ def title_slug_keywords(url)
618
+ URI.parse(url).path.to_s.split("/").reject(&:empty?).reverse_each do |segment|
619
+ tokens = significant_slug_tokens(segment)
620
+ return tokens if tokens.length >= 2
621
+ end
622
+
623
+ []
624
+ rescue URI::InvalidURIError
625
+ []
626
+ end
627
+
628
+ def significant_slug_tokens(text)
629
+ decoded = URI.decode_www_form_component(text.to_s.tr("+", " "))
630
+ decoded.downcase.gsub(/[^a-z0-9]+/, " ").split.select do |token|
631
+ token.length >= 3 && token.match?(/[a-z]/) && !token.match?(/\d/) &&
632
+ !TITLE_SLUG_STOPWORDS.include?(token)
633
+ end.uniq
634
+ rescue ArgumentError
635
+ []
636
+ end
637
+
149
638
  def fallback_result(url, fallback)
150
639
  build_result(url, *fallback)
151
640
  end
152
641
 
642
+ def network_error_result(url, error)
643
+ message = error.message.to_s.strip
644
+ warning = dns_resolution_error?(message) ? "dns_resolution_failed" : "network_error"
645
+ Result.error(url: url, warning: warning, message: message)
646
+ end
647
+
648
+ def network_error?(error)
649
+ error.message.to_s.match?(NETWORK_ERROR_PATTERN)
650
+ end
651
+
652
+ def dns_resolution_error?(message)
653
+ message.match?(/ERR_NAME_NOT_RESOLVED|DNS|resolve|resolution|ENOTFOUND|EAI_AGAIN/i)
654
+ end
655
+
153
656
  def docs_fallback_candidate?(requested_url, result = nil)
154
657
  candidates = [requested_url]
155
658
  if result