http_mimic 0.5.1 โ 0.5.3
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 +27 -0
- data/README.md +76 -33
- data/lib/http_mimic/command_builder.rb +4 -0
- data/lib/http_mimic/configuration.rb +18 -0
- data/lib/http_mimic/module_methods.rb +28 -0
- data/lib/http_mimic/obscura.rb +21 -2
- data/lib/http_mimic/proxy_pool.rb +224 -0
- data/lib/http_mimic/request.rb +116 -29
- data/lib/http_mimic/version.rb +1 -1
- data/lib/http_mimic/waf/akamai_solver.rb +97 -39
- data/lib/http_mimic.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b0b4192818428818cca7cde26b4ce90d6fa847db6e80e2b63c163dd8171bdd9
|
|
4
|
+
data.tar.gz: de6a71df66c710a30e7cb8fcbe6976653ec6242a6d29fd6959016513da4513be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b4ba6f1355da36e9f1064e62b1b988200f483a58b49524e3dd5350b0c99660d0b93ab650fc9ea8b3b7aa2204f41b7e2ca51c6b439128c267d7660be77b2b644
|
|
7
|
+
data.tar.gz: dd25b3fbf25dca0d935b910cb2b0981ab77d9dbfe45cfa5b522a99bbca7081016e6323af3fc59c389ef925c6df0b4d0bbf7321c7a93df5154d0d41e328962d76
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.5.3] - 2026-09-05
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Automatic Free Proxy Pool (`auto_proxy`)**:
|
|
12
|
+
- Built-in `HttpMimic::ProxyPool` automatically gathers and caches hundreds of public HTTP proxies from maintained sources (`monosans`, `proxyscrape`, `TheSpeedX`).
|
|
13
|
+
- Disabled by default (`auto_proxy: false`) with per-request and global configuration opt-in.
|
|
14
|
+
- Transparent proxy failure retry: automatically detects dead proxies (exit codes 5, 7, 28, 35, 56 or connection errors), marks them dead in the pool, and transparently retries with fresh proxies up to `proxy_retries` (default: 3).
|
|
15
|
+
- Explicit manual `:proxy` option always takes precedence over `auto_proxy`.
|
|
16
|
+
- Seamless integration with Obscura and WAF solver handshakes.
|
|
17
|
+
- Added DSL helpers (`HttpMimic.auto_proxy`, `HttpMimic.proxy_pool`, `HttpMimic.refresh_proxies!`).
|
|
18
|
+
- **Streamlined Documentation**:
|
|
19
|
+
- Refined README with concise, high-level highlights focusing on core anti-detect capabilities and developer experience.
|
|
20
|
+
|
|
21
|
+
## [0.5.2] - 2026-09-05
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **Anti-Poisoning Failure Protection for CookieStore (`persist_on_failure`, `clear_on_failure`)**:
|
|
25
|
+
- `persist_on_failure` (default: `false`): Ensures failed/blocked requests (403, 401, retry statuses, or WAF challenge pages) do not save invalid or bot-flagged cookies to disk.
|
|
26
|
+
- `clear_on_failure` (default: `true`): Automatically purges stored host cookies when a request fails verification or is blocked by WAF, preventing poisoned sessions from breaking subsequent requests.
|
|
27
|
+
- Added class-level DSL and configuration support (`HttpMimic.persist_on_failure`, `HttpMimic.clear_on_failure`).
|
|
28
|
+
- **Two-Phase Telemetry Handshake in `AkamaiSolver`**:
|
|
29
|
+
- Automatic multi-round telemetry loop sending second-stage interaction sensor posts after initial cookie acquisition to flip `_abck` tokens from `~-1~` to verified `~0~`.
|
|
30
|
+
- **Intelligent Profile Fallback & WAF Protection**:
|
|
31
|
+
- `:auto` mode now rotates between distinct browser engines (`:impersonate` [Chrome], `:android`, `:ios`, `:firefox`) and skips plain `:curl` fallback on WAF targets to avoid IP flagging.
|
|
32
|
+
- Added `:firefox` and `:safari` profile support in `CommandBuilder`.
|
|
33
|
+
- Added Best Response Preservation: prevents successful/200 progress from being wiped out by later failed fallback attempts.
|
|
34
|
+
|
|
8
35
|
## [0.5.1] - 2026-08-30
|
|
9
36
|
|
|
10
37
|
### Added
|
data/README.md
CHANGED
|
@@ -1,41 +1,27 @@
|
|
|
1
|
-
# HttpMimic
|
|
1
|
+
# HttpMimic ๐ญ
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
> **Next-Generation Anti-Detect HTTP Client for Ruby**
|
|
4
|
+
> An elegant, high-performance HTTP client combining authentic browser TLS/JA4 fingerprint impersonation, built-in anti-bot challenge solving, lightweight headless SPA rendering, and an automatic free proxy pool into an intuitive HTTParty-style API.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
`http_mimic` provides a complete, modern toolchain for accessing protected endpoints and web scraping:
|
|
7
|
+
- **๐ญ Authentic Browser Fingerprints**: Simulates authentic Chrome, Safari (macOS & iOS), Android, and Firefox TLS 1.3 / HTTP/2 handshakes and JA3/JA4 signatures.
|
|
8
|
+
- **๐ก๏ธ Built-in Anti-Bot Defense**: Automatically detects and solves advanced WAF challenges at microsecond speeds without the overhead of heavy browsers.
|
|
9
|
+
- **โก Dual-Tier Engine with Headless SPA Support**: Fast HTTP-first pipeline with seamless escalation to an embedded, lightweight (<100MB) Rust+V8 headless engine (`auto_render_spa`) when dynamic DOM rendering is needed.
|
|
10
|
+
- **๐ Automatic Free Proxy Pool (`auto_proxy`)**: Built-in, zero-dependency proxy pool with health tracking, auto-rotation, and transparent failure retry.
|
|
11
|
+
- **๐ช Resilient Session Management**: Host-based persistent cookie caching with automatic anti-poisoning protection to prevent tainted sessions.
|
|
12
|
+
- **๐ฆ Zero-Setup Driver Management**: Automatically provisions and manages required native binaries across macOS, Linux, and Windows on demand.
|
|
13
|
+
- **๐ Intuitive Ruby DSL**: Clean, idiomatic syntax supporting class mixins (`include HttpMimic`), direct module calls (`HttpMimic.get`), and reusable client instances.
|
|
7
14
|
|
|
8
15
|
---
|
|
9
16
|
|
|
10
|
-
## ๐
|
|
11
|
-
|
|
12
|
-
- **
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **
|
|
18
|
-
- Chrome support: `chrome131` (default), `chrome124`, `chrome120`, `chrome133a`, `chrome136`, `chrome142`, `chrome99-110`.
|
|
19
|
-
- Firefox support: `firefox135`, `firefox133`, `firefox144`, `firefox117`, `firefox109`, `firefox102`, `firefox98`.
|
|
20
|
-
- Safari support: `safari180`, `safari170`, `safari155`, `safari153`.
|
|
21
|
-
- Edge & Tor support: `edge101`, `edge99`, `tor145`.
|
|
22
|
-
- **HTTParty-Style API**:
|
|
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`, `persist_cookies`).
|
|
25
|
-
- Reusable instance client: `HttpMimic::Client.new(...)`.
|
|
26
|
-
- **Zero Shell Injection Risk**:
|
|
27
|
-
- Executes commands with array arguments via `Open3.capture3(*cmd_array)`.
|
|
28
|
-
- Uses stdin streaming (`-d @-`) to safely handle large payloads without hitting OS command-line limits.
|
|
29
|
-
- **Smart Response Parsing**:
|
|
30
|
-
- HTTP status helpers: `response.code`, `response.success?`, `response.redirect?`, `response.client_error?`, `response.server_error?`.
|
|
31
|
-
- Case-insensitive header access: `response.headers['Content-Type']`.
|
|
32
|
-
- Automatic `Set-Cookie` header parsing: `response.cookies['session_id']`.
|
|
33
|
-
- Auto-parsed JSON with object delegation: `response['key']`, `response.parsed_response`.
|
|
34
|
-
- Complete 3xx redirect history tracking: `response.history`.
|
|
35
|
-
- **Comprehensive Request Options**:
|
|
36
|
-
- Supports `query`, `headers`, `json`, `body` (form data), `cookies`, `timeout`, `connect_timeout`, `proxy`, `basic_auth`, `digest_auth`, `bearer_token`, `insecure`, custom `curl_options`, and more.
|
|
37
|
-
- **Graceful Fallback**:
|
|
38
|
-
- If a specific binary is unavailable and auto-download is disabled, automatically falls back to system standard `curl`.
|
|
17
|
+
## ๐ Highlights
|
|
18
|
+
|
|
19
|
+
- **Authentic TLS & HTTP/2 Impersonation**: Native support for `chrome131` (default), `chrome120-142`, `safari180` (macOS/iOS), `firefox135`, `edge101`, and `tor145`.
|
|
20
|
+
- **Intelligent Challenge Resolution**: Automatic WAF challenge mitigation and smart profile fallback to keep requests succeeding.
|
|
21
|
+
- **Client-Side SPA Hydration**: High-speed HTTP execution by default, with optional V8 DOM hydration for complex JavaScript apps (`auto_render_spa: true`).
|
|
22
|
+
- **Free Proxy Pool & Auto-Rotation**: Opt-in proxy management (`auto_proxy: true`) with automatic dead-proxy detection and retry.
|
|
23
|
+
- **Safe & Reliable**: Zero shell injection risk via `Open3.capture3(*cmd_array)`, safe stdin streaming for large payloads, and smart response parsing.
|
|
24
|
+
- **Flexible Integration**: Works seamlessly as a one-off client, a reusable instance, or a class-level DSL.
|
|
39
25
|
|
|
40
26
|
---
|
|
41
27
|
|
|
@@ -198,6 +184,8 @@ response = HttpMimic.get('https://example.com/items', persist_cookies: true)
|
|
|
198
184
|
class Scraper
|
|
199
185
|
include HttpMimic
|
|
200
186
|
persist_cookies true
|
|
187
|
+
# persist_on_failure false # (default: do not save cookies on 403 / verification failure)
|
|
188
|
+
# clear_on_failure true # (default: auto-clear cached cookies if verification fails)
|
|
201
189
|
end
|
|
202
190
|
|
|
203
191
|
# 3. Manual CookieStore helpers
|
|
@@ -206,6 +194,9 @@ HttpMimic.save_cookies('example.com', { session: '123' }) # Save cookies with de
|
|
|
206
194
|
HttpMimic.clear_cookies!('example.com') # Clear host or all cookies
|
|
207
195
|
```
|
|
208
196
|
|
|
197
|
+
> **๐ก๏ธ Anti-Poisoning Failure Protection:**
|
|
198
|
+
> By default, `persist_on_failure` is set to `false`, meaning cookies returned with HTTP error codes (e.g. 403, 401) or unverified WAF challenge pages are **never** persisted to disk. Furthermore, `clear_on_failure: true` automatically wipes tainted host cookies upon verification failure, preventing blocked sessions from poisoning subsequent requests.
|
|
199
|
+
|
|
209
200
|
---
|
|
210
201
|
|
|
211
202
|
## ๐งญ Target-Specific Navigation Headers & Client Hints
|
|
@@ -247,6 +238,8 @@ HttpMimic.configure do |config|
|
|
|
247
238
|
# Host CookieStore Persistence (disabled by default)
|
|
248
239
|
config.persist_cookies = false # Auto-persist cookies per host
|
|
249
240
|
config.cookie_store_dir = File.expand_path('~/.http_mimic/cookies') # Directory for cookie store
|
|
241
|
+
config.persist_on_failure = false # Do not save cookies if request/verification fails
|
|
242
|
+
config.clear_on_failure = true # Purge cached host cookies on 403 / verification failure
|
|
250
243
|
|
|
251
244
|
# WAF challenge solving (enabled by default)
|
|
252
245
|
config.auto_solve_waf = true # Automatically solve detected WAF JS challenges
|
|
@@ -278,11 +271,14 @@ end
|
|
|
278
271
|
| `:body` | Hash / String | Form payload (Hash) or raw request body string |
|
|
279
272
|
| `:cookies` | Hash / String | Request cookies |
|
|
280
273
|
| `:persist_cookies` | Boolean | Automatically load and save cookies for the host across requests |
|
|
274
|
+
| `:persist_on_failure` | Boolean | Save cookies even if request or verification fails (default `false`) |
|
|
275
|
+
| `:clear_on_failure` | Boolean | Automatically clear stored cookies on verification failure / 403 / WAF block (default `true`) |
|
|
281
276
|
| `:cookie_jar` | String | Path to save cookies (`-c`) |
|
|
282
277
|
| `:cookie_file` | String | Path to read cookies (`-b`) |
|
|
283
278
|
| `:timeout` | Integer / Float | Maximum execution timeout in seconds (`--max-time`) |
|
|
284
279
|
| `:connect_timeout` | Integer / Float | Connection timeout in seconds (`--connect-timeout`) |
|
|
285
280
|
| `:proxy` | String | Proxy address (e.g., `'http://127.0.0.1:8888'`) |
|
|
281
|
+
| `:auto_proxy` | Boolean | Automatically fetch and rotate through a pool of free HTTP proxies (default: `false`) |
|
|
286
282
|
| `:basic_auth` | Hash | `{ username: 'admin', password: 'secret' }` |
|
|
287
283
|
| `:bearer_token` | String | Appends `Authorization: Bearer <token>` header |
|
|
288
284
|
| `:insecure` | Boolean | Disable SSL certificate verification (`-k`) |
|
|
@@ -331,6 +327,53 @@ response = HttpMimic.spa('https://example.com/spa')
|
|
|
331
327
|
|
|
332
328
|
---
|
|
333
329
|
|
|
330
|
+
## ๐ Automatic Free Proxy Pool (`auto_proxy`)
|
|
331
|
+
|
|
332
|
+
`http_mimic` provides a built-in, zero-dependency Free Proxy Pool manager. When enabled, `HttpMimic` automatically fetches and pools hundreds of free HTTP proxies from maintained public sources (monosans, ProxyScrape, TheSpeedX), automatically marks dead proxies, and transparently retries requests on network or proxy failure.
|
|
333
|
+
|
|
334
|
+
By default, `auto_proxy` is **`false`** so users can choose when to opt-in.
|
|
335
|
+
|
|
336
|
+
### 1. Per-Request Opt-in
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
# Automatically obtain a proxy from the pool and execute request
|
|
340
|
+
response = HttpMimic.get('https://httpbin.org/ip', auto_proxy: true)
|
|
341
|
+
|
|
342
|
+
# Explicit proxy always takes precedence over auto_proxy:
|
|
343
|
+
response = HttpMimic.get('https://httpbin.org/ip', auto_proxy: true, proxy: 'http://my-dedicated-proxy:8080')
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### 2. Global Configuration
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
HttpMimic.configure do |config|
|
|
350
|
+
config.auto_proxy = true # Enable free proxy pool by default
|
|
351
|
+
config.proxy_retries = 3 # Maximum proxy retry attempts on connection failure
|
|
352
|
+
config.proxy_pool_ttl = 1800 # Cache duration for fetched proxy list (seconds)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Or via class-level DSL:
|
|
356
|
+
HttpMimic.auto_proxy = true
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### 3. Proxy Pool Management
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
# Check available pool size
|
|
363
|
+
HttpMimic.proxy_pool.size
|
|
364
|
+
|
|
365
|
+
# Sample a random proxy from the pool
|
|
366
|
+
HttpMimic.proxy_pool.sample # => "http://185.200.188.234:10001"
|
|
367
|
+
|
|
368
|
+
# Manually refresh the proxy pool
|
|
369
|
+
HttpMimic.refresh_proxies!
|
|
370
|
+
|
|
371
|
+
# Load custom proxies into the pool
|
|
372
|
+
HttpMimic.proxy_pool.load(['http://1.2.3.4:8080', 'http://5.6.7.8:3128'])
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
334
377
|
## ๐ Response Object
|
|
335
378
|
|
|
336
379
|
The `Response` object wraps the HTTP response with convenient methods:
|
|
@@ -248,6 +248,10 @@ module HttpMimic
|
|
|
248
248
|
(options[:ios_impersonate] || 'safari260_ios').to_s.downcase
|
|
249
249
|
when :mobile
|
|
250
250
|
(options[:mobile_impersonate] || 'chrome131_android').to_s.downcase
|
|
251
|
+
when :firefox
|
|
252
|
+
(options[:firefox_impersonate] || 'firefox135').to_s.downcase
|
|
253
|
+
when :safari
|
|
254
|
+
(options[:safari_impersonate] || 'safari180').to_s.downcase
|
|
251
255
|
else
|
|
252
256
|
(options[:impersonate] || config.default_impersonate).to_s.downcase
|
|
253
257
|
end
|
|
@@ -42,10 +42,19 @@ module HttpMimic
|
|
|
42
42
|
# Shared Host-based CookieStore settings
|
|
43
43
|
attr_accessor :persist_cookies
|
|
44
44
|
attr_accessor :cookie_store_dir
|
|
45
|
+
attr_accessor :persist_on_failure
|
|
46
|
+
attr_accessor :clear_on_failure
|
|
45
47
|
|
|
46
48
|
# Modern Navigation Headers simulation
|
|
47
49
|
attr_accessor :navigation_headers
|
|
48
50
|
|
|
51
|
+
# Automatic Free Proxy Pool settings
|
|
52
|
+
attr_accessor :auto_proxy
|
|
53
|
+
attr_accessor :proxy_sources
|
|
54
|
+
attr_accessor :proxy_retries
|
|
55
|
+
attr_accessor :proxy_pool_ttl
|
|
56
|
+
attr_accessor :proxy_timeout
|
|
57
|
+
|
|
49
58
|
def initialize
|
|
50
59
|
@default_impersonate = 'chrome131'
|
|
51
60
|
@default_mobile_impersonate = 'safari180_ios'
|
|
@@ -89,9 +98,18 @@ module HttpMimic
|
|
|
89
98
|
# Shared Host CookieStore defaults
|
|
90
99
|
@persist_cookies = false
|
|
91
100
|
@cookie_store_dir = File.expand_path('~/.http_mimic/cookies')
|
|
101
|
+
@persist_on_failure = false
|
|
102
|
+
@clear_on_failure = true
|
|
92
103
|
|
|
93
104
|
# Navigation Headers defaults
|
|
94
105
|
@navigation_headers = true
|
|
106
|
+
|
|
107
|
+
# Automatic Free Proxy Pool defaults
|
|
108
|
+
@auto_proxy = false
|
|
109
|
+
@proxy_sources = nil
|
|
110
|
+
@proxy_retries = 3
|
|
111
|
+
@proxy_pool_ttl = 1800
|
|
112
|
+
@proxy_timeout = 5
|
|
95
113
|
end
|
|
96
114
|
end
|
|
97
115
|
end
|
|
@@ -35,6 +35,24 @@ module HttpMimic
|
|
|
35
35
|
default_options[:proxy] = proxy_str
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def auto_proxy(enabled = nil)
|
|
39
|
+
return default_options[:auto_proxy] if enabled.nil?
|
|
40
|
+
default_options[:auto_proxy] = enabled
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def proxy_pool
|
|
44
|
+
ProxyPool.instance
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def proxy_sources(sources = nil)
|
|
48
|
+
return configuration.proxy_sources if sources.nil?
|
|
49
|
+
configuration.proxy_sources = sources
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def refresh_proxies!(force: true)
|
|
53
|
+
ProxyPool.refresh!(force: force)
|
|
54
|
+
end
|
|
55
|
+
|
|
38
56
|
def cookies(c = nil)
|
|
39
57
|
return default_options[:cookies] if c.nil?
|
|
40
58
|
default_options[:cookies] ||= {}
|
|
@@ -46,6 +64,16 @@ module HttpMimic
|
|
|
46
64
|
default_options[:persist_cookies] = enabled
|
|
47
65
|
end
|
|
48
66
|
|
|
67
|
+
def persist_on_failure(enabled = nil)
|
|
68
|
+
return default_options[:persist_on_failure] if enabled.nil?
|
|
69
|
+
default_options[:persist_on_failure] = enabled
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def clear_on_failure(enabled = nil)
|
|
73
|
+
return default_options[:clear_on_failure] if enabled.nil?
|
|
74
|
+
default_options[:clear_on_failure] = enabled
|
|
75
|
+
end
|
|
76
|
+
|
|
49
77
|
def auto_render_spa(enabled = nil)
|
|
50
78
|
return default_options[:auto_render_spa] if enabled.nil?
|
|
51
79
|
default_options[:auto_render_spa] = enabled
|
data/lib/http_mimic/obscura.rb
CHANGED
|
@@ -82,13 +82,32 @@ module HttpMimic
|
|
|
82
82
|
|
|
83
83
|
code = (status && status.success?) ? 200 : (status ? status.exitstatus : 500)
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
# Detect if the rendered output contains an explicit HTTP error block page
|
|
86
|
+
if code == 200 && stdout
|
|
87
|
+
if stdout.include?('HTTP 403 - Forbidden') ||
|
|
88
|
+
(stdout.include?('Reference Error:') && stdout.include?('Akamai')) ||
|
|
89
|
+
stdout.include?('"page_name": "403 ERROR"')
|
|
90
|
+
code = 403
|
|
91
|
+
elsif stdout.include?('404 Not Found') && stdout.include?('<title>404')
|
|
92
|
+
code = 404
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
status_msg = if code == 200
|
|
97
|
+
'OK (Obscura SPA Rendered)'
|
|
98
|
+
elsif code == 403
|
|
99
|
+
'Forbidden (WAF Blocked)'
|
|
100
|
+
else
|
|
101
|
+
'Error'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raw_headers = "HTTP/2 #{code} #{status_msg}\r\ncontent-type: text/html; charset=utf-8\r\nx-rendered-by: obscura\r\n\r\n"
|
|
86
105
|
headers = Headers.new({ 'content-type' => 'text/html; charset=utf-8', 'x-rendered-by' => 'obscura' })
|
|
87
106
|
|
|
88
107
|
Response.new(
|
|
89
108
|
code: code,
|
|
90
109
|
http_version: 'HTTP/2',
|
|
91
|
-
status_message:
|
|
110
|
+
status_message: status_msg,
|
|
92
111
|
headers: headers,
|
|
93
112
|
cookies: Cookies.new,
|
|
94
113
|
body: stdout,
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
module HttpMimic
|
|
8
|
+
class ProxyPool
|
|
9
|
+
DEFAULT_SOURCES = [
|
|
10
|
+
'https://raw.githubusercontent.com/monosans/proxy-list/main/proxies/http.txt',
|
|
11
|
+
'https://api.proxyscrape.com/v2/?request=displayproxies&protocol=http&timeout=5000&country=all&ssl=all&anonymity=all',
|
|
12
|
+
'https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/http.txt'
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def instance
|
|
17
|
+
@instance ||= new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reset_instance!
|
|
21
|
+
@instance = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get
|
|
25
|
+
instance.get
|
|
26
|
+
end
|
|
27
|
+
alias sample get
|
|
28
|
+
alias next_proxy get
|
|
29
|
+
|
|
30
|
+
def mark_dead(proxy)
|
|
31
|
+
instance.mark_dead(proxy)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mark_alive(proxy)
|
|
35
|
+
instance.mark_alive(proxy)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def refresh!(force: true)
|
|
39
|
+
instance.refresh!(force: force)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def load(proxies)
|
|
43
|
+
instance.load(proxies)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def all
|
|
47
|
+
instance.all
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def available
|
|
51
|
+
instance.available
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def dead_proxies
|
|
55
|
+
instance.dead_proxies
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def size
|
|
59
|
+
instance.size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear!
|
|
63
|
+
instance.clear!
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
attr_reader :sources, :ttl, :timeout
|
|
68
|
+
attr_accessor :proxies
|
|
69
|
+
|
|
70
|
+
def initialize(options = {})
|
|
71
|
+
config = HttpMimic.configuration rescue nil
|
|
72
|
+
@sources = options[:sources] || config&.proxy_sources || DEFAULT_SOURCES.dup
|
|
73
|
+
@ttl = options[:ttl] || config&.proxy_pool_ttl || 1800
|
|
74
|
+
@timeout = options[:timeout] || config&.proxy_timeout || 5
|
|
75
|
+
@proxies = []
|
|
76
|
+
@dead_proxies = Set.new
|
|
77
|
+
@last_fetched_at = nil
|
|
78
|
+
@mutex = Mutex.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Retrieve an available proxy from the pool
|
|
82
|
+
#
|
|
83
|
+
# @return [String, nil] Proxy URL (e.g. 'http://1.2.3.4:8080') or nil if none available
|
|
84
|
+
def get
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
refresh_unlocked(force: false) if should_refresh_unlocked?
|
|
87
|
+
avail = @proxies - @dead_proxies.to_a
|
|
88
|
+
if avail.empty? && !@proxies.empty?
|
|
89
|
+
# If all current proxies are exhausted/dead, clear dead set and try one more refresh
|
|
90
|
+
@dead_proxies.clear
|
|
91
|
+
refresh_unlocked(force: true)
|
|
92
|
+
avail = @proxies - @dead_proxies.to_a
|
|
93
|
+
end
|
|
94
|
+
avail.sample
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
alias sample get
|
|
98
|
+
alias next_proxy get
|
|
99
|
+
|
|
100
|
+
# Mark a proxy as dead/unusable
|
|
101
|
+
#
|
|
102
|
+
# @param proxy [String]
|
|
103
|
+
def mark_dead(proxy)
|
|
104
|
+
return unless proxy
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
@dead_proxies.add(normalize_proxy(proxy))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Mark a proxy as active/usable
|
|
111
|
+
#
|
|
112
|
+
# @param proxy [String]
|
|
113
|
+
def mark_alive(proxy)
|
|
114
|
+
return unless proxy
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
@dead_proxies.delete(normalize_proxy(proxy))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Explicitly refresh proxy pool from configured sources
|
|
121
|
+
def refresh!(force: true)
|
|
122
|
+
@mutex.synchronize do
|
|
123
|
+
refresh_unlocked(force: force)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Manually load a custom array of proxies
|
|
128
|
+
#
|
|
129
|
+
# @param proxy_list [Array<String>]
|
|
130
|
+
def load(proxy_list)
|
|
131
|
+
@mutex.synchronize do
|
|
132
|
+
@proxies = Array(proxy_list).map { |p| normalize_proxy(p) }.compact.uniq
|
|
133
|
+
@dead_proxies.clear
|
|
134
|
+
@last_fetched_at = Time.now
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns all loaded proxies
|
|
139
|
+
def all
|
|
140
|
+
@mutex.synchronize { @proxies.dup }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returns all non-dead available proxies
|
|
144
|
+
def available
|
|
145
|
+
@mutex.synchronize { (@proxies - @dead_proxies.to_a).dup }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Returns dead proxies
|
|
149
|
+
def dead_proxies
|
|
150
|
+
@mutex.synchronize { @dead_proxies.to_a }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Number of available proxies
|
|
154
|
+
def size
|
|
155
|
+
@mutex.synchronize { (@proxies - @dead_proxies.to_a).size }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Reset proxy pool
|
|
159
|
+
def clear!
|
|
160
|
+
@mutex.synchronize do
|
|
161
|
+
@proxies.clear
|
|
162
|
+
@dead_proxies.clear
|
|
163
|
+
@last_fetched_at = nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def should_refresh_unlocked?
|
|
170
|
+
@proxies.empty? || @last_fetched_at.nil? || (Time.now - @last_fetched_at > @ttl)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def refresh_unlocked(force: false)
|
|
174
|
+
return if !force && !should_refresh_unlocked?
|
|
175
|
+
|
|
176
|
+
new_proxies = fetch_from_sources
|
|
177
|
+
if !new_proxies.empty?
|
|
178
|
+
@proxies = new_proxies
|
|
179
|
+
@last_fetched_at = Time.now
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def fetch_from_sources
|
|
184
|
+
collected = []
|
|
185
|
+
sources_to_try = Array(@sources).empty? ? DEFAULT_SOURCES : @sources
|
|
186
|
+
|
|
187
|
+
sources_to_try.each do |source_url|
|
|
188
|
+
begin
|
|
189
|
+
uri = URI.parse(source_url)
|
|
190
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
191
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
192
|
+
http.open_timeout = @timeout
|
|
193
|
+
http.read_timeout = @timeout
|
|
194
|
+
|
|
195
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
196
|
+
req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
|
197
|
+
|
|
198
|
+
res = http.request(req)
|
|
199
|
+
if res.is_a?(Net::HTTPSuccess) && res.body
|
|
200
|
+
# Extract IP:PORT matches
|
|
201
|
+
ips = res.body.scan(/\b(?:\d{1,3}\.){3}\d{1,3}:\d{2,5}\b/)
|
|
202
|
+
if !ips.empty?
|
|
203
|
+
collected.concat(ips.take(150))
|
|
204
|
+
# Stop early if we have collected enough proxies
|
|
205
|
+
break if collected.size >= 50
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
if HttpMimic.configuration.debug
|
|
210
|
+
puts "[HttpMimic::ProxyPool] Failed to fetch proxy list from #{source_url}: #{e.message}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
collected.uniq.map { |p| normalize_proxy(p) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def normalize_proxy(proxy)
|
|
219
|
+
p = proxy.to_s.strip
|
|
220
|
+
return nil if p.empty?
|
|
221
|
+
p.start_with?('http://', 'https://', 'socks5://', 'socks4://') ? p : "http://#{p}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
data/lib/http_mimic/request.rb
CHANGED
|
@@ -16,6 +16,16 @@ module HttpMimic
|
|
|
16
16
|
def perform
|
|
17
17
|
mode = (options[:mode] || config.mode || :auto).to_sym
|
|
18
18
|
|
|
19
|
+
# Automatic Free Proxy Pool integration
|
|
20
|
+
should_auto_proxy = options.fetch(:auto_proxy, config.auto_proxy)
|
|
21
|
+
if should_auto_proxy && !options[:proxy]
|
|
22
|
+
selected_proxy = ProxyPool.get
|
|
23
|
+
if selected_proxy
|
|
24
|
+
options[:proxy] = selected_proxy
|
|
25
|
+
log_debug("[HttpMimic::ProxyPool] Assigned proxy from pool: #{selected_proxy}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
# Delegate directly to Obscura headless SPA renderer if requested
|
|
20
30
|
if options[:render] == :spa || options[:render] == :obscura || mode == :spa || mode == :obscura
|
|
21
31
|
return Obscura.render(url, options)
|
|
@@ -40,6 +50,8 @@ module HttpMimic
|
|
|
40
50
|
attempts = []
|
|
41
51
|
profiles_to_try = determine_profiles(mode, auto_fallback)
|
|
42
52
|
waf_solve_attempted = false
|
|
53
|
+
best_response = nil
|
|
54
|
+
proxy_retries_left = should_auto_proxy ? (options[:proxy_retries] || config.proxy_retries || 3) : 0
|
|
43
55
|
|
|
44
56
|
response = nil
|
|
45
57
|
final_status = nil
|
|
@@ -47,6 +59,12 @@ module HttpMimic
|
|
|
47
59
|
final_command = nil
|
|
48
60
|
|
|
49
61
|
profiles_to_try.each_with_index do |profile, index|
|
|
62
|
+
# Never fall back to plain curl if a WAF challenge has already been detected/attempted
|
|
63
|
+
if profile == :curl && waf_solve_attempted
|
|
64
|
+
log_debug("[HttpMimic] Skipping plain :curl fallback for protected WAF target.")
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
50
68
|
current_opts = options.merge(profile: profile)
|
|
51
69
|
|
|
52
70
|
builder = CommandBuilder.new(method, url, current_opts, config)
|
|
@@ -61,6 +79,25 @@ module HttpMimic
|
|
|
61
79
|
log_debug("Curl exit status: #{status.exitstatus}")
|
|
62
80
|
log_debug("Curl stderr: #{stderr}") unless stderr.empty?
|
|
63
81
|
|
|
82
|
+
# Handle proxy failure retry when auto_proxy is enabled
|
|
83
|
+
if should_auto_proxy && current_opts[:proxy] && (status.exitstatus != 0)
|
|
84
|
+
is_proxy_err = [5, 7, 28, 35, 56].include?(status.exitstatus) ||
|
|
85
|
+
stderr.include?('Failed to connect to') ||
|
|
86
|
+
stderr.include?('tunneling failed') ||
|
|
87
|
+
stderr.downcase.include?('proxy')
|
|
88
|
+
|
|
89
|
+
if is_proxy_err
|
|
90
|
+
ProxyPool.mark_dead(current_opts[:proxy])
|
|
91
|
+
if proxy_retries_left > 0
|
|
92
|
+
proxy_retries_left -= 1
|
|
93
|
+
new_proxy = ProxyPool.get
|
|
94
|
+
log_debug("[HttpMimic::ProxyPool] Proxy #{current_opts[:proxy]} failed (exit #{status.exitstatus}). Retrying with new proxy #{new_proxy} (#{proxy_retries_left} retries left)...")
|
|
95
|
+
options[:proxy] = new_proxy
|
|
96
|
+
redo
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
64
101
|
response = ResponseParser.new(
|
|
65
102
|
stdout,
|
|
66
103
|
exit_status: status,
|
|
@@ -69,6 +106,10 @@ module HttpMimic
|
|
|
69
106
|
request_url: final_url
|
|
70
107
|
).parse
|
|
71
108
|
|
|
109
|
+
if should_auto_proxy && current_opts[:proxy] && response.success?
|
|
110
|
+
ProxyPool.mark_alive(current_opts[:proxy])
|
|
111
|
+
end
|
|
112
|
+
|
|
72
113
|
response.mode_used = profile
|
|
73
114
|
response.fallback_triggered = (index > 0)
|
|
74
115
|
|
|
@@ -86,6 +127,17 @@ module HttpMimic
|
|
|
86
127
|
final_stderr = stderr
|
|
87
128
|
final_command = full_command
|
|
88
129
|
|
|
130
|
+
# Track best response so a 200 (or challenge page) isn't wiped out by a failed fallback
|
|
131
|
+
if response
|
|
132
|
+
if best_response.nil?
|
|
133
|
+
best_response = response
|
|
134
|
+
elsif response.success? && !best_response.success?
|
|
135
|
+
best_response = response
|
|
136
|
+
elsif response.code == 200 && best_response.code != 200
|
|
137
|
+
best_response = response
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
89
141
|
# Forward any received cookies to subsequent attempts
|
|
90
142
|
if response.cookies && !response.cookies.empty?
|
|
91
143
|
existing_cookies = options[:cookies] ? (options[:cookies].is_a?(Hash) ? options[:cookies] : options[:cookies].to_h) : {}
|
|
@@ -104,10 +156,13 @@ module HttpMimic
|
|
|
104
156
|
solved_resp = Waf.solve(url, response, current_opts)
|
|
105
157
|
if solved_resp
|
|
106
158
|
response = solved_resp
|
|
107
|
-
is_blocked = (response.code != 0 && retry_statuses.include?(response.code))
|
|
159
|
+
is_blocked = (response.code != 0 && retry_statuses.include?(response.code)) || Waf::Detector.challenge_page?(response)
|
|
108
160
|
if response.cookies && !response.cookies.empty?
|
|
109
161
|
options[:cookies] = (options[:cookies] || {}).merge(response.cookies.to_h)
|
|
110
162
|
end
|
|
163
|
+
if response.success? || (response.code == 200 && best_response&.code != 200)
|
|
164
|
+
best_response = response
|
|
165
|
+
end
|
|
111
166
|
end
|
|
112
167
|
end
|
|
113
168
|
end
|
|
@@ -119,33 +174,61 @@ module HttpMimic
|
|
|
119
174
|
log_debug("[HttpMimic] Attempt #{index + 1} with #{profile} resulted in status #{response.code}. Triggering smart fallback to next profile...")
|
|
120
175
|
end
|
|
121
176
|
|
|
122
|
-
#
|
|
177
|
+
# Preserve best response if the last attempt resulted in a regression (e.g. 403 / error after getting 200)
|
|
178
|
+
if best_response && (response.nil? || response.error? || (final_status && final_status.exitstatus != 0))
|
|
179
|
+
if best_response.success? || (best_response.code == 200 && response&.code != 200)
|
|
180
|
+
response = best_response
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Automatic SPA Detection & Obscura rendering fallback for SPA shells & Behavioral Challenges
|
|
123
185
|
auto_render_spa = options.fetch(:auto_render_spa, config.auto_render_spa)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
186
|
+
auto_solve_waf = options.fetch(:solve_waf, config.auto_solve_waf)
|
|
187
|
+
should_render_spa = auto_render_spa && response && response.success? && SpaDetector.spa?(response)
|
|
188
|
+
should_render_cpt = auto_solve_waf && response && Waf::Detector.challenge_page?(response)
|
|
189
|
+
|
|
190
|
+
if (should_render_spa || should_render_cpt) && method.to_s.upcase == 'GET'
|
|
191
|
+
target_reason = should_render_cpt ? 'WAF challenge page' : 'unhydrated SPA shell'
|
|
192
|
+
log_debug("[HttpMimic] Detected #{target_reason} on #{url}. Automatically rendering with Obscura...")
|
|
193
|
+
begin
|
|
194
|
+
spa_opts = options.dup
|
|
195
|
+
# Forward all validated cookies from Tier 1 (Mode 2: Two-Phase Pipeline)
|
|
196
|
+
if response.cookies && !response.cookies.empty?
|
|
197
|
+
tier1_cookies = response.cookies.to_h
|
|
198
|
+
existing_cookies = spa_opts[:cookies].is_a?(Hash) ? spa_opts[:cookies] : {}
|
|
199
|
+
spa_opts[:cookies] = existing_cookies.merge(tier1_cookies)
|
|
200
|
+
end
|
|
201
|
+
spa_opts[:wait_until] ||= 'load' if should_render_cpt
|
|
202
|
+
rendered_resp = Obscura.render(url, spa_opts)
|
|
203
|
+
if rendered_resp && rendered_resp.success?
|
|
204
|
+
response = rendered_resp
|
|
141
205
|
end
|
|
206
|
+
rescue StandardError => e
|
|
207
|
+
log_debug("[HttpMimic] Automatic Obscura render failed (#{e.message}), keeping Tier 1 response.")
|
|
142
208
|
end
|
|
143
209
|
end
|
|
144
210
|
|
|
145
211
|
# Persist cookies back to store if enabled
|
|
146
|
-
if should_persist_cookie && host
|
|
147
|
-
|
|
148
|
-
|
|
212
|
+
if should_persist_cookie && host
|
|
213
|
+
is_success = response && (response.success? || response.redirect?) && !Waf::Detector.challenge_page?(response) && (final_status.nil? || final_status.exitstatus == 0)
|
|
214
|
+
verification_failed = !is_success && response && (
|
|
215
|
+
[401, 403].include?(response.code) ||
|
|
216
|
+
retry_statuses.include?(response.code) ||
|
|
217
|
+
Waf::Detector.challenge_page?(response)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
persist_on_failure = options.fetch(:persist_on_failure, config.persist_on_failure)
|
|
221
|
+
clear_on_failure = options.fetch(:clear_on_failure, config.clear_on_failure)
|
|
222
|
+
|
|
223
|
+
if is_success || persist_on_failure
|
|
224
|
+
if response && response.cookies && !response.cookies.empty?
|
|
225
|
+
CookieStore.save(host, response.cookies)
|
|
226
|
+
log_debug("[HttpMimic::CookieStore] Saved #{response.cookies.size} cookies for #{host}")
|
|
227
|
+
end
|
|
228
|
+
elsif clear_on_failure && verification_failed
|
|
229
|
+
CookieStore.clear(host)
|
|
230
|
+
log_debug("[HttpMimic::CookieStore] Verification failed for #{host} (status: #{response&.code}). Cleared stored cookies.")
|
|
231
|
+
end
|
|
149
232
|
end
|
|
150
233
|
|
|
151
234
|
handle_errors(final_status, final_stderr, final_command, response)
|
|
@@ -157,7 +240,7 @@ module HttpMimic
|
|
|
157
240
|
def determine_profiles(mode, auto_fallback)
|
|
158
241
|
case mode
|
|
159
242
|
when :curl, :curl_first
|
|
160
|
-
auto_fallback ? [:curl, :impersonate, :android, :ios] : [:curl]
|
|
243
|
+
auto_fallback ? [:curl, :impersonate, :android, :ios, :firefox] : [:curl]
|
|
161
244
|
when :curl_only
|
|
162
245
|
[:curl]
|
|
163
246
|
when :impersonate_only
|
|
@@ -168,18 +251,22 @@ module HttpMimic
|
|
|
168
251
|
[:android]
|
|
169
252
|
when :ios_only
|
|
170
253
|
[:ios]
|
|
254
|
+
when :firefox_only
|
|
255
|
+
[:firefox]
|
|
256
|
+
when :safari_only
|
|
257
|
+
[:safari]
|
|
171
258
|
when :mobile_first
|
|
172
|
-
auto_fallback ? [:android, :ios, :impersonate, :
|
|
259
|
+
auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:mobile]
|
|
173
260
|
when :android_first
|
|
174
|
-
auto_fallback ? [:android, :ios, :impersonate, :
|
|
261
|
+
auto_fallback ? [:android, :ios, :impersonate, :firefox] : [:android]
|
|
175
262
|
when :ios_first
|
|
176
|
-
auto_fallback ? [:ios, :android, :impersonate, :
|
|
263
|
+
auto_fallback ? [:ios, :android, :impersonate, :firefox] : [:ios]
|
|
177
264
|
when :impersonate_first
|
|
178
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
265
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
179
266
|
when :auto, :smart
|
|
180
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
267
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
181
268
|
else
|
|
182
|
-
auto_fallback ? [:impersonate, :android, :ios, :
|
|
269
|
+
auto_fallback ? [:impersonate, :android, :ios, :firefox] : [:impersonate]
|
|
183
270
|
end
|
|
184
271
|
end
|
|
185
272
|
|
data/lib/http_mimic/version.rb
CHANGED
|
@@ -22,6 +22,7 @@ module HttpMimic
|
|
|
22
22
|
impersonate: impersonate,
|
|
23
23
|
cookies: cookies,
|
|
24
24
|
headers: { 'Referer' => target_url },
|
|
25
|
+
proxy: options[:proxy],
|
|
25
26
|
auto_fallback: false,
|
|
26
27
|
solve_waf: false
|
|
27
28
|
)
|
|
@@ -29,22 +30,81 @@ module HttpMimic
|
|
|
29
30
|
|
|
30
31
|
sensor_js = script_resp.body
|
|
31
32
|
context_js = File.read(CONTEXT_JS_PATH)
|
|
32
|
-
cookie_str = cookies.to_cookie_string
|
|
33
33
|
|
|
34
34
|
doc_title = initial_response.title.to_s
|
|
35
35
|
user_agent = options[:user_agent]
|
|
36
36
|
referer = (options[:headers] || {})['Referer'] || (options[:headers] || {})['referer'] || target_url
|
|
37
37
|
|
|
38
|
-
# 2.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
# 2. Phase 1: Virtual browser simulation inside QuickJS (initial telemetry)
|
|
39
|
+
driver_script_1 = build_driver_script(
|
|
40
|
+
target_url: target_url,
|
|
41
|
+
doc_title: doc_title,
|
|
42
|
+
cookie_str: cookies.to_cookie_string,
|
|
43
|
+
referer: referer,
|
|
44
|
+
user_agent: user_agent,
|
|
45
|
+
context_js: context_js,
|
|
46
|
+
sensor_js: sensor_js,
|
|
47
|
+
phase: 1
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
result1 = JSRuntime.eval_json(driver_script_1)
|
|
51
|
+
sensor_posts_1 = result1 && result1['sensor_posts']
|
|
52
|
+
return nil if sensor_posts_1.nil? || sensor_posts_1.empty?
|
|
53
|
+
|
|
54
|
+
# 3. Post Phase 1 sensor data to Akamai endpoint
|
|
55
|
+
updated_cookies = cookies.dup
|
|
56
|
+
post_sensor_data(sensor_script_url, target_url, impersonate, updated_cookies, sensor_posts_1, proxy: options[:proxy])
|
|
57
|
+
|
|
58
|
+
# 4. Phase 2: If _abck is not verified yet (~0~), perform second round after short pause
|
|
59
|
+
if !verified_abck?(updated_cookies)
|
|
60
|
+
sleep 0.8
|
|
61
|
+
driver_script_2 = build_driver_script(
|
|
62
|
+
target_url: target_url,
|
|
63
|
+
doc_title: doc_title,
|
|
64
|
+
cookie_str: updated_cookies.to_cookie_string,
|
|
65
|
+
referer: referer,
|
|
66
|
+
user_agent: user_agent,
|
|
67
|
+
context_js: context_js,
|
|
68
|
+
sensor_js: sensor_js,
|
|
69
|
+
phase: 2
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
result2 = JSRuntime.eval_json(driver_script_2)
|
|
73
|
+
sensor_posts_2 = result2 && result2['sensor_posts']
|
|
74
|
+
if sensor_posts_2 && !sensor_posts_2.empty?
|
|
75
|
+
post_sensor_data(sensor_script_url, target_url, impersonate, updated_cookies, sensor_posts_2, proxy: options[:proxy])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 5. Retry original target URL with the updated cookies
|
|
80
|
+
retry_headers = (options[:headers] || {}).merge('Referer' => target_url)
|
|
81
|
+
HttpMimic.get(
|
|
82
|
+
target_url,
|
|
83
|
+
options.merge(
|
|
84
|
+
cookies: updated_cookies,
|
|
85
|
+
headers: retry_headers,
|
|
86
|
+
auto_fallback: false,
|
|
87
|
+
solve_waf: false
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
if HttpMimic.configuration.debug
|
|
92
|
+
puts "[HttpMimic::Waf::AkamaiSolver] Error solving Akamai challenge: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_driver_script(params)
|
|
98
|
+
<<~JS
|
|
99
|
+
globalThis.__TARGET_URL__ = #{params[:target_url].to_json};
|
|
100
|
+
globalThis.__DOCUMENT_TITLE__ = #{params[:doc_title].to_json};
|
|
101
|
+
globalThis.__INITIAL_COOKIES__ = #{params[:cookie_str].to_json};
|
|
102
|
+
globalThis.__REFERRER__ = #{params[:referer].to_json};
|
|
103
|
+
#{params[:user_agent] ? "globalThis.__USER_AGENT__ = #{params[:user_agent].to_json};" : ""}
|
|
104
|
+
globalThis.__TELEMETRY_PHASE__ = #{params[:phase] || 1};
|
|
45
105
|
|
|
46
|
-
#{context_js}
|
|
47
|
-
#{sensor_js}
|
|
106
|
+
#{params[:context_js]}
|
|
107
|
+
#{params[:sensor_js]}
|
|
48
108
|
|
|
49
109
|
if (typeof globalThis.__simulateHumanInteractions === "function") {
|
|
50
110
|
globalThis.__simulateHumanInteractions();
|
|
@@ -57,63 +117,61 @@ module HttpMimic
|
|
|
57
117
|
sensor_posts: globalThis.__sensor_posts
|
|
58
118
|
});
|
|
59
119
|
JS
|
|
120
|
+
end
|
|
60
121
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return nil if sensor_posts.nil? || sensor_posts.empty?
|
|
64
|
-
|
|
65
|
-
# 3. Post sensor data to Akamai endpoint
|
|
66
|
-
updated_cookies = cookies.dup
|
|
122
|
+
def post_sensor_data(sensor_script_url, target_url, impersonate, updated_cookies, sensor_posts, proxy: nil)
|
|
123
|
+
origin_url = URI.parse(target_url).tap { |u| u.path = ''; u.query = nil }.to_s rescue target_url
|
|
67
124
|
sensor_posts.each do |post|
|
|
68
125
|
post_body = post['body']
|
|
69
126
|
next if post_body.nil? || post_body.empty?
|
|
70
127
|
|
|
128
|
+
post_url = if post['url'].to_s.empty?
|
|
129
|
+
target_url
|
|
130
|
+
elsif post['url'].start_with?('http')
|
|
131
|
+
post['url']
|
|
132
|
+
else
|
|
133
|
+
URI.join(target_url, post['url']).to_s
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
content_type = (post['headers'] && (post['headers']['Content-Type'] || post['headers']['content-type'])) || 'application/json'
|
|
137
|
+
|
|
71
138
|
post_resp = HttpMimic.post(
|
|
72
|
-
|
|
139
|
+
post_url,
|
|
73
140
|
impersonate: impersonate,
|
|
74
141
|
cookies: updated_cookies,
|
|
75
142
|
headers: {
|
|
76
|
-
'Content-Type' =>
|
|
143
|
+
'Content-Type' => content_type,
|
|
77
144
|
'Referer' => target_url,
|
|
78
|
-
'Origin' =>
|
|
145
|
+
'Origin' => origin_url,
|
|
79
146
|
'Accept' => '*/*'
|
|
80
147
|
},
|
|
148
|
+
proxy: proxy,
|
|
81
149
|
body: post_body,
|
|
82
150
|
auto_fallback: false,
|
|
83
151
|
solve_waf: false
|
|
84
152
|
)
|
|
85
153
|
|
|
86
|
-
post_resp.cookies.each { |k, v| updated_cookies[k] = v }
|
|
154
|
+
post_resp.cookies.each { |k, v| updated_cookies[k] = v } if post_resp&.cookies
|
|
87
155
|
end
|
|
156
|
+
end
|
|
88
157
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
158
|
+
def verified_abck?(cookies)
|
|
159
|
+
return false unless cookies
|
|
160
|
+
abck = cookies['_abck'] || (cookies.respond_to?(:key?) && cookies['_abck'])
|
|
161
|
+
abck.to_s.include?('~0~')
|
|
105
162
|
end
|
|
106
163
|
|
|
107
164
|
def extract_sensor_script_url(target_url, html)
|
|
108
165
|
return nil if html.nil? || html.empty?
|
|
109
166
|
|
|
110
|
-
match = html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_
|
|
167
|
+
match = html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_\-\/]+\?[^"\x27\s>]+)["\x27]/i)
|
|
111
168
|
match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*akam[^"\x27]*)["\x27]/i)
|
|
112
|
-
match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_
|
|
169
|
+
match ||= html.match(/<script[^>]*src=["\x27]([^"\x27]*\/[a-zA-Z0-9_-]{10,}\?[a-zA-Z0-9_=-]+)["\x27]/i)
|
|
113
170
|
|
|
114
171
|
return nil unless match
|
|
115
172
|
|
|
116
|
-
|
|
173
|
+
raw_url = match[1].gsub('&', '&')
|
|
174
|
+
URI.join(target_url, raw_url).to_s
|
|
117
175
|
rescue StandardError
|
|
118
176
|
nil
|
|
119
177
|
end
|
data/lib/http_mimic.rb
CHANGED
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.5.
|
|
4
|
+
version: 0.5.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- anxgang
|
|
@@ -73,6 +73,7 @@ files:
|
|
|
73
73
|
- lib/http_mimic/js_runtime.rb
|
|
74
74
|
- lib/http_mimic/module_methods.rb
|
|
75
75
|
- lib/http_mimic/obscura.rb
|
|
76
|
+
- lib/http_mimic/proxy_pool.rb
|
|
76
77
|
- lib/http_mimic/request.rb
|
|
77
78
|
- lib/http_mimic/response.rb
|
|
78
79
|
- lib/http_mimic/response_parser.rb
|