http_mimic 0.4.0 → 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 +64 -0
- data/README.md +117 -33
- data/lib/http_mimic/command_builder.rb +17 -1
- data/lib/http_mimic/configuration.rb +33 -0
- data/lib/http_mimic/downloader.rb +97 -0
- data/lib/http_mimic/module_methods.rb +39 -0
- data/lib/http_mimic/obscura.rb +158 -0
- data/lib/http_mimic/proxy_pool.rb +224 -0
- data/lib/http_mimic/request.rb +127 -11
- data/lib/http_mimic/spa_detector.rb +95 -0
- data/lib/http_mimic/version.rb +1 -1
- data/lib/http_mimic/waf/akamai_solver.rb +97 -39
- data/lib/http_mimic/waf/detector.rb +12 -0
- data/lib/http_mimic/waf/google_solver.rb +124 -0
- data/lib/http_mimic/waf.rb +3 -0
- data/lib/http_mimic.rb +27 -0
- metadata +5 -1
|
@@ -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
|
|
@@ -9,6 +9,8 @@ module HttpMimic
|
|
|
9
9
|
|
|
10
10
|
if akamai?(response)
|
|
11
11
|
:akamai
|
|
12
|
+
elsif google?(response)
|
|
13
|
+
:google
|
|
12
14
|
elsif cloudflare?(response)
|
|
13
15
|
:cloudflare
|
|
14
16
|
elsif datadome?(response)
|
|
@@ -24,6 +26,16 @@ module HttpMimic
|
|
|
24
26
|
return true if body.include?('sec-if-cpt-container') || body.include?('sec-bc-button-parent')
|
|
25
27
|
return true if body.include?('challenges.cloudflare.com') || body.include?('cf-turnstile')
|
|
26
28
|
return true if body.include?('datadome.captcha') || body.include?('geo.captcha-delivery.com')
|
|
29
|
+
return true if body.include?('/httpservice/retry/enablejs') || (body.include?('knitsail') && body.include?('SG_SS'))
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def google?(response)
|
|
34
|
+
return false unless response
|
|
35
|
+
body = response.body.to_s
|
|
36
|
+
return true if body.include?('/httpservice/retry/enablejs')
|
|
37
|
+
return true if body.include?('knitsail') && body.include?('SG_SS')
|
|
38
|
+
return true if response.headers['server']&.downcase&.include?('gws') && body.include?('enablejs')
|
|
27
39
|
false
|
|
28
40
|
end
|
|
29
41
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module HttpMimic
|
|
7
|
+
module Waf
|
|
8
|
+
class GoogleSolver
|
|
9
|
+
class << self
|
|
10
|
+
def solve(target_url, response, options = {})
|
|
11
|
+
return nil unless response
|
|
12
|
+
|
|
13
|
+
html = response.body.to_s
|
|
14
|
+
return nil if html.empty?
|
|
15
|
+
|
|
16
|
+
uri = URI.parse(target_url) rescue nil
|
|
17
|
+
base_url = uri ? "#{uri.scheme}://#{uri.host}" : "https://www.google.com"
|
|
18
|
+
|
|
19
|
+
impersonate = options[:impersonate] || HttpMimic.configuration.default_impersonate || 'chrome131'
|
|
20
|
+
current_cookies = (response.cookies || Cookies.new).dup
|
|
21
|
+
|
|
22
|
+
# Strategy 1: Session warmup from Google root if cookies are sparse
|
|
23
|
+
if current_cookies.empty? || !current_cookies.key?('NID')
|
|
24
|
+
warmup_resp = HttpMimic.get(
|
|
25
|
+
"#{base_url}/",
|
|
26
|
+
impersonate: impersonate,
|
|
27
|
+
cookies: current_cookies,
|
|
28
|
+
auto_fallback: false,
|
|
29
|
+
solve_waf: false,
|
|
30
|
+
persist_cookies: false
|
|
31
|
+
)
|
|
32
|
+
warmup_resp.cookies.each { |k, v| current_cookies[k] = v } if warmup_resp&.cookies
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Strategy 2: Attempt QuickJS solve if script payload exists
|
|
36
|
+
scripts = html.scan(/<script[^>]*>([\s\S]*?)<\/script>/i).map(&:first)
|
|
37
|
+
if scripts.any? { |s| s.include?('knitsail') || s.include?('SG_SS') || s.include?('closureDynamicButton') }
|
|
38
|
+
sg_ss_cookie = solve_with_quickjs(target_url, scripts, current_cookies)
|
|
39
|
+
current_cookies['SG_SS'] = sg_ss_cookie if sg_ss_cookie && !sg_ss_cookie.start_with?('E:')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Strategy 3: Retry search query with same-origin referral headers and updated cookies
|
|
43
|
+
retry_headers = (options[:headers] || {}).dup
|
|
44
|
+
retry_headers['Referer'] ||= "#{base_url}/"
|
|
45
|
+
retry_headers['sec-fetch-site'] ||= 'same-origin'
|
|
46
|
+
|
|
47
|
+
retry_opts = options.merge(
|
|
48
|
+
cookies: current_cookies,
|
|
49
|
+
headers: retry_headers,
|
|
50
|
+
auto_fallback: false,
|
|
51
|
+
solve_waf: false
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
HttpMimic.get(target_url, **retry_opts)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
HttpMimic.logger.debug("[HttpMimic::Waf::GoogleSolver] Failed to solve Google challenge: #{e.message}") if HttpMimic.logger
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def solve_with_quickjs(target_url, scripts, cookies)
|
|
63
|
+
context_js_path = File.expand_path('browser_context.js', __dir__)
|
|
64
|
+
context_js = File.read(context_js_path)
|
|
65
|
+
cookie_str = cookies.respond_to?(:to_cookie_string) ? cookies.to_cookie_string : cookies.to_s
|
|
66
|
+
|
|
67
|
+
driver_script = <<~JS
|
|
68
|
+
globalThis.__TARGET_URL__ = #{target_url.to_json};
|
|
69
|
+
globalThis.__DOCUMENT_TITLE__ = "Google Search";
|
|
70
|
+
globalThis.__INITIAL_COOKIES__ = #{cookie_str.to_json};
|
|
71
|
+
globalThis.__REFERRER__ = "https://www.google.com/";
|
|
72
|
+
|
|
73
|
+
#{context_js}
|
|
74
|
+
|
|
75
|
+
// Google specific environment enhancements
|
|
76
|
+
globalThis.sessionStorage = {
|
|
77
|
+
_data: {},
|
|
78
|
+
getItem(k) { return this._data[k] || null; },
|
|
79
|
+
setItem(k, v) { this._data[k] = String(v); },
|
|
80
|
+
removeItem(k) { delete this._data[k]; },
|
|
81
|
+
clear() { this._data = {}; }
|
|
82
|
+
};
|
|
83
|
+
globalThis.localStorage = globalThis.sessionStorage;
|
|
84
|
+
globalThis._F_css = function() {};
|
|
85
|
+
globalThis.google = { c: { c: { a: true } } };
|
|
86
|
+
|
|
87
|
+
#{scripts.join("\n;\n")}
|
|
88
|
+
|
|
89
|
+
// Extract p token and invoke knitsail if present
|
|
90
|
+
var allScripts = #{scripts.join("\n").to_json};
|
|
91
|
+
var pMatch = allScripts.match(/var p=\\x27([^\\x27]+)\\x27/);
|
|
92
|
+
var pVal = pMatch ? pMatch[1] : null;
|
|
93
|
+
|
|
94
|
+
var solved_token = null;
|
|
95
|
+
if (globalThis.knitsail && typeof globalThis.knitsail.a === "function" && pVal) {
|
|
96
|
+
try {
|
|
97
|
+
globalThis.knitsail.a(pVal, function(resultCallback) {
|
|
98
|
+
if (typeof resultCallback === "function") {
|
|
99
|
+
resultCallback(function(token) {
|
|
100
|
+
solved_token = token;
|
|
101
|
+
}, [{}]);
|
|
102
|
+
}
|
|
103
|
+
}, false, undefined, undefined, undefined, undefined, true);
|
|
104
|
+
} catch(e) {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof globalThis.__drainEventLoop === "function") {
|
|
108
|
+
globalThis.__drainEventLoop(50);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
token: solved_token
|
|
113
|
+
});
|
|
114
|
+
JS
|
|
115
|
+
|
|
116
|
+
result = JSRuntime.eval_json(driver_script)
|
|
117
|
+
result ? result['token'] : nil
|
|
118
|
+
rescue StandardError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/http_mimic/waf.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'http_mimic/waf/detector'
|
|
4
4
|
require 'http_mimic/waf/akamai_solver'
|
|
5
|
+
require 'http_mimic/waf/google_solver'
|
|
5
6
|
|
|
6
7
|
module HttpMimic
|
|
7
8
|
module Waf
|
|
@@ -13,6 +14,8 @@ module HttpMimic
|
|
|
13
14
|
case waf_type
|
|
14
15
|
when :akamai
|
|
15
16
|
AkamaiSolver.solve(url, response, options)
|
|
17
|
+
when :google
|
|
18
|
+
GoogleSolver.solve(url, response, options)
|
|
16
19
|
else
|
|
17
20
|
nil
|
|
18
21
|
end
|
data/lib/http_mimic.rb
CHANGED
|
@@ -19,6 +19,9 @@ require 'http_mimic/module_methods'
|
|
|
19
19
|
require 'http_mimic/js_runtime'
|
|
20
20
|
require 'http_mimic/waf'
|
|
21
21
|
require 'http_mimic/cookie_store'
|
|
22
|
+
require 'http_mimic/obscura'
|
|
23
|
+
require 'http_mimic/spa_detector'
|
|
24
|
+
require 'http_mimic/proxy_pool'
|
|
22
25
|
|
|
23
26
|
module HttpMimic
|
|
24
27
|
extend ModuleMethods
|
|
@@ -94,6 +97,30 @@ module HttpMimic
|
|
|
94
97
|
CookieStore.clear(host)
|
|
95
98
|
end
|
|
96
99
|
|
|
100
|
+
# Obscura headless SPA engine helpers
|
|
101
|
+
def download_obscura!(version: nil, force: false)
|
|
102
|
+
Downloader.download_obscura!(version: version, force: force)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def obscura_installed?(version: nil)
|
|
106
|
+
Downloader.obscura_installed?(version: version)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def obscura_path
|
|
110
|
+
Downloader.obscura_path
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Render SPA page using Obscura headless engine
|
|
114
|
+
def render(url, options = {})
|
|
115
|
+
Obscura.render(url, options)
|
|
116
|
+
end
|
|
117
|
+
alias spa render
|
|
118
|
+
|
|
119
|
+
# Check if a response represents an unhydrated SPA shell
|
|
120
|
+
def spa?(response)
|
|
121
|
+
SpaDetector.spa?(response)
|
|
122
|
+
end
|
|
123
|
+
|
|
97
124
|
def included(base)
|
|
98
125
|
base.extend(ModuleMethods)
|
|
99
126
|
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.5.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- anxgang
|
|
@@ -72,13 +72,17 @@ files:
|
|
|
72
72
|
- lib/http_mimic/headers.rb
|
|
73
73
|
- lib/http_mimic/js_runtime.rb
|
|
74
74
|
- lib/http_mimic/module_methods.rb
|
|
75
|
+
- lib/http_mimic/obscura.rb
|
|
76
|
+
- lib/http_mimic/proxy_pool.rb
|
|
75
77
|
- lib/http_mimic/request.rb
|
|
76
78
|
- lib/http_mimic/response.rb
|
|
77
79
|
- lib/http_mimic/response_parser.rb
|
|
80
|
+
- lib/http_mimic/spa_detector.rb
|
|
78
81
|
- lib/http_mimic/version.rb
|
|
79
82
|
- lib/http_mimic/waf.rb
|
|
80
83
|
- lib/http_mimic/waf/akamai_solver.rb
|
|
81
84
|
- lib/http_mimic/waf/detector.rb
|
|
85
|
+
- lib/http_mimic/waf/google_solver.rb
|
|
82
86
|
homepage: https://github.com/anxgang/http_mimic
|
|
83
87
|
licenses:
|
|
84
88
|
- MIT
|