@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.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # ghostfetch
2
+
3
+ Resilient HTTP client for Node.js with CycleTLS, automatic proxy rotation, smart error classification, and per-site custom interceptors.
4
+
5
+ Built for backend developers who need to fetch data from sites that aggressively block automated requests.
6
+
7
+ ## Features
8
+
9
+ - **CycleTLS** — TLS fingerprint spoofing (bypasses Cloudflare and similar WAFs)
10
+ - **Proxy rotation** — random proxy selection per request with automatic health check
11
+ - **Smart error classification** — proxy vs server vs ambiguous errors
12
+ - **Proxy banning** — auto-ban failing proxies with configurable TTL
13
+ - **Custom interceptors** — per-site response handling (`retry`, `ban`, `skip`)
14
+ - **Default status handling** — 429/503 auto-retry, 407 proxy ban
15
+ - **Cloudflare detection** — JS challenge detection with descriptive errors
16
+ - **Country-based proxy selection** — auto-resolved via ipinfo.io
17
+ - **forceProxy mode** — wait for available proxy instead of proceeding without one
18
+ - **Health check on init** — dead proxies are discarded before any request
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install ghostfetch
24
+ # or
25
+ pnpm add ghostfetch
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```ts
31
+ import { GhostFetch } from 'ghostfetch';
32
+
33
+ const client = new GhostFetch({
34
+ proxies: [
35
+ 'http://user:pass@host:8001',
36
+ 'http://user:pass@host:8002',
37
+ ],
38
+ timeout: 30000,
39
+ retry: { delays: [5000, 15000, 30000] }, // 3 retries: wait 5s, 15s, 30s
40
+ ban: { maxFailures: 3, duration: 60 * 60 * 1000 },
41
+ // ban: false — disable proxy banning entirely
42
+ });
43
+
44
+ // Wait for health check to complete
45
+ const health = await client.ready();
46
+ console.log(health);
47
+ // { total: 2, healthy: 2, dead: 0, countries: { US: 1, DE: 1 }, proxies: { ... } }
48
+
49
+ // Make requests
50
+ const res = await client.get('https://api.example.com/data');
51
+ console.log(res.status, res.body);
52
+ ```
53
+
54
+ ## Custom Interceptors
55
+
56
+ Interceptors let you define per-site response handling. You can add them at the instance level (applies to all matching requests) or at the request level (applies to that single request only).
57
+
58
+ ### Instance-level interceptor
59
+
60
+ Matches requests by URL. First matching interceptor takes full ownership — default status handling (429, 503, etc.) is bypassed.
61
+
62
+ ```ts
63
+ client.addInterceptor({
64
+ name: 'example-api',
65
+ match: (url) => url.includes('example.com'),
66
+ check: (res) => {
67
+ if (res.status === 401) return 'skip'; // don't retry auth errors
68
+ if (res.body.includes('rate limit')) return 'retry'; // retry with different proxy
69
+ if (res.body.includes('blocked')) return 'ban'; // ban this proxy + retry
70
+ return null; // use default behavior
71
+ },
72
+ });
73
+ ```
74
+
75
+ ### Request-level interceptor
76
+
77
+ No `match` needed — it applies to this specific request. Takes priority over instance-level interceptors.
78
+
79
+ ```ts
80
+ const res = await client.get('https://special-api.com/data', {
81
+ interceptor: {
82
+ check: (res) => {
83
+ if (res.status === 401) return 'skip';
84
+ if (res.status === 200 && res.body.includes('error')) return 'retry';
85
+ return null;
86
+ },
87
+ },
88
+ });
89
+ ```
90
+
91
+ ### Interceptor priority
92
+
93
+ 1. **Request-level interceptor** — checked first, if it returns non-null action, it wins
94
+ 2. **Instance-level interceptors** — checked next, first `match` takes ownership
95
+ 3. **Default status handling** — only runs if no interceptor claimed the response
96
+
97
+ ### Actions
98
+
99
+ | Action | Proxy effect | Retry | Default bypass |
100
+ |--------|-------------|-------|----------------|
101
+ | `'retry'` | not penalized | yes | yes |
102
+ | `'ban'` | fail count +1 | yes | yes |
103
+ | `'skip'` | not penalized | no, return response | yes |
104
+ | `null` | — | — | no, defaults apply |
105
+
106
+ ## Country-Based Proxy Selection
107
+
108
+ All proxies are automatically resolved via ipinfo.io on init. This gives you country-level control over which proxy handles which request — useful when certain APIs only accept traffic from specific regions.
109
+
110
+ ```ts
111
+ // Only use a German proxy for this request
112
+ const res = await client.get('https://eu-only-api.com/data', {
113
+ country: 'DE',
114
+ });
115
+ ```
116
+
117
+ ## Force Proxy Mode
118
+
119
+ By default, if all proxies are banned or unavailable, requests proceed without a proxy. Enable `forceProxy` to make the request wait until a proxy becomes available (ban expires or proxy list is refreshed). This is useful for cron jobs where requests without a proxy are pointless.
120
+
121
+ **What happens when `forceProxy: true` and no proxy is available?**
122
+
123
+ | Scenario | Behavior |
124
+ |----------|----------|
125
+ | Proxy list is empty (none configured) | Throws `NoProxyAvailableError` immediately — nothing to wait for |
126
+ | Proxies exist but all banned | Waits until a ban expires or `onProxyRefresh` provides fresh proxies |
127
+
128
+ You can set it as the instance default or override per-request:
129
+
130
+ ```ts
131
+ // Instance default: all requests wait for proxy
132
+ const client = new GhostFetch({
133
+ proxies: [...],
134
+ forceProxy: true,
135
+ });
136
+
137
+ // Override per-request: this specific endpoint works without proxy
138
+ const publicData = await client.get('https://public-api.com/data', {
139
+ forceProxy: false,
140
+ });
141
+
142
+ // Or the other way: instance default is false, but this request needs a proxy
143
+ const protectedData = await client.get('https://protected-api.com/data', {
144
+ forceProxy: true,
145
+ });
146
+ ```
147
+
148
+ ## Disable Proxy Banning
149
+
150
+ If your proxy provider handles rotation internally (e.g. BrightData, Oxylabs residential) or you have a small proxy pool you don't want to lose, you can disable banning entirely:
151
+
152
+ ```ts
153
+ const client = new GhostFetch({
154
+ proxies: [...],
155
+ ban: false, // no proxy will ever be banned
156
+ });
157
+ ```
158
+
159
+ With `ban: false`, failed proxies are never penalized — they stay in rotation regardless of errors. Retry still works, just without proxy exclusion logic.
160
+
161
+ ## Proxy Refresh
162
+
163
+ Provide an `onProxyRefresh` callback to fetch a fresh proxy list from your provider. When triggered, all existing bans are cleared and the new proxies go through health check before entering the pool.
164
+
165
+ | Config | Behavior |
166
+ |--------|----------|
167
+ | `onProxyRefresh` only | No automatic refresh — call `client.refreshProxies()` manually |
168
+ | `onProxyRefresh` + `proxyRefreshInterval` | Auto-refresh at the given interval |
169
+ | Neither | No refresh capability — initial proxy list is used for the lifetime |
170
+
171
+ ```ts
172
+ // Auto-refresh every hour
173
+ const client = new GhostFetch({
174
+ proxies: [...],
175
+ proxyRefreshInterval: 60 * 60 * 1000,
176
+ onProxyRefresh: async () => {
177
+ return ['http://user:pass@newhost:8001', ...];
178
+ },
179
+ });
180
+
181
+ // Or manual-only: no interval, call when you need it
182
+ const client2 = new GhostFetch({
183
+ proxies: [...],
184
+ onProxyRefresh: async () => fetchFromProvider(),
185
+ });
186
+ await client2.refreshProxies(); // triggers onProxyRefresh → health check → pool updated
187
+ ```
188
+
189
+ ## Error Handling
190
+
191
+ ghostfetch throws specific error classes so you can handle each scenario precisely:
192
+
193
+ - **`CloudflareJSChallengeError`** — the target site requires a browser-level JS challenge that CycleTLS can't solve. You'll need puppeteer-extra with stealth plugin for this.
194
+ - **`NoProxyAvailableError`** — all proxies are banned and `forceProxy` is not enabled, or the proxy list is empty.
195
+ - **`MaxRetriesExceededError`** — all retry attempts failed. Contains `.attempts` count and `.lastError` with the final error details.
196
+
197
+ ```ts
198
+ import {
199
+ CloudflareJSChallengeError,
200
+ NoProxyAvailableError,
201
+ MaxRetriesExceededError,
202
+ } from 'ghostfetch';
203
+
204
+ try {
205
+ const res = await client.get('https://example.com');
206
+ } catch (err) {
207
+ if (err instanceof CloudflareJSChallengeError) {
208
+ // Needs headless browser (puppeteer-extra with stealth plugin)
209
+ }
210
+ if (err instanceof NoProxyAvailableError) {
211
+ // All proxies banned or list empty
212
+ }
213
+ if (err instanceof MaxRetriesExceededError) {
214
+ console.log(err.attempts, err.lastError);
215
+ }
216
+ }
217
+ ```
218
+
219
+ ## License
220
+
221
+ MIT
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyError = classifyError;
4
+ exports.isCloudflareChallenge = isCloudflareChallenge;
5
+ exports.checkInterceptors = checkInterceptors;
6
+ exports.checkDefaultRetryStatus = checkDefaultRetryStatus;
7
+ /** Error codes that are definitely proxy/network failures — request never reached the server. */
8
+ const PROXY_ERROR_CODES = new Set([
9
+ 'ECONNREFUSED',
10
+ 'ENOTFOUND',
11
+ 'EAI_AGAIN',
12
+ 'EHOSTUNREACH',
13
+ 'ENETUNREACH',
14
+ 'EPIPE',
15
+ ]);
16
+ /** Error codes that could be proxy OR server — we can't tell for sure. */
17
+ const AMBIGUOUS_ERROR_CODES = new Set([
18
+ 'ETIMEDOUT',
19
+ 'ECONNRESET',
20
+ 'ECONNABORTED',
21
+ ]);
22
+ const PROXY_ERROR_KEYWORDS = [
23
+ 'proxy',
24
+ 'tunnel',
25
+ 'connect econnrefused',
26
+ ];
27
+ const AMBIGUOUS_ERROR_KEYWORDS = [
28
+ 'socket hang up',
29
+ 'timeout',
30
+ ];
31
+ /** Cloudflare JS challenge detection patterns */
32
+ const CF_CHALLENGE_PATTERNS = [
33
+ 'cf-browser-verification',
34
+ 'cf_chl_opt',
35
+ 'jschl_vc',
36
+ 'jschl_answer',
37
+ 'Checking your browser',
38
+ 'Just a moment...',
39
+ '_cf_chl_tk',
40
+ ];
41
+ /**
42
+ * Default status codes that should trigger retry.
43
+ * - 'server': retry with different proxy, proxy is not penalized
44
+ * - 'proxy': retry with different proxy, proxy fail count incremented
45
+ */
46
+ const DEFAULT_RETRY_STATUSES = {
47
+ 429: 'server', // Rate limit — not proxy's fault, just retry with different IP
48
+ 503: 'server', // Service unavailable — server overloaded
49
+ 407: 'proxy', // Proxy authentication required — proxy is broken
50
+ };
51
+ /**
52
+ * Classify an error as proxy, server, or ambiguous.
53
+ *
54
+ * - proxy: request definitely never reached the server (DNS fail, connection refused, etc.)
55
+ * - server: an HTTP response was received — the proxy worked fine
56
+ * - ambiguous: could be either (timeout, connection reset) — proxy should NOT be penalized
57
+ */
58
+ function classifyError(error) {
59
+ if (error && typeof error === 'object') {
60
+ const err = error;
61
+ // If there's an HTTP status code, the request reached the server → server error
62
+ if (err.status && typeof err.status === 'number') {
63
+ return 'server';
64
+ }
65
+ const code = (err.code || err.errno);
66
+ const message = (err.message || '').toLowerCase();
67
+ // Check definite proxy errors first
68
+ if (code && PROXY_ERROR_CODES.has(code)) {
69
+ return 'proxy';
70
+ }
71
+ if (PROXY_ERROR_KEYWORDS.some((kw) => message.includes(kw))) {
72
+ return 'proxy';
73
+ }
74
+ // Check ambiguous errors
75
+ if (code && AMBIGUOUS_ERROR_CODES.has(code)) {
76
+ return 'ambiguous';
77
+ }
78
+ if (AMBIGUOUS_ERROR_KEYWORDS.some((kw) => message.includes(kw))) {
79
+ return 'ambiguous';
80
+ }
81
+ }
82
+ // Default: treat unknown errors as server errors (keep proxies alive)
83
+ return 'server';
84
+ }
85
+ /**
86
+ * Check if a successful response is actually a Cloudflare JS challenge.
87
+ */
88
+ function isCloudflareChallenge(response) {
89
+ if (response.status === 403 || response.status === 503) {
90
+ return CF_CHALLENGE_PATTERNS.some((pattern) => response.body.includes(pattern));
91
+ }
92
+ return false;
93
+ }
94
+ /**
95
+ * Run interceptors against a response.
96
+ *
97
+ * First interceptor whose `match` returns true takes ownership.
98
+ * Its `check` result determines the action. Default status handling
99
+ * is bypassed whenever an interceptor matches (even if check returns null).
100
+ */
101
+ function checkInterceptors(url, response, interceptors) {
102
+ for (const interceptor of interceptors) {
103
+ if (!interceptor.match(url))
104
+ continue;
105
+ const action = interceptor.check(response);
106
+ return { matched: true, action, interceptor };
107
+ }
108
+ return { matched: false, action: null };
109
+ }
110
+ /**
111
+ * Check if a response status code should trigger a default retry.
112
+ * Only called when no interceptor matched the URL.
113
+ * Returns the error type if retry should happen, or null if response is fine.
114
+ */
115
+ function checkDefaultRetryStatus(status) {
116
+ return DEFAULT_RETRY_STATUSES[status] ?? null;
117
+ }
118
+ //# sourceMappingURL=classifier.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classifier.js","sourceRoot":"","sources":["../../src/classifier.ts"],"names":[],"mappings":"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"}