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