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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/Rakefile +5 -0
- data/lib/fetch_util/assets/extract.js +1 -1
- data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +187 -104
- data/lib/fetch_util/browser/interaction_helpers/dom_interaction.rb +14 -2
- data/lib/fetch_util/browser/navigation/headers_and_readiness.rb +0 -7
- data/lib/fetch_util/browser/navigation/navigator_patch.rb +3 -1
- data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +28 -27
- data/lib/fetch_util/browser/site_stabilization/gitlab_repo.rb +32 -0
- data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +25 -32
- data/lib/fetch_util/browser/site_stabilization.rb +2 -0
- data/lib/fetch_util/browser/stabilization/page_flow.rb +93 -10
- data/lib/fetch_util/browser.rb +55 -16
- data/lib/fetch_util/cli.rb +1 -0
- data/lib/fetch_util/extractor.rb +27 -3
- data/lib/fetch_util/fetcher.rb +696 -67
- data/lib/fetch_util/parallel_fetcher.rb +15 -20
- data/lib/fetch_util/raw_docs_fallback.rb +48 -24
- data/lib/fetch_util/regulatory/http_client.rb +111 -43
- data/lib/fetch_util/result.rb +100 -3
- data/lib/fetch_util/version.rb +1 -1
- data/lib/fetch_util.rb +12 -2
- metadata +3 -2
data/lib/fetch_util/fetcher.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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(
|
|
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,
|
|
72
|
-
warnings = resolved_warnings(
|
|
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.
|
|
223
|
+
Result.from_payload(
|
|
97
224
|
url: url,
|
|
98
225
|
final_url: final_url,
|
|
99
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
130
|
-
|
|
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?(
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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|
|
|
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
|