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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/Rakefile +5 -0
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/browser/site_stabilization/gitlab_repo.rb +32 -0
- data/lib/fetch_util/browser/site_stabilization.rb +2 -0
- data/lib/fetch_util/browser/stabilization/page_flow.rb +1 -0
- data/lib/fetch_util/cli.rb +1 -0
- data/lib/fetch_util/fetcher.rb +545 -42
- data/lib/fetch_util/raw_docs_fallback.rb +40 -3
- data/lib/fetch_util/result.rb +100 -3
- data/lib/fetch_util/version.rb +1 -1
- metadata +2 -1
data/lib/fetch_util/fetcher.rb
CHANGED
|
@@ -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(
|
|
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.
|
|
128
|
+
Result.from_payload(
|
|
97
129
|
url: url,
|
|
98
130
|
final_url: final_url,
|
|
99
|
-
|
|
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
|
|
130
|
-
|
|
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
|