http_mimic 0.3.2 → 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 +16 -0
- data/README.md +48 -1
- data/lib/http_mimic/command_builder.rb +96 -11
- data/lib/http_mimic/configuration.rb +32 -4
- data/lib/http_mimic/cookie_store.rb +118 -0
- data/lib/http_mimic/cookies.rb +38 -0
- data/lib/http_mimic/downloader.rb +102 -0
- data/lib/http_mimic/exceptions.rb +15 -0
- data/lib/http_mimic/js_runtime.rb +78 -0
- data/lib/http_mimic/module_methods.rb +5 -0
- data/lib/http_mimic/request.rb +46 -1
- data/lib/http_mimic/version.rb +1 -1
- data/lib/http_mimic/waf/akamai_solver.rb +123 -0
- data/lib/http_mimic/waf/detector.rb +61 -0
- data/lib/http_mimic/waf.rb +22 -0
- data/lib/http_mimic.rb +51 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b0a9fff1b74c98ea24c294a491b00ba57e270d24e6c0bb7a669e6d2db264d8a
|
|
4
|
+
data.tar.gz: e11d3ae615b92abcfedda8547120290d82020acd82230353a127cc97dfc844f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e0a93bb67bb84582e24df07ea4d51d291a53f0b48ab77ad0bd4a63b666bede4b493bbb405ad34d76602e086eafd76a455668c2a491488d83dd2c0be1060fe66
|
|
7
|
+
data.tar.gz: c616cafefeb23d78af7320beab5ddc3b8f79aafda440fd1fac889f5a5990417b31716105e8c74cab566e6c0ed6eca04a82a397ddc4242eb59caed519e52739de
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-08-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Target-Specific Navigation Headers & Client Hints Simulation**:
|
|
12
|
+
- Automatically generates browser-accurate Navigation Headers (`sec-fetch-dest`, `sec-fetch-mode`, `sec-fetch-site`, `upgrade-insecure-requests`) and Client Hints (`sec-ch-ua`, `sec-ch-ua-mobile`, `sec-ch-ua-platform`) tailored to the selected impersonation target.
|
|
13
|
+
- Firefox (`firefox*`, `ff*`), Safari / iOS (`safari*`, `ios*`), and Tor targets strictly omit Chromium-only `sec-ch-ua*` headers to avoid anti-bot fingerprint anomalies and detection.
|
|
14
|
+
- Android Chrome targets properly send `sec-ch-ua-mobile: ?1` and `sec-ch-ua-platform: "Android"`.
|
|
15
|
+
- Edge targets properly send `"Microsoft Edge"` brand headers with `"Windows"` platform.
|
|
16
|
+
- **Unified Persistent CookieStore (`persist_cookies`)**:
|
|
17
|
+
- Added `:persist_cookies` request option and `persist_cookies` class-level DSL method to automatically load and persist session cookies across requests per host into `~/.http_mimic/cookies/`.
|
|
18
|
+
- Added helper methods: `HttpMimic.load_cookies(host, max_age: nil)`, `HttpMimic.save_cookies(host, cookies, ttl: nil)`, and `HttpMimic.clear_cookies!(host = nil)`.
|
|
19
|
+
- **WAF Challenge Solving & Fallback Performance Optimizations**:
|
|
20
|
+
- Added single-pass WAF solver guard (`waf_solve_attempted`) to prevent redundant JavaScript solver executions across fallback attempts.
|
|
21
|
+
- Added cross-attempt cookie accumulation to preserve session state between fallback profiles.
|
|
22
|
+
- Added `HttpMimic::Waf::Detector.challenge_page?` to detect interstitial challenges disguised under HTTP 200 responses.
|
|
23
|
+
|
|
8
24
|
## [0.3.2] - 2026-08-29
|
|
9
25
|
|
|
10
26
|
### Added
|
data/README.md
CHANGED
|
@@ -21,7 +21,7 @@ It also includes **Webdrivers-like automatic driver management**, automatically
|
|
|
21
21
|
- Edge & Tor support: `edge101`, `edge99`, `tor145`.
|
|
22
22
|
- **HTTParty-Style API**:
|
|
23
23
|
- Direct module methods: `HttpMimic.get`, `HttpMimic.post`, etc.
|
|
24
|
-
- Class mixin via `include HttpMimic` (`base_uri`, `headers`, `default_params`, `default_timeout`, `impersonate`, `proxy`, `cookies`).
|
|
24
|
+
- Class mixin via `include HttpMimic` (`base_uri`, `headers`, `default_params`, `default_timeout`, `impersonate`, `proxy`, `cookies`, `persist_cookies`).
|
|
25
25
|
- Reusable instance client: `HttpMimic::Client.new(...)`.
|
|
26
26
|
- **Zero Shell Injection Risk**:
|
|
27
27
|
- Executes commands with array arguments via `Open3.capture3(*cmd_array)`.
|
|
@@ -186,6 +186,40 @@ HttpMimic.get(images.first).save('samba_og.jpg')
|
|
|
186
186
|
|
|
187
187
|
---
|
|
188
188
|
|
|
189
|
+
## 🍪 Persistent Host CookieStore
|
|
190
|
+
|
|
191
|
+
`HttpMimic` supports persistent host-based cookie caching. Once enabled, session and verification cookies are automatically saved to `~/.http_mimic/cookies/<host>.json` and reused in subsequent requests to the same host:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# 1. Enable per-request
|
|
195
|
+
response = HttpMimic.get('https://example.com/items', persist_cookies: true)
|
|
196
|
+
|
|
197
|
+
# 2. Or enable in class mixin
|
|
198
|
+
class Scraper
|
|
199
|
+
include HttpMimic
|
|
200
|
+
persist_cookies true
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# 3. Manual CookieStore helpers
|
|
204
|
+
HttpMimic.load_cookies('example.com') # => Hash of active cookies
|
|
205
|
+
HttpMimic.save_cookies('example.com', { session: '123' }) # Save cookies with default TTL
|
|
206
|
+
HttpMimic.clear_cookies!('example.com') # Clear host or all cookies
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 🧭 Target-Specific Navigation Headers & Client Hints
|
|
212
|
+
|
|
213
|
+
`HttpMimic` automatically constructs browser-accurate HTTP navigation headers on `GET` and `HEAD` requests:
|
|
214
|
+
|
|
215
|
+
- **Chrome Desktop / Mobile**: Generates `sec-ch-ua`, `sec-ch-ua-mobile`, `sec-ch-ua-platform`, and standard Chrome `Accept` headers.
|
|
216
|
+
- **Firefox & Safari / iOS**: **Strictly omits** Chromium-only `sec-ch-ua*` headers to prevent anti-bot detection and fingerprint mismatch anomalies.
|
|
217
|
+
- **Edge**: Generates Microsoft Edge brand `sec-ch-ua` headers.
|
|
218
|
+
|
|
219
|
+
Can be disabled globally with `config.navigation_headers = false` or per request via `navigation_headers: false`.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
189
223
|
## ⚙️ Global Configuration
|
|
190
224
|
|
|
191
225
|
Configure global defaults in an initializer (e.g., `config/initializers/http_mimic.rb`):
|
|
@@ -207,6 +241,16 @@ HttpMimic.configure do |config|
|
|
|
207
241
|
config.raise_on_error = false # Raise exceptions on HTTP errors / non-zero exits
|
|
208
242
|
config.debug = false # Print debug logs
|
|
209
243
|
|
|
244
|
+
# Navigation headers & Client Hints simulation (enabled by default)
|
|
245
|
+
config.navigation_headers = true # Browser-accurate sec-ch-ua, sec-fetch-*, Accept headers
|
|
246
|
+
|
|
247
|
+
# Host CookieStore Persistence (disabled by default)
|
|
248
|
+
config.persist_cookies = false # Auto-persist cookies per host
|
|
249
|
+
config.cookie_store_dir = File.expand_path('~/.http_mimic/cookies') # Directory for cookie store
|
|
250
|
+
|
|
251
|
+
# WAF challenge solving (enabled by default)
|
|
252
|
+
config.auto_solve_waf = true # Automatically solve detected WAF JS challenges
|
|
253
|
+
|
|
210
254
|
# Webdrivers-like auto-download settings (enabled by default)
|
|
211
255
|
config.auto_download = true # Auto-download missing binary
|
|
212
256
|
config.driver_version = 'v2.1.1' # Target release version
|
|
@@ -225,12 +269,15 @@ end
|
|
|
225
269
|
| `:auto_fallback` | Boolean | Whether to automatically retry with alternative profile on blocked status (default `true`) |
|
|
226
270
|
| `:retry_statuses`| Array | Status codes that trigger auto-fallback (default `[403, 429, 503]`) |
|
|
227
271
|
| `:impersonate` | String | Target browser to mimic (e.g., `'chrome131'`, `'chrome120'`, `'firefox135'`, `'safari180'`, `'tor145'`) |
|
|
272
|
+
| `:navigation_headers` | Boolean | Automatically generate browser-accurate navigation headers (`sec-ch-ua`, `sec-fetch-*`, `Accept`) (default `true`) |
|
|
273
|
+
| `:solve_waf` | Boolean | Automatically detect and solve WAF challenges (e.g. Akamai) via embedded QuickJS (default `true`) |
|
|
228
274
|
| `:binary` | String | Path to a custom `curl-impersonate` executable |
|
|
229
275
|
| `:query` / `:params` | Hash | URL query parameters (supports nested parameters and encoding) |
|
|
230
276
|
| `:headers` | Hash | Custom HTTP request headers |
|
|
231
277
|
| `:json` | Hash / Array | Serialized to JSON with `Content-Type: application/json` |
|
|
232
278
|
| `:body` | Hash / String | Form payload (Hash) or raw request body string |
|
|
233
279
|
| `:cookies` | Hash / String | Request cookies |
|
|
280
|
+
| `:persist_cookies` | Boolean | Automatically load and save cookies for the host across requests |
|
|
234
281
|
| `:cookie_jar` | String | Path to save cookies (`-c`) |
|
|
235
282
|
| `:cookie_file` | String | Path to read cookies (`-b`) |
|
|
236
283
|
| `:timeout` | Integer / Float | Maximum execution timeout in seconds (`--max-time`) |
|
|
@@ -208,6 +208,13 @@ module HttpMimic
|
|
|
208
208
|
headers_to_send['Accept'] ||= '*/*'
|
|
209
209
|
headers_to_send['Accept-Encoding'] ||= 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3'
|
|
210
210
|
headers_to_send['Connection'] ||= 'close'
|
|
211
|
+
else
|
|
212
|
+
# Automatic Navigation Headers for browser profiles on GET/HEAD
|
|
213
|
+
nav_headers_enabled = options.fetch(:navigation_headers, config.navigation_headers)
|
|
214
|
+
if nav_headers_enabled && %i[get head].include?(method.to_s.downcase.to_sym)
|
|
215
|
+
target = resolve_target
|
|
216
|
+
apply_navigation_headers(headers_to_send, target)
|
|
217
|
+
end
|
|
211
218
|
end
|
|
212
219
|
|
|
213
220
|
# Append headers
|
|
@@ -232,7 +239,20 @@ module HttpMimic
|
|
|
232
239
|
[binary, args, stdin_data, final_url]
|
|
233
240
|
end
|
|
234
241
|
|
|
235
|
-
def
|
|
242
|
+
def resolve_target
|
|
243
|
+
case options[:profile]
|
|
244
|
+
when :android
|
|
245
|
+
(options[:android_impersonate] || 'chrome131_android').to_s.downcase
|
|
246
|
+
when :ios
|
|
247
|
+
(options[:ios_impersonate] || 'safari260_ios').to_s.downcase
|
|
248
|
+
when :mobile
|
|
249
|
+
(options[:mobile_impersonate] || 'chrome131_android').to_s.downcase
|
|
250
|
+
else
|
|
251
|
+
(options[:impersonate] || config.default_impersonate).to_s.downcase
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def resolve_binary(target = resolve_target)
|
|
236
256
|
# 0. If profile is explicitly set to :curl, use system curl
|
|
237
257
|
if options[:profile] == :curl
|
|
238
258
|
curl_path = find_executable('curl') || 'curl'
|
|
@@ -248,16 +268,6 @@ module HttpMimic
|
|
|
248
268
|
raise BinaryNotFoundError, "Specified executable does not exist or is not executable: #{explicit}"
|
|
249
269
|
end
|
|
250
270
|
|
|
251
|
-
target = case options[:profile]
|
|
252
|
-
when :android
|
|
253
|
-
(options[:android_impersonate] || 'chrome131_android').to_s.downcase
|
|
254
|
-
when :ios
|
|
255
|
-
(options[:ios_impersonate] || 'safari260_ios').to_s.downcase
|
|
256
|
-
when :mobile
|
|
257
|
-
(options[:mobile_impersonate] || 'chrome131_android').to_s.downcase
|
|
258
|
-
else
|
|
259
|
-
(options[:impersonate] || config.default_impersonate).to_s.downcase
|
|
260
|
-
end
|
|
261
271
|
candidate_names = TARGET_BINARY_MAP[target] || ["curl_#{target}", target]
|
|
262
272
|
|
|
263
273
|
# 2. Check local installation directory (~/.http_mimic/bin)
|
|
@@ -304,6 +314,81 @@ module HttpMimic
|
|
|
304
314
|
|
|
305
315
|
private
|
|
306
316
|
|
|
317
|
+
def apply_navigation_headers(headers_to_send, target)
|
|
318
|
+
target_str = target.to_s.downcase
|
|
319
|
+
|
|
320
|
+
if target_str.start_with?('firefox', 'ff')
|
|
321
|
+
# Firefox (Gecko): Mozilla does not send Client Hints (sec-ch-ua*)
|
|
322
|
+
headers_to_send['sec-fetch-dest'] ||= 'document'
|
|
323
|
+
headers_to_send['sec-fetch-mode'] ||= 'navigate'
|
|
324
|
+
headers_to_send['sec-fetch-site'] ||= 'none'
|
|
325
|
+
headers_to_send['sec-fetch-user'] ||= '?1'
|
|
326
|
+
headers_to_send['upgrade-insecure-requests'] ||= '1'
|
|
327
|
+
headers_to_send['accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'
|
|
328
|
+
headers_to_send['accept-language'] ||= 'en-US,en;q=0.5'
|
|
329
|
+
|
|
330
|
+
elsif target_str.start_with?('safari') || target_str == 'ios' || target_str == 'safari_ios' || target_str.include?('_ios')
|
|
331
|
+
# Safari / WebKit (Desktop & iOS): WebKit does not send Client Hints (sec-ch-ua*)
|
|
332
|
+
headers_to_send['sec-fetch-dest'] ||= 'document'
|
|
333
|
+
headers_to_send['sec-fetch-mode'] ||= 'navigate'
|
|
334
|
+
headers_to_send['sec-fetch-site'] ||= 'none'
|
|
335
|
+
headers_to_send['accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
336
|
+
headers_to_send['accept-language'] ||= 'en-US,en;q=0.9'
|
|
337
|
+
|
|
338
|
+
elsif target_str.start_with?('tor')
|
|
339
|
+
# Tor Browser: No sec-ch-ua headers
|
|
340
|
+
headers_to_send['sec-fetch-dest'] ||= 'document'
|
|
341
|
+
headers_to_send['sec-fetch-mode'] ||= 'navigate'
|
|
342
|
+
headers_to_send['sec-fetch-site'] ||= 'none'
|
|
343
|
+
headers_to_send['sec-fetch-user'] ||= '?1'
|
|
344
|
+
headers_to_send['upgrade-insecure-requests'] ||= '1'
|
|
345
|
+
headers_to_send['accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
346
|
+
headers_to_send['accept-language'] ||= 'en-US,en;q=0.5'
|
|
347
|
+
|
|
348
|
+
elsif target_str.start_with?('edge')
|
|
349
|
+
# Edge (Chromium): Microsoft Edge sec-ch-ua
|
|
350
|
+
ver = target_str[/\d+/] || '101'
|
|
351
|
+
headers_to_send['sec-ch-ua'] ||= "\"Microsoft Edge\";v=\"#{ver}\", \"Chromium\";v=\"#{ver}\", \"Not_A Brand\";v=\"24\""
|
|
352
|
+
headers_to_send['sec-ch-ua-mobile'] ||= '?0'
|
|
353
|
+
headers_to_send['sec-ch-ua-platform'] ||= '"Windows"'
|
|
354
|
+
headers_to_send['sec-fetch-dest'] ||= 'document'
|
|
355
|
+
headers_to_send['sec-fetch-mode'] ||= 'navigate'
|
|
356
|
+
headers_to_send['sec-fetch-site'] ||= 'none'
|
|
357
|
+
headers_to_send['sec-fetch-user'] ||= '?1'
|
|
358
|
+
headers_to_send['upgrade-insecure-requests'] ||= '1'
|
|
359
|
+
headers_to_send['accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'
|
|
360
|
+
headers_to_send['accept-language'] ||= 'en-US,en;q=0.9'
|
|
361
|
+
|
|
362
|
+
elsif target_str.include?('android')
|
|
363
|
+
# Android Chrome
|
|
364
|
+
ver = target_str[/\d+/] || '131'
|
|
365
|
+
headers_to_send['sec-ch-ua'] ||= "\"Chromium\";v=\"#{ver}\", \"Not_A Brand\";v=\"24\", \"Google Chrome\";v=\"#{ver}\""
|
|
366
|
+
headers_to_send['sec-ch-ua-mobile'] ||= '?1'
|
|
367
|
+
headers_to_send['sec-ch-ua-platform'] ||= '"Android"'
|
|
368
|
+
headers_to_send['sec-fetch-dest'] ||= 'document'
|
|
369
|
+
headers_to_send['sec-fetch-mode'] ||= 'navigate'
|
|
370
|
+
headers_to_send['sec-fetch-site'] ||= 'none'
|
|
371
|
+
headers_to_send['sec-fetch-user'] ||= '?1'
|
|
372
|
+
headers_to_send['upgrade-insecure-requests'] ||= '1'
|
|
373
|
+
headers_to_send['accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'
|
|
374
|
+
headers_to_send['accept-language'] ||= 'en-US,en;q=0.9'
|
|
375
|
+
|
|
376
|
+
else
|
|
377
|
+
# Chrome Desktop (Default)
|
|
378
|
+
ver = target_str[/\d+/] || '131'
|
|
379
|
+
headers_to_send['sec-ch-ua'] ||= "\"Google Chrome\";v=\"#{ver}\", \"Chromium\";v=\"#{ver}\", \"Not_A Brand\";v=\"24\""
|
|
380
|
+
headers_to_send['sec-ch-ua-mobile'] ||= '?0'
|
|
381
|
+
headers_to_send['sec-ch-ua-platform'] ||= '"macOS"'
|
|
382
|
+
headers_to_send['sec-fetch-dest'] ||= 'document'
|
|
383
|
+
headers_to_send['sec-fetch-mode'] ||= 'navigate'
|
|
384
|
+
headers_to_send['sec-fetch-site'] ||= 'none'
|
|
385
|
+
headers_to_send['sec-fetch-user'] ||= '?1'
|
|
386
|
+
headers_to_send['upgrade-insecure-requests'] ||= '1'
|
|
387
|
+
headers_to_send['accept'] ||= 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'
|
|
388
|
+
headers_to_send['accept-language'] ||= 'en-US,en;q=0.9'
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
307
392
|
def find_in_download_dir(candidates)
|
|
308
393
|
target_dir = File.expand_path(config.install_dir)
|
|
309
394
|
return nil unless Dir.exist?(target_dir)
|
|
@@ -26,6 +26,20 @@ module HttpMimic
|
|
|
26
26
|
attr_accessor :install_dir
|
|
27
27
|
attr_accessor :github_repo
|
|
28
28
|
|
|
29
|
+
# QuickJS standalone binary management settings
|
|
30
|
+
attr_accessor :qjs_version
|
|
31
|
+
attr_accessor :qjs_github_repo
|
|
32
|
+
|
|
33
|
+
# WAF & JS challenge solving settings
|
|
34
|
+
attr_accessor :auto_solve_waf
|
|
35
|
+
|
|
36
|
+
# Shared Host-based CookieStore settings
|
|
37
|
+
attr_accessor :persist_cookies
|
|
38
|
+
attr_accessor :cookie_store_dir
|
|
39
|
+
|
|
40
|
+
# Modern Navigation Headers simulation
|
|
41
|
+
attr_accessor :navigation_headers
|
|
42
|
+
|
|
29
43
|
def initialize
|
|
30
44
|
@default_impersonate = 'chrome131'
|
|
31
45
|
@default_mobile_impersonate = 'safari180_ios'
|
|
@@ -45,10 +59,24 @@ module HttpMimic
|
|
|
45
59
|
@retry_statuses = [403, 429, 503]
|
|
46
60
|
|
|
47
61
|
# Enable automatic downloading of curl-impersonate driver (lexiforest) by default
|
|
48
|
-
@auto_download
|
|
49
|
-
@driver_version
|
|
50
|
-
@install_dir
|
|
51
|
-
@github_repo
|
|
62
|
+
@auto_download = true
|
|
63
|
+
@driver_version = 'v2.1.1'
|
|
64
|
+
@install_dir = File.expand_path('~/.http_mimic/bin')
|
|
65
|
+
@github_repo = 'lexiforest/curl-impersonate'
|
|
66
|
+
|
|
67
|
+
# QuickJS defaults
|
|
68
|
+
@qjs_version = 'v0.16.2'
|
|
69
|
+
@qjs_github_repo = 'quickjs-ng/quickjs'
|
|
70
|
+
|
|
71
|
+
# WAF challenge solving defaults
|
|
72
|
+
@auto_solve_waf = true
|
|
73
|
+
|
|
74
|
+
# Shared Host CookieStore defaults
|
|
75
|
+
@persist_cookies = false
|
|
76
|
+
@cookie_store_dir = File.expand_path('~/.http_mimic/cookies')
|
|
77
|
+
|
|
78
|
+
# Navigation Headers defaults
|
|
79
|
+
@navigation_headers = true
|
|
52
80
|
end
|
|
53
81
|
end
|
|
54
82
|
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module HttpMimic
|
|
8
|
+
class CookieStore
|
|
9
|
+
DEFAULT_TTL = 7200 # 2 hours
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def store_dir
|
|
13
|
+
File.expand_path(HttpMimic.configuration.cookie_store_dir || '~/.http_mimic/cookies')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cookie_file_for(host)
|
|
17
|
+
clean_host = sanitize_host(host)
|
|
18
|
+
File.join(store_dir, "#{clean_host}.json")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Load cookies for a given host
|
|
22
|
+
# Returns a Hash of cookies if valid and not expired, or empty Hash
|
|
23
|
+
def load(host, max_age: DEFAULT_TTL)
|
|
24
|
+
file = cookie_file_for(host)
|
|
25
|
+
return {} unless File.file?(file)
|
|
26
|
+
|
|
27
|
+
data = nil
|
|
28
|
+
File.open(file, File::RDONLY) do |f|
|
|
29
|
+
f.flock(File::LOCK_SH)
|
|
30
|
+
data = JSON.parse(f.read) rescue nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
return {} unless data.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
updated_at = data['updated_at'].to_i
|
|
36
|
+
if !max_age.nil?
|
|
37
|
+
return {} if (Time.now.to_i - updated_at) > max_age
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
data['cookies'] || {}
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
if HttpMimic.configuration.debug
|
|
43
|
+
puts "[HttpMimic::CookieStore] Error loading cookies for #{host}: #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
{}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Save cookies for a given host with atomic locking
|
|
49
|
+
def save(host, cookies, ttl: DEFAULT_TTL)
|
|
50
|
+
return if cookies.nil? || cookies.empty?
|
|
51
|
+
|
|
52
|
+
clean_host = sanitize_host(host)
|
|
53
|
+
dir = store_dir
|
|
54
|
+
FileUtils.mkdir_p(dir)
|
|
55
|
+
|
|
56
|
+
file = cookie_file_for(clean_host)
|
|
57
|
+
existing_cookies = load(clean_host, max_age: ttl) || {}
|
|
58
|
+
|
|
59
|
+
new_cookies = cookies.is_a?(Cookies) ? cookies.to_h : cookies.dup
|
|
60
|
+
merged = existing_cookies.merge(stringify_keys(new_cookies))
|
|
61
|
+
|
|
62
|
+
payload = {
|
|
63
|
+
'host' => clean_host,
|
|
64
|
+
'updated_at' => Time.now.to_i,
|
|
65
|
+
'ttl' => ttl,
|
|
66
|
+
'cookies' => merged
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Thread & Process safe atomic write
|
|
70
|
+
tmp_file = "#{file}.tmp.#{Process.pid}_#{Time.now.to_f}"
|
|
71
|
+
File.open(tmp_file, File::RDWR | File::CREAT, 0644) do |f|
|
|
72
|
+
f.flock(File::LOCK_EX)
|
|
73
|
+
f.write(JSON.pretty_generate(payload))
|
|
74
|
+
f.flush
|
|
75
|
+
end
|
|
76
|
+
File.rename(tmp_file, file)
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
FileUtils.rm_f(tmp_file) if tmp_file
|
|
79
|
+
if HttpMimic.configuration.debug
|
|
80
|
+
puts "[HttpMimic::CookieStore] Error saving cookies for #{host}: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Clear stored cookies for a host or all hosts
|
|
85
|
+
def clear(host = nil)
|
|
86
|
+
if host
|
|
87
|
+
file = cookie_file_for(host)
|
|
88
|
+
FileUtils.rm_f(file)
|
|
89
|
+
else
|
|
90
|
+
FileUtils.rm_rf(store_dir)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Extract host from URL or host string
|
|
95
|
+
def extract_host(url_or_host)
|
|
96
|
+
str = url_or_host.to_s.strip
|
|
97
|
+
if str =~ %r{^https?://}i
|
|
98
|
+
URI.parse(str).host
|
|
99
|
+
else
|
|
100
|
+
str.split(':').first
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
str
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def sanitize_host(host)
|
|
109
|
+
h = extract_host(host) || 'default'
|
|
110
|
+
h.gsub(/[^a-zA-Z0-9.\-_]/, '_')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def stringify_keys(hash)
|
|
114
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/http_mimic/cookies.rb
CHANGED
|
@@ -47,6 +47,44 @@ module HttpMimic
|
|
|
47
47
|
@store.values
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
def empty?
|
|
51
|
+
@store.empty?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def size
|
|
55
|
+
@store.size
|
|
56
|
+
end
|
|
57
|
+
alias length size
|
|
58
|
+
|
|
59
|
+
def delete(name)
|
|
60
|
+
@cookie_items.delete(name.to_s)
|
|
61
|
+
@store.delete(name.to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clear
|
|
65
|
+
@cookie_items.clear
|
|
66
|
+
@store.clear
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def merge(other)
|
|
70
|
+
dup_cookies = self.class.new(@store)
|
|
71
|
+
if other.is_a?(Cookies)
|
|
72
|
+
other.each { |k, v| dup_cookies[k] = v }
|
|
73
|
+
elsif other.is_a?(Hash)
|
|
74
|
+
other.each { |k, v| dup_cookies[k] = v }
|
|
75
|
+
end
|
|
76
|
+
dup_cookies
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def merge!(other)
|
|
80
|
+
if other.is_a?(Cookies)
|
|
81
|
+
other.each { |k, v| self[k] = v }
|
|
82
|
+
elsif other.is_a?(Hash)
|
|
83
|
+
other.each { |k, v| self[k] = v }
|
|
84
|
+
end
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
50
88
|
def to_h
|
|
51
89
|
@store.dup
|
|
52
90
|
end
|
|
@@ -13,6 +13,9 @@ module HttpMimic
|
|
|
13
13
|
DEFAULT_GITHUB_REPO = 'lexiforest/curl-impersonate'
|
|
14
14
|
DEFAULT_VERSION = 'v2.1.1'
|
|
15
15
|
|
|
16
|
+
DEFAULT_QJS_REPO = 'quickjs-ng/quickjs'
|
|
17
|
+
DEFAULT_QJS_VERSION = 'v0.16.2'
|
|
18
|
+
|
|
16
19
|
class DownloadError < HttpMimic::Error; end
|
|
17
20
|
class UnsupportedPlatformError < HttpMimic::Error; end
|
|
18
21
|
|
|
@@ -63,6 +66,105 @@ module HttpMimic
|
|
|
63
66
|
true
|
|
64
67
|
end
|
|
65
68
|
|
|
69
|
+
def download_qjs!(version: nil, install_dir: nil, repo: nil, force: false)
|
|
70
|
+
target_version = normalize_version(version || HttpMimic.configuration.qjs_version || DEFAULT_QJS_VERSION)
|
|
71
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
72
|
+
target_repo = repo || HttpMimic.configuration.qjs_github_repo || DEFAULT_QJS_REPO
|
|
73
|
+
|
|
74
|
+
FileUtils.mkdir_p(target_dir)
|
|
75
|
+
|
|
76
|
+
dest_binary = File.join(target_dir, binary_name_for_platform('qjs'))
|
|
77
|
+
|
|
78
|
+
if !force && qjs_installed?(version: target_version, install_dir: target_dir)
|
|
79
|
+
log_info("qjs #{target_version} is already installed in #{target_dir}")
|
|
80
|
+
return dest_binary
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
asset_name = qjs_platform_asset
|
|
84
|
+
download_url = "https://github.com/#{target_repo}/releases/download/#{target_version}/#{asset_name}"
|
|
85
|
+
|
|
86
|
+
log_info("Downloading QuickJS (#{target_version}) [#{asset_name}]...")
|
|
87
|
+
binary_data = fetch_binary(download_url)
|
|
88
|
+
|
|
89
|
+
File.binwrite(dest_binary, binary_data)
|
|
90
|
+
File.chmod(0755, dest_binary)
|
|
91
|
+
|
|
92
|
+
File.write(qjs_version_file_path(target_dir), target_version)
|
|
93
|
+
|
|
94
|
+
log_info("QuickJS #{target_version} installation complete! (#{dest_binary})")
|
|
95
|
+
dest_binary
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def qjs_installed?(version: nil, install_dir: nil)
|
|
99
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
100
|
+
qjs_bin = File.join(target_dir, binary_name_for_platform('qjs'))
|
|
101
|
+
return false unless File.file?(qjs_bin) && File.executable?(qjs_bin)
|
|
102
|
+
|
|
103
|
+
if version
|
|
104
|
+
target_version = normalize_version(version)
|
|
105
|
+
v_file = qjs_version_file_path(target_dir)
|
|
106
|
+
return false unless File.file?(v_file)
|
|
107
|
+
return File.read(v_file).strip == target_version
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def qjs_path(install_dir: nil)
|
|
114
|
+
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
115
|
+
candidate = File.join(target_dir, binary_name_for_platform('qjs'))
|
|
116
|
+
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
117
|
+
|
|
118
|
+
# Fallback to system PATH
|
|
119
|
+
sys_qjs = `which qjs 2>/dev/null`.strip
|
|
120
|
+
return sys_qjs if !sys_qjs.empty? && File.executable?(sys_qjs)
|
|
121
|
+
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def qjs_platform_asset
|
|
126
|
+
os = host_os
|
|
127
|
+
cpu = host_cpu
|
|
128
|
+
|
|
129
|
+
case os
|
|
130
|
+
when :macos
|
|
131
|
+
case cpu
|
|
132
|
+
when :arm64 then 'qjs-darwin-arm64'
|
|
133
|
+
when :x86_64 then 'qjs-darwin-x86_64'
|
|
134
|
+
else
|
|
135
|
+
raise UnsupportedPlatformError, "Unsupported macOS CPU architecture for QuickJS: #{cpu}"
|
|
136
|
+
end
|
|
137
|
+
when :linux
|
|
138
|
+
case cpu
|
|
139
|
+
when :x86_64
|
|
140
|
+
'qjs-linux-x86_64'
|
|
141
|
+
when :aarch64, :arm64
|
|
142
|
+
'qjs-linux-aarch64'
|
|
143
|
+
when :arm
|
|
144
|
+
'qjs-linux-armv7'
|
|
145
|
+
when :i386, :i686
|
|
146
|
+
'qjs-linux-x86'
|
|
147
|
+
when :riscv64
|
|
148
|
+
'qjs-linux-riscv64'
|
|
149
|
+
else
|
|
150
|
+
raise UnsupportedPlatformError, "Unsupported Linux CPU architecture for QuickJS: #{cpu}"
|
|
151
|
+
end
|
|
152
|
+
when :windows
|
|
153
|
+
case cpu
|
|
154
|
+
when :x86_64 then 'qjs-windows-x86_64.exe'
|
|
155
|
+
when :i386, :i686 then 'qjs-windows-x86.exe'
|
|
156
|
+
else
|
|
157
|
+
raise UnsupportedPlatformError, "Unsupported Windows CPU architecture for QuickJS: #{cpu}"
|
|
158
|
+
end
|
|
159
|
+
else
|
|
160
|
+
raise UnsupportedPlatformError, "Unsupported operating system for QuickJS: #{RbConfig::CONFIG['host_os']}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def qjs_version_file_path(dir)
|
|
165
|
+
File.join(dir, '.qjs_version')
|
|
166
|
+
end
|
|
167
|
+
|
|
66
168
|
def binary_path(name, install_dir: nil)
|
|
67
169
|
target_dir = File.expand_path(install_dir || HttpMimic.configuration.install_dir)
|
|
68
170
|
candidate = File.join(target_dir, binary_name_for_platform(name))
|
|
@@ -31,4 +31,19 @@ module HttpMimic
|
|
|
31
31
|
|
|
32
32
|
# Raised when response parsing fails
|
|
33
33
|
class ResponseParseError < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised when JavaScript execution fails in JSRuntime
|
|
36
|
+
class JSError < Error
|
|
37
|
+
attr_reader :stderr, :exit_code
|
|
38
|
+
|
|
39
|
+
def initialize(message, stderr: nil, exit_code: nil)
|
|
40
|
+
super(message)
|
|
41
|
+
@stderr = stderr
|
|
42
|
+
@exit_code = exit_code
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Raised when JavaScript execution times out
|
|
47
|
+
class JSTimeoutError < JSError; end
|
|
34
48
|
end
|
|
49
|
+
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module HttpMimic
|
|
8
|
+
class JSRuntime
|
|
9
|
+
STDIN_RUNNER = <<~JS
|
|
10
|
+
const input = std.in.readAsString();
|
|
11
|
+
const result = eval(input);
|
|
12
|
+
if (result !== undefined) {
|
|
13
|
+
if (typeof result === "object" && result !== null) {
|
|
14
|
+
console.log(JSON.stringify(result));
|
|
15
|
+
} else {
|
|
16
|
+
console.log(result);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
JS
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def eval(js_code, timeout: nil, install_dir: nil, auto_download: nil)
|
|
23
|
+
ensure_qjs_installed!(install_dir: install_dir, auto_download: auto_download)
|
|
24
|
+
|
|
25
|
+
qjs_bin = Downloader.qjs_path(install_dir: install_dir)
|
|
26
|
+
raise BinaryNotFoundError, "QuickJS binary 'qjs' not found. Run HttpMimic.download_qjs! to install." unless qjs_bin
|
|
27
|
+
|
|
28
|
+
cmd = [qjs_bin, '--std', '-e', STDIN_RUNNER]
|
|
29
|
+
effective_timeout = timeout || HttpMimic.configuration.default_timeout
|
|
30
|
+
|
|
31
|
+
stdout = nil
|
|
32
|
+
stderr = nil
|
|
33
|
+
status = nil
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
if effective_timeout && effective_timeout > 0
|
|
37
|
+
Timeout.timeout(effective_timeout) do
|
|
38
|
+
stdout, stderr, status = Open3.capture3(*cmd, stdin_data: js_code.to_s)
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
stdout, stderr, status = Open3.capture3(*cmd, stdin_data: js_code.to_s)
|
|
42
|
+
end
|
|
43
|
+
rescue Timeout::Error
|
|
44
|
+
raise JSTimeoutError.new("JavaScript execution timed out after #{effective_timeout}s")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless status && status.success?
|
|
48
|
+
raise JSError.new(
|
|
49
|
+
"JavaScript execution failed: #{stderr.to_s.strip}",
|
|
50
|
+
stderr: stderr,
|
|
51
|
+
exit_code: status ? status.exitstatus : nil
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
stdout.to_s.strip
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def eval_json(js_code, timeout: nil, install_dir: nil, auto_download: nil)
|
|
59
|
+
output = eval(js_code, timeout: timeout, install_dir: install_dir, auto_download: auto_download)
|
|
60
|
+
return nil if output.nil? || output.empty?
|
|
61
|
+
|
|
62
|
+
JSON.parse(output)
|
|
63
|
+
rescue JSON::ParserError => e
|
|
64
|
+
raise ResponseParseError, "Failed to parse JavaScript JSON output: #{e.message} (Output: #{output.inspect})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def ensure_qjs_installed!(install_dir: nil, auto_download: nil)
|
|
70
|
+
should_download = auto_download.nil? ? HttpMimic.configuration.auto_download : auto_download
|
|
71
|
+
return unless should_download
|
|
72
|
+
return if Downloader.qjs_installed?(install_dir: install_dir)
|
|
73
|
+
|
|
74
|
+
Downloader.download_qjs!(install_dir: install_dir)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -41,6 +41,11 @@ module HttpMimic
|
|
|
41
41
|
default_options[:cookies].merge!(c)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
def persist_cookies(enabled = nil)
|
|
45
|
+
return default_options[:persist_cookies] if enabled.nil?
|
|
46
|
+
default_options[:persist_cookies] = enabled
|
|
47
|
+
end
|
|
48
|
+
|
|
44
49
|
def mode(m = nil)
|
|
45
50
|
return default_options[:mode] if m.nil?
|
|
46
51
|
default_options[:mode] = m
|
data/lib/http_mimic/request.rb
CHANGED
|
@@ -18,8 +18,22 @@ module HttpMimic
|
|
|
18
18
|
auto_fallback = options.fetch(:auto_fallback, config.auto_fallback)
|
|
19
19
|
retry_statuses = options[:retry_statuses] || config.retry_statuses || [403, 429, 503]
|
|
20
20
|
|
|
21
|
+
# Persistent CookieStore integration
|
|
22
|
+
should_persist_cookie = options.fetch(:persist_cookies, config.persist_cookies)
|
|
23
|
+
host = CookieStore.extract_host(url)
|
|
24
|
+
|
|
25
|
+
if should_persist_cookie && host
|
|
26
|
+
stored_cookies = CookieStore.load(host)
|
|
27
|
+
if stored_cookies && !stored_cookies.empty?
|
|
28
|
+
log_debug("[HttpMimic::CookieStore] Loaded #{stored_cookies.size} persistent cookies for #{host}")
|
|
29
|
+
user_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
|
|
30
|
+
options[:cookies] = stored_cookies.merge(user_cookies)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
21
34
|
attempts = []
|
|
22
35
|
profiles_to_try = determine_profiles(mode, auto_fallback)
|
|
36
|
+
waf_solve_attempted = false
|
|
23
37
|
|
|
24
38
|
response = nil
|
|
25
39
|
final_status = nil
|
|
@@ -66,7 +80,32 @@ module HttpMimic
|
|
|
66
80
|
final_stderr = stderr
|
|
67
81
|
final_command = full_command
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
# Forward any received cookies to subsequent attempts
|
|
84
|
+
if response.cookies && !response.cookies.empty?
|
|
85
|
+
existing_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
|
|
86
|
+
options[:cookies] = response.cookies.to_h.merge(existing_cookies)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
auto_solve_waf = options.fetch(:solve_waf, config.auto_solve_waf)
|
|
90
|
+
is_blocked = (status.exitstatus != 0) || retry_statuses.include?(response.code) || Waf::Detector.challenge_page?(response)
|
|
91
|
+
|
|
92
|
+
# Only attempt WAF resolution once per request to avoid unnecessary latency on subsequent fallbacks
|
|
93
|
+
if is_blocked && auto_solve_waf && !waf_solve_attempted && method.to_s.upcase == 'GET'
|
|
94
|
+
waf_type = Waf::Detector.detect(response)
|
|
95
|
+
if waf_type
|
|
96
|
+
waf_solve_attempted = true
|
|
97
|
+
log_debug("[HttpMimic] Detected #{waf_type.to_s.capitalize} WAF challenge. Attempting to solve with QuickJS...")
|
|
98
|
+
solved_resp = Waf.solve(url, response, current_opts)
|
|
99
|
+
if solved_resp
|
|
100
|
+
response = solved_resp
|
|
101
|
+
is_blocked = (response.code != 0 && retry_statuses.include?(response.code))
|
|
102
|
+
if response.cookies && !response.cookies.empty?
|
|
103
|
+
options[:cookies] = (options[:cookies] || {}).merge(response.cookies.to_h)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
70
109
|
if !is_blocked || (index == profiles_to_try.size - 1)
|
|
71
110
|
break
|
|
72
111
|
end
|
|
@@ -74,6 +113,12 @@ module HttpMimic
|
|
|
74
113
|
log_debug("[HttpMimic] Attempt #{index + 1} with #{profile} resulted in status #{response.code}. Triggering smart fallback to next profile...")
|
|
75
114
|
end
|
|
76
115
|
|
|
116
|
+
# Persist cookies back to store if enabled
|
|
117
|
+
if should_persist_cookie && host && response && response.cookies && !response.cookies.empty?
|
|
118
|
+
CookieStore.save(host, response.cookies)
|
|
119
|
+
log_debug("[HttpMimic::CookieStore] Saved #{response.cookies.size} cookies for #{host}")
|
|
120
|
+
end
|
|
121
|
+
|
|
77
122
|
handle_errors(final_status, final_stderr, final_command, response)
|
|
78
123
|
response
|
|
79
124
|
end
|
data/lib/http_mimic/version.rb
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module HttpMimic
|
|
7
|
+
module Waf
|
|
8
|
+
class AkamaiSolver
|
|
9
|
+
CONTEXT_JS_PATH = File.expand_path('browser_context.js', __dir__)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def solve(target_url, initial_response, options = {})
|
|
13
|
+
cookies = initial_response.cookies.dup
|
|
14
|
+
impersonate = options[:impersonate] || HttpMimic.configuration.default_impersonate || 'chrome131'
|
|
15
|
+
|
|
16
|
+
sensor_script_url = extract_sensor_script_url(target_url, initial_response.body)
|
|
17
|
+
return nil unless sensor_script_url
|
|
18
|
+
|
|
19
|
+
# 1. Fetch the dynamic sensor script
|
|
20
|
+
script_resp = HttpMimic.get(
|
|
21
|
+
sensor_script_url,
|
|
22
|
+
impersonate: impersonate,
|
|
23
|
+
cookies: cookies,
|
|
24
|
+
headers: { 'Referer' => target_url },
|
|
25
|
+
auto_fallback: false,
|
|
26
|
+
solve_waf: false
|
|
27
|
+
)
|
|
28
|
+
return nil unless script_resp.success?
|
|
29
|
+
|
|
30
|
+
sensor_js = script_resp.body
|
|
31
|
+
context_js = File.read(CONTEXT_JS_PATH)
|
|
32
|
+
cookie_str = cookies.to_cookie_string
|
|
33
|
+
|
|
34
|
+
doc_title = initial_response.title.to_s
|
|
35
|
+
user_agent = options[:user_agent]
|
|
36
|
+
referer = (options[:headers] || {})['Referer'] || (options[:headers] || {})['referer'] || target_url
|
|
37
|
+
|
|
38
|
+
# 2. Run virtual browser simulation inside QuickJS
|
|
39
|
+
driver_script = <<~JS
|
|
40
|
+
globalThis.__TARGET_URL__ = #{target_url.to_json};
|
|
41
|
+
globalThis.__DOCUMENT_TITLE__ = #{doc_title.to_json};
|
|
42
|
+
globalThis.__INITIAL_COOKIES__ = #{cookie_str.to_json};
|
|
43
|
+
globalThis.__REFERRER__ = #{referer.to_json};
|
|
44
|
+
#{user_agent ? "globalThis.__USER_AGENT__ = #{user_agent.to_json};" : ""}
|
|
45
|
+
|
|
46
|
+
#{context_js}
|
|
47
|
+
#{sensor_js}
|
|
48
|
+
|
|
49
|
+
if (typeof globalThis.__simulateHumanInteractions === "function") {
|
|
50
|
+
globalThis.__simulateHumanInteractions();
|
|
51
|
+
}
|
|
52
|
+
if (typeof globalThis.__drainEventLoop === "function") {
|
|
53
|
+
globalThis.__drainEventLoop(100);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
sensor_posts: globalThis.__sensor_posts
|
|
58
|
+
});
|
|
59
|
+
JS
|
|
60
|
+
|
|
61
|
+
result = JSRuntime.eval_json(driver_script)
|
|
62
|
+
sensor_posts = result && result['sensor_posts']
|
|
63
|
+
return nil if sensor_posts.nil? || sensor_posts.empty?
|
|
64
|
+
|
|
65
|
+
# 3. Post sensor data to Akamai endpoint
|
|
66
|
+
updated_cookies = cookies.dup
|
|
67
|
+
sensor_posts.each do |post|
|
|
68
|
+
post_body = post['body']
|
|
69
|
+
next if post_body.nil? || post_body.empty?
|
|
70
|
+
|
|
71
|
+
post_resp = HttpMimic.post(
|
|
72
|
+
sensor_script_url,
|
|
73
|
+
impersonate: impersonate,
|
|
74
|
+
cookies: updated_cookies,
|
|
75
|
+
headers: {
|
|
76
|
+
'Content-Type' => 'text/plain;charset=UTF-8',
|
|
77
|
+
'Referer' => target_url,
|
|
78
|
+
'Origin' => URI.parse(target_url).tap { |u| u.path = ''; u.query = nil }.to_s,
|
|
79
|
+
'Accept' => '*/*'
|
|
80
|
+
},
|
|
81
|
+
body: post_body,
|
|
82
|
+
auto_fallback: false,
|
|
83
|
+
solve_waf: false
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
post_resp.cookies.each { |k, v| updated_cookies[k] = v }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 4. Retry original target URL with the updated cookies
|
|
90
|
+
retry_headers = (options[:headers] || {}).merge('Referer' => target_url)
|
|
91
|
+
HttpMimic.get(
|
|
92
|
+
target_url,
|
|
93
|
+
options.merge(
|
|
94
|
+
cookies: updated_cookies,
|
|
95
|
+
headers: retry_headers,
|
|
96
|
+
auto_fallback: false,
|
|
97
|
+
solve_waf: false
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
if HttpMimic.configuration.debug
|
|
102
|
+
puts "[HttpMimic::Waf::AkamaiSolver] Error solving Akamai challenge: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def extract_sensor_script_url(target_url, html)
|
|
108
|
+
return nil if html.nil? || html.empty?
|
|
109
|
+
|
|
110
|
+
match = html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/i)
|
|
111
|
+
match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*akam[^"\x27]*)["\x27]/i)
|
|
112
|
+
match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_\-\/]+\?v=[a-zA-Z0-9_-]+)["\x27]/i)
|
|
113
|
+
|
|
114
|
+
return nil unless match
|
|
115
|
+
|
|
116
|
+
URI.join(target_url, match[1]).to_s
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HttpMimic
|
|
4
|
+
module Waf
|
|
5
|
+
class Detector
|
|
6
|
+
class << self
|
|
7
|
+
def detect(response)
|
|
8
|
+
return nil unless response
|
|
9
|
+
|
|
10
|
+
if akamai?(response)
|
|
11
|
+
:akamai
|
|
12
|
+
elsif cloudflare?(response)
|
|
13
|
+
:cloudflare
|
|
14
|
+
elsif datadome?(response)
|
|
15
|
+
:datadome
|
|
16
|
+
else
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def challenge_page?(response)
|
|
22
|
+
return false unless response
|
|
23
|
+
body = response.body.to_s
|
|
24
|
+
return true if body.include?('sec-if-cpt-container') || body.include?('sec-bc-button-parent')
|
|
25
|
+
return true if body.include?('challenges.cloudflare.com') || body.include?('cf-turnstile')
|
|
26
|
+
return true if body.include?('datadome.captcha') || body.include?('geo.captcha-delivery.com')
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def akamai?(response)
|
|
31
|
+
return true if response.headers['set-cookie']&.include?('_abck=')
|
|
32
|
+
return true if response.cookies.key?('_abck') || response.cookies.key?('bm_sz') || response.cookies.key?('ak_bmsc')
|
|
33
|
+
return true if response.headers['x-akamai-transformed'] || response.headers['x-reference-error']
|
|
34
|
+
|
|
35
|
+
body = response.body.to_s
|
|
36
|
+
return true if body.include?('Reference Error:') && body.include?('Akamai')
|
|
37
|
+
return true if body.include?('bmak') || body.include?('sensor_data')
|
|
38
|
+
return true if body.include?('sec-if-cpt-container') || body.include?('sec-bc-button-parent')
|
|
39
|
+
return true if body =~ /<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/
|
|
40
|
+
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def cloudflare?(response)
|
|
45
|
+
return true if response.headers['server']&.downcase&.include?('cloudflare')
|
|
46
|
+
return true if response.headers['cf-ray'] || response.cookies.key?('cf_clearance')
|
|
47
|
+
return true if response.body.to_s.include?('challenges.cloudflare.com')
|
|
48
|
+
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def datadome?(response)
|
|
53
|
+
return true if response.headers['x-datadome'] || response.cookies.key?('datadome')
|
|
54
|
+
return true if response.headers['server']&.downcase&.include?('datadome')
|
|
55
|
+
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'http_mimic/waf/detector'
|
|
4
|
+
require 'http_mimic/waf/akamai_solver'
|
|
5
|
+
|
|
6
|
+
module HttpMimic
|
|
7
|
+
module Waf
|
|
8
|
+
class << self
|
|
9
|
+
def solve(url, response, options = {})
|
|
10
|
+
waf_type = Detector.detect(response)
|
|
11
|
+
return nil unless waf_type
|
|
12
|
+
|
|
13
|
+
case waf_type
|
|
14
|
+
when :akamai
|
|
15
|
+
AkamaiSolver.solve(url, response, options)
|
|
16
|
+
else
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/http_mimic.rb
CHANGED
|
@@ -16,6 +16,9 @@ require 'http_mimic/command_builder'
|
|
|
16
16
|
require 'http_mimic/request'
|
|
17
17
|
require 'http_mimic/client'
|
|
18
18
|
require 'http_mimic/module_methods'
|
|
19
|
+
require 'http_mimic/js_runtime'
|
|
20
|
+
require 'http_mimic/waf'
|
|
21
|
+
require 'http_mimic/cookie_store'
|
|
19
22
|
|
|
20
23
|
module HttpMimic
|
|
21
24
|
extend ModuleMethods
|
|
@@ -43,6 +46,54 @@ module HttpMimic
|
|
|
43
46
|
Downloader.installed?(version: version)
|
|
44
47
|
end
|
|
45
48
|
|
|
49
|
+
# Manually download and install QuickJS binary driver
|
|
50
|
+
def download_qjs!(version: nil, force: false)
|
|
51
|
+
Downloader.download_qjs!(version: version, force: force)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check if QuickJS driver is installed locally
|
|
55
|
+
def qjs_installed?(version: nil)
|
|
56
|
+
Downloader.qjs_installed?(version: version)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Return local path to QuickJS binary
|
|
60
|
+
def qjs_path
|
|
61
|
+
Downloader.qjs_path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Evaluate JavaScript code using QuickJS
|
|
65
|
+
def eval_js(code, options = {})
|
|
66
|
+
JSRuntime.eval(code, timeout: options[:timeout], install_dir: options[:install_dir], auto_download: options[:auto_download])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Evaluate JavaScript and auto-parse JSON result
|
|
70
|
+
def eval_js_json(code, options = {})
|
|
71
|
+
JSRuntime.eval_json(code, timeout: options[:timeout], install_dir: options[:install_dir], auto_download: options[:auto_download])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Automatically detect WAF type from response
|
|
75
|
+
def detect_waf(response)
|
|
76
|
+
Waf::Detector.detect(response)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Attempt to solve WAF challenge for a blocked response
|
|
80
|
+
def solve_waf(url, response, options = {})
|
|
81
|
+
Waf.solve(url, response, options)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Persistent Host CookieStore helpers
|
|
85
|
+
def load_cookies(host, max_age: nil)
|
|
86
|
+
CookieStore.load(host, max_age: max_age)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def save_cookies(host, cookies, ttl: nil)
|
|
90
|
+
CookieStore.save(host, cookies, ttl: ttl || CookieStore::DEFAULT_TTL)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def clear_cookies!(host = nil)
|
|
94
|
+
CookieStore.clear(host)
|
|
95
|
+
end
|
|
96
|
+
|
|
46
97
|
def included(base)
|
|
47
98
|
base.extend(ModuleMethods)
|
|
48
99
|
base.send(:include, InstanceMethods)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: http_mimic
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- anxgang
|
|
@@ -65,15 +65,20 @@ files:
|
|
|
65
65
|
- lib/http_mimic/client.rb
|
|
66
66
|
- lib/http_mimic/command_builder.rb
|
|
67
67
|
- lib/http_mimic/configuration.rb
|
|
68
|
+
- lib/http_mimic/cookie_store.rb
|
|
68
69
|
- lib/http_mimic/cookies.rb
|
|
69
70
|
- lib/http_mimic/downloader.rb
|
|
70
71
|
- lib/http_mimic/exceptions.rb
|
|
71
72
|
- lib/http_mimic/headers.rb
|
|
73
|
+
- lib/http_mimic/js_runtime.rb
|
|
72
74
|
- lib/http_mimic/module_methods.rb
|
|
73
75
|
- lib/http_mimic/request.rb
|
|
74
76
|
- lib/http_mimic/response.rb
|
|
75
77
|
- lib/http_mimic/response_parser.rb
|
|
76
78
|
- lib/http_mimic/version.rb
|
|
79
|
+
- lib/http_mimic/waf.rb
|
|
80
|
+
- lib/http_mimic/waf/akamai_solver.rb
|
|
81
|
+
- lib/http_mimic/waf/detector.rb
|
|
77
82
|
homepage: https://github.com/anxgang/http_mimic
|
|
78
83
|
licenses:
|
|
79
84
|
- MIT
|