@emircansahin/ghostfetch 0.1.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy-manager.js","sourceRoot":"","sources":["../../src/proxy-manager.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,111 @@
1
+ /** Error codes that are definitely proxy/network failures — request never reached the server. */
2
+ const PROXY_ERROR_CODES = new Set([
3
+ 'ECONNREFUSED',
4
+ 'ENOTFOUND',
5
+ 'EAI_AGAIN',
6
+ 'EHOSTUNREACH',
7
+ 'ENETUNREACH',
8
+ 'EPIPE',
9
+ ]);
10
+ /** Error codes that could be proxy OR server — we can't tell for sure. */
11
+ const AMBIGUOUS_ERROR_CODES = new Set([
12
+ 'ETIMEDOUT',
13
+ 'ECONNRESET',
14
+ 'ECONNABORTED',
15
+ ]);
16
+ const PROXY_ERROR_KEYWORDS = [
17
+ 'proxy',
18
+ 'tunnel',
19
+ 'connect econnrefused',
20
+ ];
21
+ const AMBIGUOUS_ERROR_KEYWORDS = [
22
+ 'socket hang up',
23
+ 'timeout',
24
+ ];
25
+ /** Cloudflare JS challenge detection patterns */
26
+ const CF_CHALLENGE_PATTERNS = [
27
+ 'cf-browser-verification',
28
+ 'cf_chl_opt',
29
+ 'jschl_vc',
30
+ 'jschl_answer',
31
+ 'Checking your browser',
32
+ 'Just a moment...',
33
+ '_cf_chl_tk',
34
+ ];
35
+ /**
36
+ * Default status codes that should trigger retry.
37
+ * - 'server': retry with different proxy, proxy is not penalized
38
+ * - 'proxy': retry with different proxy, proxy fail count incremented
39
+ */
40
+ const DEFAULT_RETRY_STATUSES = {
41
+ 429: 'server', // Rate limit — not proxy's fault, just retry with different IP
42
+ 503: 'server', // Service unavailable — server overloaded
43
+ 407: 'proxy', // Proxy authentication required — proxy is broken
44
+ };
45
+ /**
46
+ * Classify an error as proxy, server, or ambiguous.
47
+ *
48
+ * - proxy: request definitely never reached the server (DNS fail, connection refused, etc.)
49
+ * - server: an HTTP response was received — the proxy worked fine
50
+ * - ambiguous: could be either (timeout, connection reset) — proxy should NOT be penalized
51
+ */
52
+ export function classifyError(error) {
53
+ if (error && typeof error === 'object') {
54
+ const err = error;
55
+ // If there's an HTTP status code, the request reached the server → server error
56
+ if (err.status && typeof err.status === 'number') {
57
+ return 'server';
58
+ }
59
+ const code = (err.code || err.errno);
60
+ const message = (err.message || '').toLowerCase();
61
+ // Check definite proxy errors first
62
+ if (code && PROXY_ERROR_CODES.has(code)) {
63
+ return 'proxy';
64
+ }
65
+ if (PROXY_ERROR_KEYWORDS.some((kw) => message.includes(kw))) {
66
+ return 'proxy';
67
+ }
68
+ // Check ambiguous errors
69
+ if (code && AMBIGUOUS_ERROR_CODES.has(code)) {
70
+ return 'ambiguous';
71
+ }
72
+ if (AMBIGUOUS_ERROR_KEYWORDS.some((kw) => message.includes(kw))) {
73
+ return 'ambiguous';
74
+ }
75
+ }
76
+ // Default: treat unknown errors as server errors (keep proxies alive)
77
+ return 'server';
78
+ }
79
+ /**
80
+ * Check if a successful response is actually a Cloudflare JS challenge.
81
+ */
82
+ export function isCloudflareChallenge(response) {
83
+ if (response.status === 403 || response.status === 503) {
84
+ return CF_CHALLENGE_PATTERNS.some((pattern) => response.body.includes(pattern));
85
+ }
86
+ return false;
87
+ }
88
+ /**
89
+ * Run interceptors against a response.
90
+ *
91
+ * First interceptor whose `match` returns true takes ownership.
92
+ * Its `check` result determines the action. Default status handling
93
+ * is bypassed whenever an interceptor matches (even if check returns null).
94
+ */
95
+ export function checkInterceptors(url, response, interceptors) {
96
+ for (const interceptor of interceptors) {
97
+ if (!interceptor.match(url))
98
+ continue;
99
+ const action = interceptor.check(response);
100
+ return { matched: true, action, interceptor };
101
+ }
102
+ return { matched: false, action: null };
103
+ }
104
+ /**
105
+ * Check if a response status code should trigger a default retry.
106
+ * Only called when no interceptor matched the URL.
107
+ * Returns the error type if retry should happen, or null if response is fine.
108
+ */
109
+ export function checkDefaultRetryStatus(status) {
110
+ return DEFAULT_RETRY_STATUSES[status] ?? null;
111
+ }
@@ -0,0 +1,403 @@
1
+ import initCycleTLS from 'cycletls';
2
+ import { ProxyManager } from './proxy-manager';
3
+ import { classifyError, isCloudflareChallenge, checkInterceptors, checkDefaultRetryStatus } from './classifier';
4
+ import { GhostFetchRequestError, CloudflareJSChallengeError, NoProxyAvailableError, MaxRetriesExceededError } from './errors';
5
+ const DEFAULT_DELAYS = [1000, 2000, 4000];
6
+ const DEFAULT_TIMEOUT = 30000;
7
+ const HEALTH_BATCH_CONCURRENCY = 10;
8
+ const HEALTH_RETRY_DELAYS = [0, 15000, 60000]; // immediate, +15s, +60s
9
+ export class GhostFetch {
10
+ constructor(config = {}) {
11
+ this.cycleTLS = null;
12
+ this.initPromise = null;
13
+ this.interceptors = [];
14
+ this.refreshTimer = null;
15
+ this.refreshing = false;
16
+ this.healthCheckPromise = null;
17
+ this.config = config;
18
+ // Start with empty proxy list — healthCheck will populate it
19
+ this.proxyManager = new ProxyManager([], config.ban);
20
+ this.retryDefaults = { delays: config.retry?.delays ?? DEFAULT_DELAYS };
21
+ // Auto health check on init if proxies provided
22
+ if (config.proxies?.length) {
23
+ this.healthCheckPromise = this.healthCheckProxies(config.proxies).catch((err) => {
24
+ // Prevent unhandled rejection — return empty result so ready() resolves safely
25
+ return { total: config.proxies.length, healthy: 0, dead: config.proxies.length, countries: {}, proxies: {} };
26
+ });
27
+ }
28
+ // Start proxy refresh interval only if both callback and interval are provided
29
+ if (config.onProxyRefresh && config.proxyRefreshInterval) {
30
+ this.refreshTimer = setInterval(() => this.refreshProxies(), config.proxyRefreshInterval);
31
+ }
32
+ }
33
+ /**
34
+ * Wait until the initial health check is complete.
35
+ * Returns health check results with proxy details and country info.
36
+ * Requests automatically wait for this internally.
37
+ *
38
+ * @example
39
+ * const result = await client.ready();
40
+ * console.log(result);
41
+ * // {
42
+ * // total: 10, healthy: 8, dead: 2,
43
+ * // countries: { US: 3, DE: 5 },
44
+ * // proxies: { 'http://...@host:8001': 'US', 'http://...@host:8002': 'DE', ... }
45
+ * // }
46
+ */
47
+ async ready() {
48
+ if (this.healthCheckPromise)
49
+ return this.healthCheckPromise;
50
+ return { total: 0, healthy: 0, dead: 0, countries: {}, proxies: {} };
51
+ }
52
+ /** Lazy-initialize CycleTLS instance. */
53
+ async ensureClient() {
54
+ if (this.cycleTLS)
55
+ return this.cycleTLS;
56
+ if (!this.initPromise) {
57
+ this.initPromise = initCycleTLS().then((client) => {
58
+ this.cycleTLS = client;
59
+ });
60
+ }
61
+ await this.initPromise;
62
+ return this.cycleTLS;
63
+ }
64
+ /** Add a custom interceptor for site-specific error handling. */
65
+ addInterceptor(interceptor) {
66
+ this.interceptors.push(interceptor);
67
+ }
68
+ /** Remove an interceptor by name. */
69
+ removeInterceptor(name) {
70
+ this.interceptors = this.interceptors.filter((i) => i.name !== name);
71
+ }
72
+ /** Manually refresh the proxy list via the onProxyRefresh callback. */
73
+ async refreshProxies() {
74
+ if (!this.config.onProxyRefresh)
75
+ return;
76
+ if (this.refreshing)
77
+ return; // prevent overlapping refreshes
78
+ // Wait for any in-progress health check (e.g., initial startup)
79
+ if (this.healthCheckPromise)
80
+ await this.healthCheckPromise;
81
+ this.refreshing = true;
82
+ try {
83
+ const proxies = await this.config.onProxyRefresh();
84
+ // Health check first — only replace if it succeeds
85
+ // healthCheckProxies calls replaceProxies internally with healthy list
86
+ await this.healthCheckProxies(proxies);
87
+ }
88
+ finally {
89
+ this.refreshing = false;
90
+ }
91
+ }
92
+ /** Get proxy manager stats. */
93
+ get stats() {
94
+ return {
95
+ totalProxies: this.proxyManager.total,
96
+ availableProxies: this.proxyManager.available,
97
+ bannedProxies: this.proxyManager.banned,
98
+ };
99
+ }
100
+ // --- HTTP methods ---
101
+ async get(url, options) {
102
+ return this.request('GET', url, options);
103
+ }
104
+ async post(url, options) {
105
+ return this.request('POST', url, options);
106
+ }
107
+ async put(url, options) {
108
+ return this.request('PUT', url, options);
109
+ }
110
+ async delete(url, options) {
111
+ return this.request('DELETE', url, options);
112
+ }
113
+ async patch(url, options) {
114
+ return this.request('PATCH', url, options);
115
+ }
116
+ async head(url, options) {
117
+ return this.request('HEAD', url, options);
118
+ }
119
+ // --- Core request logic ---
120
+ async request(method, url, options) {
121
+ // Wait for health check to finish before first request
122
+ await this.ready();
123
+ const delays = options?.retry?.delays ?? this.retryDefaults.delays ?? DEFAULT_DELAYS;
124
+ const maxAttempts = delays.length + 1; // first attempt + retries
125
+ const forceProxy = options?.forceProxy ?? this.config.forceProxy ?? false;
126
+ let lastError = null;
127
+ let lastFailedProxy = null;
128
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
129
+ // Wait before retry (not on first attempt)
130
+ if (attempt > 0) {
131
+ await sleep(delays[attempt - 1]);
132
+ }
133
+ // Pick a proxy
134
+ const proxy = options?.proxy ?? await this.pickProxy(forceProxy, lastFailedProxy, options?.country);
135
+ try {
136
+ const response = await this.executeRequest(method, url, proxy, options);
137
+ // 1. Per-request interceptor takes highest priority
138
+ if (options?.interceptor) {
139
+ const reqAction = options.interceptor.check(response);
140
+ if (reqAction !== null) {
141
+ const result = this.handleInterceptorAction(reqAction, 'request', response, proxy);
142
+ if (result === 'return') {
143
+ return response;
144
+ }
145
+ lastError = result.error;
146
+ lastFailedProxy = proxy;
147
+ continue;
148
+ }
149
+ // null → fall through to instance interceptors
150
+ }
151
+ // 2. Instance-level interceptors — first match takes full ownership
152
+ const { matched, action, interceptor } = checkInterceptors(url, response, [...this.interceptors]);
153
+ if (matched) {
154
+ if (action === 'skip' || action === null) {
155
+ if (proxy)
156
+ this.proxyManager.reportSuccess(proxy);
157
+ return response;
158
+ }
159
+ const result = this.handleInterceptorAction(action, interceptor?.name ?? 'unnamed', response, proxy);
160
+ if (result === 'return') {
161
+ return response;
162
+ }
163
+ lastError = result.error;
164
+ lastFailedProxy = proxy;
165
+ continue;
166
+ }
167
+ // 3. Cloudflare JS challenge (only when no interceptor claimed the response)
168
+ if (isCloudflareChallenge(response)) {
169
+ throw new CloudflareJSChallengeError(url, proxy ?? undefined);
170
+ }
171
+ // 4. No interceptor matched → check default retry statuses (429, 503, 407)
172
+ const defaultRetry = checkDefaultRetryStatus(response.status);
173
+ if (defaultRetry) {
174
+ if (proxy) {
175
+ if (defaultRetry === 'proxy') {
176
+ this.proxyManager.reportFailure(proxy);
177
+ }
178
+ else {
179
+ this.proxyManager.reportSuccess(proxy);
180
+ }
181
+ }
182
+ lastError = new GhostFetchRequestError({
183
+ type: defaultRetry,
184
+ message: `HTTP ${response.status}`,
185
+ status: response.status,
186
+ body: response.body,
187
+ proxy: proxy ?? undefined,
188
+ });
189
+ lastFailedProxy = proxy;
190
+ continue;
191
+ }
192
+ // 4. Normal response — return it
193
+ if (proxy)
194
+ this.proxyManager.reportSuccess(proxy);
195
+ return response;
196
+ }
197
+ catch (error) {
198
+ if (error instanceof CloudflareJSChallengeError) {
199
+ throw error;
200
+ }
201
+ if (error instanceof NoProxyAvailableError) {
202
+ throw error;
203
+ }
204
+ const errorType = classifyError(error);
205
+ const requestError = error instanceof GhostFetchRequestError
206
+ ? error
207
+ : new GhostFetchRequestError({
208
+ type: errorType,
209
+ message: error instanceof Error ? error.message : String(error),
210
+ proxy: proxy ?? undefined,
211
+ cause: error,
212
+ });
213
+ if (proxy) {
214
+ if (errorType === 'proxy') {
215
+ this.proxyManager.reportFailure(proxy);
216
+ }
217
+ else if (errorType === 'server') {
218
+ this.proxyManager.reportSuccess(proxy);
219
+ }
220
+ // 'ambiguous' — do nothing
221
+ }
222
+ lastError = requestError;
223
+ lastFailedProxy = proxy;
224
+ }
225
+ }
226
+ throw new MaxRetriesExceededError(maxAttempts, lastError);
227
+ }
228
+ /**
229
+ * Pick a proxy based on forceProxy and country settings.
230
+ * - If forceProxy: wait until a proxy is available (blocks)
231
+ * - If !forceProxy: return null if none available (proceed without proxy)
232
+ */
233
+ async pickProxy(forceProxy, exclude, country) {
234
+ const opts = { exclude, country };
235
+ const proxy = this.proxyManager.getProxy(opts);
236
+ if (proxy)
237
+ return proxy;
238
+ // No proxy available
239
+ if (this.proxyManager.total === 0) {
240
+ // No proxies configured at all
241
+ if (forceProxy)
242
+ throw new NoProxyAvailableError();
243
+ return null;
244
+ }
245
+ // Proxies exist but all banned or none match the country filter
246
+ if (forceProxy) {
247
+ // If filtering by country and no proxies exist for that country, fail immediately
248
+ if (country && this.proxyManager.getProxiesByCountry(country).length === 0) {
249
+ throw new NoProxyAvailableError();
250
+ }
251
+ // Wait until one becomes available (ban expires or refresh happens)
252
+ return this.proxyManager.waitForProxy(opts);
253
+ }
254
+ // Not forced — proceed without proxy
255
+ return null;
256
+ }
257
+ /** Handle an interceptor action (retry/ban/skip). Returns 'return' to send response, or { error } to continue retry loop. */
258
+ handleInterceptorAction(action, name, response, proxy) {
259
+ if (action === 'skip') {
260
+ if (proxy)
261
+ this.proxyManager.reportSuccess(proxy);
262
+ return 'return';
263
+ }
264
+ if (action === 'ban') {
265
+ if (proxy)
266
+ this.proxyManager.reportFailure(proxy);
267
+ return {
268
+ error: new GhostFetchRequestError({
269
+ type: 'proxy',
270
+ message: `Interceptor "${name}": ban (HTTP ${response.status})`,
271
+ status: response.status,
272
+ body: response.body,
273
+ proxy: proxy ?? undefined,
274
+ }),
275
+ };
276
+ }
277
+ // retry
278
+ if (proxy)
279
+ this.proxyManager.reportSuccess(proxy);
280
+ return {
281
+ error: new GhostFetchRequestError({
282
+ type: 'server',
283
+ message: `Interceptor "${name}": retry (HTTP ${response.status})`,
284
+ status: response.status,
285
+ body: response.body,
286
+ proxy: proxy ?? undefined,
287
+ }),
288
+ };
289
+ }
290
+ async executeRequest(method, url, proxy, options) {
291
+ const client = await this.ensureClient();
292
+ const timeout = options?.timeout ?? this.config.timeout ?? DEFAULT_TIMEOUT;
293
+ const headers = {
294
+ ...this.config.headers,
295
+ ...options?.headers,
296
+ };
297
+ const cycleTLSOptions = {
298
+ headers,
299
+ timeout,
300
+ disableRedirect: false,
301
+ };
302
+ if (proxy) {
303
+ cycleTLSOptions.proxy = proxy;
304
+ }
305
+ if (this.config.ja3) {
306
+ cycleTLSOptions.ja3 = this.config.ja3;
307
+ }
308
+ if (this.config.userAgent) {
309
+ cycleTLSOptions.userAgent = this.config.userAgent;
310
+ }
311
+ if (options?.body) {
312
+ cycleTLSOptions.body = typeof options.body === 'string'
313
+ ? options.body
314
+ : JSON.stringify(options.body);
315
+ if (typeof options.body !== 'string') {
316
+ const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === 'content-type');
317
+ if (!hasContentType) {
318
+ headers['content-type'] = 'application/json';
319
+ }
320
+ }
321
+ }
322
+ const response = await client(url, cycleTLSOptions, method.toLowerCase());
323
+ return {
324
+ status: response.status,
325
+ headers: (response.headers ?? {}),
326
+ body: response.body == null ? '' : typeof response.body === 'string' ? response.body : JSON.stringify(response.body),
327
+ url: response.finalUrl || url,
328
+ };
329
+ }
330
+ /**
331
+ * Health check + country resolution for proxies.
332
+ * Each proxy gets up to 3 attempts (0s, +15s, +60s) to reach ipinfo.io.
333
+ * Healthy proxies are added to the pool with their country code.
334
+ * Failed proxies are discarded.
335
+ */
336
+ async healthCheckProxies(proxies) {
337
+ const client = await this.ensureClient();
338
+ const healthy = [];
339
+ const proxyDetails = {};
340
+ const countries = {};
341
+ const countryEntries = [];
342
+ // Process in batches
343
+ for (let i = 0; i < proxies.length; i += HEALTH_BATCH_CONCURRENCY) {
344
+ const batch = proxies.slice(i, i + HEALTH_BATCH_CONCURRENCY);
345
+ await Promise.allSettled(batch.map(async (proxy) => {
346
+ for (let attempt = 0; attempt < HEALTH_RETRY_DELAYS.length; attempt++) {
347
+ if (attempt > 0) {
348
+ await sleep(HEALTH_RETRY_DELAYS[attempt]);
349
+ }
350
+ try {
351
+ const res = await client('https://ipinfo.io/json', { proxy, timeout: 10000, headers: {} }, 'get');
352
+ const data = typeof res.body === 'string' ? JSON.parse(res.body) : res.body;
353
+ const country = data.country ?? null;
354
+ if (country) {
355
+ countryEntries.push([proxy, country]);
356
+ countries[country] = (countries[country] ?? 0) + 1;
357
+ }
358
+ proxyDetails[proxy] = country;
359
+ healthy.push(proxy);
360
+ return;
361
+ }
362
+ catch {
363
+ // Will retry or discard
364
+ }
365
+ }
366
+ // All 3 attempts failed — proxy is dead
367
+ proxyDetails[proxy] = null;
368
+ }));
369
+ }
370
+ // Add only healthy proxies to the manager, then restore country data
371
+ this.proxyManager.replaceProxies(healthy);
372
+ for (const [proxy, country] of countryEntries) {
373
+ this.proxyManager.setCountry(proxy, country);
374
+ }
375
+ return {
376
+ total: proxies.length,
377
+ healthy: healthy.length,
378
+ dead: proxies.length - healthy.length,
379
+ countries,
380
+ proxies: proxyDetails,
381
+ };
382
+ }
383
+ /** Gracefully shut down the CycleTLS instance and clear timers. */
384
+ async destroy() {
385
+ if (this.refreshTimer) {
386
+ clearInterval(this.refreshTimer);
387
+ this.refreshTimer = null;
388
+ }
389
+ if (this.cycleTLS) {
390
+ try {
391
+ await this.cycleTLS.exit();
392
+ }
393
+ catch {
394
+ // CycleTLS may throw ESRCH when the Go process is already gone
395
+ }
396
+ this.cycleTLS = null;
397
+ this.initPromise = null;
398
+ }
399
+ }
400
+ }
401
+ function sleep(ms) {
402
+ return new Promise((resolve) => setTimeout(resolve, ms));
403
+ }
@@ -0,0 +1,35 @@
1
+ export class GhostFetchRequestError extends Error {
2
+ constructor(opts) {
3
+ super(opts.message);
4
+ this.name = 'GhostFetchRequestError';
5
+ this.type = opts.type;
6
+ this.status = opts.status;
7
+ this.body = opts.body;
8
+ this.proxy = opts.proxy;
9
+ this.cause = opts.cause;
10
+ }
11
+ }
12
+ export class CloudflareJSChallengeError extends GhostFetchRequestError {
13
+ constructor(url, proxy) {
14
+ super({
15
+ type: 'server',
16
+ message: `Cloudflare JS challenge detected at ${url}. This requires a headless browser (e.g. puppeteer-extra with stealth plugin).`,
17
+ proxy,
18
+ });
19
+ this.name = 'CloudflareJSChallengeError';
20
+ }
21
+ }
22
+ export class NoProxyAvailableError extends Error {
23
+ constructor() {
24
+ super('No proxies available — all proxies are banned or the proxy list is empty.');
25
+ this.name = 'NoProxyAvailableError';
26
+ }
27
+ }
28
+ export class MaxRetriesExceededError extends Error {
29
+ constructor(attempts, lastError) {
30
+ super(`Max retries exceeded (${attempts} attempts). Last error: ${lastError.message}`);
31
+ this.name = 'MaxRetriesExceededError';
32
+ this.lastError = lastError;
33
+ this.attempts = attempts;
34
+ }
35
+ }
@@ -0,0 +1,3 @@
1
+ export { GhostFetch } from './client';
2
+ export { ProxyManager } from './proxy-manager';
3
+ export { GhostFetchRequestError, CloudflareJSChallengeError, NoProxyAvailableError, MaxRetriesExceededError, } from './errors';
@@ -0,0 +1 @@
1
+ {"type":"module"}