ebay-mcp-remote-edition 4.6.0 → 4.7.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 +86 -4
- package/build/auth/multi-user-store.js +78 -13
- package/build/auth/oauth.js +26 -11
- package/build/captcha/captcha.js +302 -0
- package/build/config/environment.js +1 -1
- package/build/scripts/bootstrap-ebay-research-session.js +43 -2
- package/build/server-http.js +417 -29
- package/build/validation/providers/ebay-research-session-alerts.js +28 -14
- package/build/validation/providers/ebay-research.js +414 -25
- package/build/validation/providers/ebay-sold.js +53 -11
- package/build/validation/providers/ebay.js +6 -6
- package/build/validation/providers/terapeak.js +146 -5
- package/build/validation/recommendation.js +22 -5
- package/build/validation/run-validation.js +32 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ This is an open-source project provided "as is" without warranty of any kind. No
|
|
|
54
54
|
| Mode | Command | Transport | Best for | Authorization model |
|
|
55
55
|
|------|---------|-----------|----------|---------------------|
|
|
56
56
|
| **Local STDIO** | `pnpm start` / `pnpm run dev` | stdin/stdout | Single-user local AI client (Claude Desktop, Cline, Cursor, etc.) | The local process reads eBay credentials and optional `EBAY_USER_REFRESH_TOKEN` from environment variables. |
|
|
57
|
-
| **Hosted HTTP** | `pnpm run start:http` / `pnpm run dev:http` | Streamable HTTP | Multi-user server deployment; remote MCP clients | Users
|
|
57
|
+
| **Hosted HTTP** | `pnpm run start:http` / `pnpm run dev:http` | Streamable HTTP | Multi-user server deployment; remote MCP clients | Users authorize through browser OAuth. Requests can use normal session Bearer auth or opt into server-request auth with `X-Ebay-Server-Request: true`. |
|
|
58
58
|
|
|
59
59
|
Both modes use the same eBay tool registry. Local STDIO is best when one trusted local client owns the eBay credentials. Hosted HTTP runs an Express server with OAuth 2.1 discovery, environment-scoped route trees, server-side token/session storage, and admin-only operational endpoints.
|
|
60
60
|
|
|
@@ -278,9 +278,16 @@ QSTASH_CURRENT_SIGNING_KEY=
|
|
|
278
278
|
QSTASH_NEXT_SIGNING_KEY=
|
|
279
279
|
EBAY_RESEARCH_SESSION_ALERTS_ENABLED=true
|
|
280
280
|
EBAY_RESEARCH_SESSION_ALERT_CALLBACK_URL=
|
|
281
|
+
|
|
282
|
+
# eBay Research/Terapeak first-party session source.
|
|
283
|
+
# Set this explicitly in hosted deployments. If unset, research sessions default
|
|
284
|
+
# to Cloudflare KV even when EBAY_TOKEN_STORE_BACKEND=upstash-redis.
|
|
285
|
+
EBAY_RESEARCH_SESSION_STORE=upstash-redis # cloudflare_kv | upstash-redis | filesystem | none
|
|
286
|
+
EBAY_RESEARCH_SESSION_ALLOW_FILESYSTEM_FALLBACK=false
|
|
281
287
|
```
|
|
282
288
|
|
|
283
289
|
> `EBAY_TOKEN_STORE_BACKEND` defaults to Cloudflare KV when unset or unrecognized. Use `memory` only for tests or throwaway local development because hosted sessions and tokens are lost on restart.
|
|
290
|
+
> `EBAY_RESEARCH_SESSION_STORE` is separate from OAuth token storage. Set it to `upstash-redis` when Terapeak/eBay Research cookies are stored in Upstash.
|
|
284
291
|
|
|
285
292
|
### Secret file
|
|
286
293
|
|
|
@@ -321,7 +328,15 @@ GET /production/oauth/start # production browser login
|
|
|
321
328
|
|
|
322
329
|
If `OAUTH_START_KEY` is set, start URLs require either `?key=YOUR_KEY` or the `X-OAuth-Start-Key: YOUR_KEY` header. The server also includes this key as `key` in generated `authorization_url` values for unauthenticated MCP requests.
|
|
323
330
|
|
|
324
|
-
After login, the callback page shows
|
|
331
|
+
After login, the callback page shows three hosted auth options with copy buttons:
|
|
332
|
+
|
|
333
|
+
| Hosted auth mode | How to select it | Best for |
|
|
334
|
+
|------------------|------------------|----------|
|
|
335
|
+
| **User/session mode** | Send `Authorization: Bearer <session-token>` and omit `X-Ebay-Server-Request` | Normal OAuth-aware MCP clients and user-scoped desktop clients. |
|
|
336
|
+
| **Server request mode — identity headers** | Send `X-Ebay-Server-Request: true`, `X-Ebay-Client-Id`, `X-Ebay-User-Id`, and optional `X-Ebay-Environment` | Server/client setups that need to handle both regular user requests and backend server requests without copying a session token. |
|
|
337
|
+
| **Server request mode — bearer-capable clients** | Send `X-Ebay-Server-Request: true` plus `Authorization: Bearer <mcp-server-issued-bearer-token>` | MCP clients or automation platforms that can store an authorization header but should not use the admin key. |
|
|
338
|
+
|
|
339
|
+
Switching between modes is per request, not a server-wide environment toggle. The same hosted MCP server can handle user/session requests and server requests concurrently; the client chooses server mode by adding `X-Ebay-Server-Request: true`.
|
|
325
340
|
|
|
326
341
|
**Session TTL schedule:**
|
|
327
342
|
|
|
@@ -351,8 +366,12 @@ POST/GET/DELETE /mcp # resolves from ?env= or EBAY_ENVIRONMENT/EBAY_DEFAULT_EN
|
|
|
351
366
|
- `GET /mcp` without token → redirects to `oauth/start`
|
|
352
367
|
- `POST /mcp` without token → `401` JSON with `authorization_url`, `resource_metadata`, and a `WWW-Authenticate` Bearer challenge
|
|
353
368
|
- Normal user requests: `Authorization: Bearer <session-token>`
|
|
369
|
+
- Server requests with identity headers: `X-Ebay-Server-Request: true`, `X-Ebay-Client-Id: <client-id>`, `X-Ebay-User-Id: <user-id>`, `X-Ebay-Environment: sandbox|production`
|
|
370
|
+
- Server requests with bearer-capable clients: `X-Ebay-Server-Request: true` plus `Authorization: Bearer <mcp-server-issued-bearer-token>`
|
|
354
371
|
- Privileged admin bypass: `Authorization: Bearer <ADMIN_API_KEY>` when `ADMIN_API_KEY` is configured
|
|
355
372
|
|
|
373
|
+
The `X-Ebay-Server-Request` header is intentionally client-side and per-request. Leave it off for normal user/session OAuth calls. Add it when the MCP client is making a server-style request and should resolve the stored Redis/KV user token record by headers or by the MCP server-issued bearer lookup token. This bearer lookup token is generated by this MCP server on the OAuth callback page; it is **not** an eBay user access token, eBay refresh token, eBay client-credentials/app token, legacy hosted session token, or `ADMIN_API_KEY`.
|
|
374
|
+
|
|
356
375
|
#### Admin key bypass
|
|
357
376
|
|
|
358
377
|
`ADMIN_API_KEY` is used in two distinct ways:
|
|
@@ -380,6 +399,31 @@ POST /admin/session/:sessionToken/revoke # revoke session
|
|
|
380
399
|
DELETE /admin/session/:sessionToken # delete session
|
|
381
400
|
```
|
|
382
401
|
|
|
402
|
+
### Admin endpoints
|
|
403
|
+
|
|
404
|
+
All `/admin/*` routes accept authentication via either:
|
|
405
|
+
- Header: `X-Admin-API-Key: <ADMIN_API_KEY>`
|
|
406
|
+
- Query param: `?key=<ADMIN_API_KEY>` (useful for browser access)
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
GET /admin/token-status # OAuth + Playwright session health
|
|
410
|
+
POST /admin/oauth/start-for-validation # Start OAuth flow for validation runner
|
|
411
|
+
POST /admin/playwright-session # Store Playwright storage state JSON
|
|
412
|
+
GET /admin/playwright-capture # Browser UI to capture eBay Research cookies
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**`/admin/playwright-capture`** renders a self-service page for renewing the eBay Research Playwright session. Open it in a browser with the admin key as a query parameter:
|
|
416
|
+
|
|
417
|
+
```
|
|
418
|
+
https://your-server.com/admin/playwright-capture?key=YOUR_ADMIN_API_KEY
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
The page has two tabs:
|
|
422
|
+
1. **Auto-Capture** — loads eBay Research in an iframe (may be blocked by eBay's security policies)
|
|
423
|
+
2. **Manual Export** — provides step-by-step instructions to export cookies from Chrome DevTools, a bookmarklet for one-click cookie copying, and a text area to paste and submit the JSON
|
|
424
|
+
|
|
425
|
+
Submitted cookies are validated against the authenticated eBay Research ACTIVE endpoint before persistence, stored in the configured session backend (Upstash Redis / Cloudflare KV / filesystem), and written with a long KV TTL. Cookie expiry is tracked in metadata so the runtime can report missing/expired Terapeak auth separately from provider parsing failures.
|
|
426
|
+
|
|
383
427
|
### Validation endpoints
|
|
384
428
|
|
|
385
429
|
```
|
|
@@ -421,10 +465,48 @@ Cline auto-discovers OAuth, opens browser login, exchanges auth code, and stores
|
|
|
421
465
|
}
|
|
422
466
|
```
|
|
423
467
|
|
|
468
|
+
**Server request mode (custom headers):**
|
|
469
|
+
1. Open `https://your-server.com/sandbox/oauth/start` or `https://your-server.com/production/oauth/start`
|
|
470
|
+
2. Complete eBay login
|
|
471
|
+
3. Copy the server request headers shown on the callback page
|
|
472
|
+
4. Configure the MCP client with those headers:
|
|
473
|
+
```json
|
|
474
|
+
{
|
|
475
|
+
"mcpServers": {
|
|
476
|
+
"ebay-production-server": {
|
|
477
|
+
"url": "https://your-server.com/production/mcp",
|
|
478
|
+
"headers": {
|
|
479
|
+
"X-Ebay-Server-Request": "true",
|
|
480
|
+
"X-Ebay-Client-Id": "YOUR_EBAY_CLIENT_ID",
|
|
481
|
+
"X-Ebay-User-Id": "STORED_USER_ID_FROM_CALLBACK",
|
|
482
|
+
"X-Ebay-Environment": "production"
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
**Server request mode (bearer-capable clients):**
|
|
490
|
+
Use this when the MCP client can set `Authorization` but cannot easily send several custom identity headers:
|
|
491
|
+
```json
|
|
492
|
+
{
|
|
493
|
+
"mcpServers": {
|
|
494
|
+
"ebay-production-server": {
|
|
495
|
+
"url": "https://your-server.com/production/mcp",
|
|
496
|
+
"headers": {
|
|
497
|
+
"X-Ebay-Server-Request": "true",
|
|
498
|
+
"Authorization": "Bearer MCP_SERVER_ISSUED_BEARER_TOKEN_FROM_CALLBACK"
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
424
505
|
**Make / Zapier / other platforms:**
|
|
425
506
|
1. Complete OAuth via browser at `/oauth/start`
|
|
426
|
-
2.
|
|
427
|
-
3.
|
|
507
|
+
2. If the platform supports multiple headers, use server request mode identity headers
|
|
508
|
+
3. If the platform only supports one auth header, use server request mode with the MCP server-issued bearer lookup token from the callback page
|
|
509
|
+
4. Set MCP URL to `https://your-server.com/sandbox/mcp`
|
|
428
510
|
|
|
429
511
|
---
|
|
430
512
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from 'crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
2
|
import { createKVStore } from '../auth/kv-store.js';
|
|
3
3
|
// ── TTL constants (seconds) ────────────────────────────────────────────────
|
|
4
4
|
/** 15 minutes — matches the eBay OAuth state parameter lifetime. */
|
|
@@ -24,6 +24,22 @@ const DEFAULT_REFRESH_TOKEN_TTL_S = 18 * 30 * 24 * 60 * 60;
|
|
|
24
24
|
function secondsFromNow(ttlSeconds) {
|
|
25
25
|
return new Date(Date.now() + ttlSeconds * 1_000).toISOString();
|
|
26
26
|
}
|
|
27
|
+
function sha256(value) {
|
|
28
|
+
return createHash('sha256').update(value).digest('hex');
|
|
29
|
+
}
|
|
30
|
+
function getTokenTtlSeconds(tokenData) {
|
|
31
|
+
let ttlSeconds = DEFAULT_REFRESH_TOKEN_TTL_S;
|
|
32
|
+
if (tokenData.userRefreshTokenExpiry) {
|
|
33
|
+
const expiryMs = typeof tokenData.userRefreshTokenExpiry === 'number'
|
|
34
|
+
? tokenData.userRefreshTokenExpiry
|
|
35
|
+
: new Date(tokenData.userRefreshTokenExpiry).getTime();
|
|
36
|
+
const remaining = Math.floor((expiryMs - Date.now()) / 1_000);
|
|
37
|
+
if (remaining > 0) {
|
|
38
|
+
ttlSeconds = remaining;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return ttlSeconds;
|
|
42
|
+
}
|
|
27
43
|
export class MultiUserAuthStore {
|
|
28
44
|
kv;
|
|
29
45
|
/**
|
|
@@ -51,10 +67,16 @@ export class MultiUserAuthStore {
|
|
|
51
67
|
userTokenKey(userId, environment) {
|
|
52
68
|
return `user:${userId}:env:${environment}:tokens`;
|
|
53
69
|
}
|
|
70
|
+
userTokenIndexKey(clientId, userId, environment) {
|
|
71
|
+
return `user_index:env:${environment}:client:${sha256(clientId)}:user:${userId}`;
|
|
72
|
+
}
|
|
73
|
+
serverBearerKey(token) {
|
|
74
|
+
return `server_bearer:${sha256(token)}`;
|
|
75
|
+
}
|
|
54
76
|
sessionKey(sessionToken) {
|
|
55
77
|
return `session:${sessionToken}`;
|
|
56
78
|
}
|
|
57
|
-
async createOAuthState(environment, returnTo, mcpContext) {
|
|
79
|
+
async createOAuthState(environment, returnTo, mcpContext, targetUserId) {
|
|
58
80
|
const state = randomUUID();
|
|
59
81
|
const record = {
|
|
60
82
|
state,
|
|
@@ -63,6 +85,7 @@ export class MultiUserAuthStore {
|
|
|
63
85
|
expiresAt: secondsFromNow(OAUTH_STATE_TTL_S),
|
|
64
86
|
returnTo,
|
|
65
87
|
...mcpContext,
|
|
88
|
+
...(targetUserId ? { targetUserId } : {}),
|
|
66
89
|
};
|
|
67
90
|
await this.kv.put(this.stateKey(state), record, OAUTH_STATE_TTL_S);
|
|
68
91
|
return record;
|
|
@@ -83,28 +106,70 @@ export class MultiUserAuthStore {
|
|
|
83
106
|
}
|
|
84
107
|
async saveUserTokens(userId, environment, tokenData) {
|
|
85
108
|
// Derive TTL from refresh token expiry when available; fall back to 18 months.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const expiryMs = typeof tokenData.userRefreshTokenExpiry === 'number'
|
|
89
|
-
? tokenData.userRefreshTokenExpiry
|
|
90
|
-
: new Date(tokenData.userRefreshTokenExpiry).getTime();
|
|
91
|
-
const remaining = Math.floor((expiryMs - Date.now()) / 1_000);
|
|
92
|
-
if (remaining > 0) {
|
|
93
|
-
ttlSeconds = remaining;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
109
|
+
const ttlSeconds = getTokenTtlSeconds(tokenData);
|
|
110
|
+
const now = new Date().toISOString();
|
|
96
111
|
const record = {
|
|
97
112
|
userId,
|
|
98
113
|
environment,
|
|
99
114
|
tokenData,
|
|
100
|
-
updatedAt:
|
|
115
|
+
updatedAt: now,
|
|
101
116
|
expiresAt: secondsFromNow(ttlSeconds),
|
|
102
117
|
};
|
|
103
118
|
await this.kv.put(this.userTokenKey(userId, environment), record, ttlSeconds);
|
|
119
|
+
if (tokenData.clientId) {
|
|
120
|
+
const indexRecord = {
|
|
121
|
+
userId,
|
|
122
|
+
environment,
|
|
123
|
+
clientIdHash: sha256(tokenData.clientId),
|
|
124
|
+
createdAt: now,
|
|
125
|
+
updatedAt: now,
|
|
126
|
+
expiresAt: record.expiresAt,
|
|
127
|
+
};
|
|
128
|
+
await this.kv.put(this.userTokenIndexKey(tokenData.clientId, userId, environment), indexRecord, ttlSeconds);
|
|
129
|
+
}
|
|
104
130
|
}
|
|
105
131
|
async getUserTokens(userId, environment) {
|
|
106
132
|
return await this.kv.get(this.userTokenKey(userId, environment));
|
|
107
133
|
}
|
|
134
|
+
async getUserTokensByClientUser(clientId, userId, environment) {
|
|
135
|
+
const index = await this.kv.get(this.userTokenIndexKey(clientId, userId, environment));
|
|
136
|
+
if (index?.userId !== userId || index.environment !== environment) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const record = await this.getUserTokens(index.userId, index.environment);
|
|
140
|
+
if (record?.tokenData.clientId !== clientId) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return record;
|
|
144
|
+
}
|
|
145
|
+
async createServerBearerToken(userId, environment) {
|
|
146
|
+
const record = await this.getUserTokens(userId, environment);
|
|
147
|
+
if (!record?.tokenData) {
|
|
148
|
+
throw new Error(`Cannot create server bearer token without stored user tokens for ${userId}`);
|
|
149
|
+
}
|
|
150
|
+
const ttlSeconds = getTokenTtlSeconds(record.tokenData);
|
|
151
|
+
const token = `ebay_mcp_${randomUUID()}${randomUUID()}`;
|
|
152
|
+
const tokenHash = sha256(token);
|
|
153
|
+
const bearerRecord = {
|
|
154
|
+
tokenHash,
|
|
155
|
+
userId,
|
|
156
|
+
environment,
|
|
157
|
+
createdAt: new Date().toISOString(),
|
|
158
|
+
expiresAt: secondsFromNow(ttlSeconds),
|
|
159
|
+
};
|
|
160
|
+
await this.kv.put(this.serverBearerKey(token), bearerRecord, ttlSeconds);
|
|
161
|
+
return {
|
|
162
|
+
...bearerRecord,
|
|
163
|
+
token,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async getUserTokensByServerBearerToken(token) {
|
|
167
|
+
const bearerRecord = await this.kv.get(this.serverBearerKey(token));
|
|
168
|
+
if (!bearerRecord) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return await this.getUserTokens(bearerRecord.userId, bearerRecord.environment);
|
|
172
|
+
}
|
|
108
173
|
async createSession(userId, environment) {
|
|
109
174
|
const sessionToken = randomUUID() + randomUUID();
|
|
110
175
|
const now = new Date().toISOString();
|
package/build/auth/oauth.js
CHANGED
|
@@ -16,6 +16,18 @@ export class EbayOAuthClient {
|
|
|
16
16
|
getTokenEndpoint() {
|
|
17
17
|
return `${getOAuthTokenBaseUrl(this.config.environment)}/identity/v1/oauth2/token`;
|
|
18
18
|
}
|
|
19
|
+
getEffectiveClientCredentials() {
|
|
20
|
+
return {
|
|
21
|
+
clientId: this.userTokens?.clientId || this.config.clientId,
|
|
22
|
+
clientSecret: this.userTokens?.clientSecret || this.config.clientSecret,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
getEffectiveRedirectUri() {
|
|
26
|
+
return (this.userTokens?.ruName ||
|
|
27
|
+
this.userTokens?.redirectUri ||
|
|
28
|
+
this.config.ruName ||
|
|
29
|
+
this.config.redirectUri);
|
|
30
|
+
}
|
|
19
31
|
async initialize() {
|
|
20
32
|
if (this.context?.userId && this.context.environment) {
|
|
21
33
|
const stored = await this.authStore.getUserTokens(this.context.userId, this.context.environment);
|
|
@@ -115,7 +127,8 @@ export class EbayOAuthClient {
|
|
|
115
127
|
return this.appAccessToken;
|
|
116
128
|
}
|
|
117
129
|
const authUrl = this.getTokenEndpoint();
|
|
118
|
-
const
|
|
130
|
+
const effectiveCredentials = this.getEffectiveClientCredentials();
|
|
131
|
+
const credentials = Buffer.from(`${effectiveCredentials.clientId}:${effectiveCredentials.clientSecret}`).toString('base64');
|
|
119
132
|
const response = await axios.post(authUrl, new URLSearchParams({
|
|
120
133
|
grant_type: 'client_credentials',
|
|
121
134
|
scope: 'https://api.ebay.com/oauth/api_scope',
|
|
@@ -130,12 +143,13 @@ export class EbayOAuthClient {
|
|
|
130
143
|
return this.appAccessToken;
|
|
131
144
|
}
|
|
132
145
|
async exchangeCodeForToken(code) {
|
|
133
|
-
const redirectUriForExchange = this.
|
|
146
|
+
const redirectUriForExchange = this.getEffectiveRedirectUri();
|
|
134
147
|
if (!redirectUriForExchange) {
|
|
135
148
|
throw new Error('RuName (EBAY_RUNAME) or redirectUri is required for authorization code exchange');
|
|
136
149
|
}
|
|
137
150
|
const tokenUrl = this.getTokenEndpoint();
|
|
138
|
-
const
|
|
151
|
+
const effectiveCredentials = this.getEffectiveClientCredentials();
|
|
152
|
+
const credentials = Buffer.from(`${effectiveCredentials.clientId}:${effectiveCredentials.clientSecret}`).toString('base64');
|
|
139
153
|
try {
|
|
140
154
|
const response = await axios.post(tokenUrl, new URLSearchParams({
|
|
141
155
|
grant_type: 'authorization_code',
|
|
@@ -150,10 +164,10 @@ export class EbayOAuthClient {
|
|
|
150
164
|
const tokenData = response.data;
|
|
151
165
|
const now = Date.now();
|
|
152
166
|
this.userTokens = {
|
|
153
|
-
clientId:
|
|
154
|
-
clientSecret:
|
|
167
|
+
clientId: effectiveCredentials.clientId,
|
|
168
|
+
clientSecret: effectiveCredentials.clientSecret,
|
|
155
169
|
redirectUri: this.config.redirectUri,
|
|
156
|
-
ruName:
|
|
170
|
+
ruName: redirectUriForExchange,
|
|
157
171
|
userAccessToken: tokenData.access_token,
|
|
158
172
|
userRefreshToken: tokenData.refresh_token,
|
|
159
173
|
tokenType: tokenData.token_type,
|
|
@@ -183,7 +197,8 @@ export class EbayOAuthClient {
|
|
|
183
197
|
throw new Error('No user tokens available to refresh');
|
|
184
198
|
}
|
|
185
199
|
const authUrl = this.getTokenEndpoint();
|
|
186
|
-
const
|
|
200
|
+
const effectiveCredentials = this.getEffectiveClientCredentials();
|
|
201
|
+
const credentials = Buffer.from(`${effectiveCredentials.clientId}:${effectiveCredentials.clientSecret}`).toString('base64');
|
|
187
202
|
const response = await axios.post(authUrl, new URLSearchParams({
|
|
188
203
|
grant_type: 'refresh_token',
|
|
189
204
|
refresh_token: this.userTokens.userRefreshToken,
|
|
@@ -196,10 +211,10 @@ export class EbayOAuthClient {
|
|
|
196
211
|
const tokenData = response.data;
|
|
197
212
|
const now = Date.now();
|
|
198
213
|
this.userTokens = {
|
|
199
|
-
clientId:
|
|
200
|
-
clientSecret:
|
|
201
|
-
redirectUri: this.config.redirectUri,
|
|
202
|
-
ruName: this.config.ruName,
|
|
214
|
+
clientId: effectiveCredentials.clientId,
|
|
215
|
+
clientSecret: effectiveCredentials.clientSecret,
|
|
216
|
+
redirectUri: this.userTokens.redirectUri || this.config.redirectUri,
|
|
217
|
+
ruName: this.userTokens.ruName || this.config.ruName,
|
|
203
218
|
userAccessToken: tokenData.access_token,
|
|
204
219
|
userRefreshToken: tokenData.refresh_token || this.userTokens.userRefreshToken,
|
|
205
220
|
tokenType: tokenData.token_type,
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createLogger } from '../utils/logger.js';
|
|
2
|
+
const logger = createLogger('captcha');
|
|
3
|
+
function makeCaptchaError(provider, message, code) {
|
|
4
|
+
const error = new Error(message);
|
|
5
|
+
error.provider = provider;
|
|
6
|
+
error.code = code;
|
|
7
|
+
return error;
|
|
8
|
+
}
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// 2Captcha client
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const TWOCAPTCHA_API_URL = 'https://2captcha.com';
|
|
13
|
+
class TwoCaptchaClient {
|
|
14
|
+
name = 'twocaptcha';
|
|
15
|
+
apiKey;
|
|
16
|
+
constructor(apiKey) {
|
|
17
|
+
this.apiKey = apiKey;
|
|
18
|
+
}
|
|
19
|
+
captchaTypeToMethod(type) {
|
|
20
|
+
switch (type) {
|
|
21
|
+
case 'hcaptcha':
|
|
22
|
+
return 'hcaptcha';
|
|
23
|
+
case 'recaptcha_v2':
|
|
24
|
+
return 'usercaptcha';
|
|
25
|
+
case 'recaptcha_v3':
|
|
26
|
+
return 'userrecaptcha';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
buildFormData(payload) {
|
|
30
|
+
const params = new URLSearchParams();
|
|
31
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
32
|
+
if (value !== undefined) {
|
|
33
|
+
params.append(key, value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return params;
|
|
37
|
+
}
|
|
38
|
+
async createTask(config) {
|
|
39
|
+
const method = this.captchaTypeToMethod(config.type);
|
|
40
|
+
const payload = {
|
|
41
|
+
key: this.apiKey,
|
|
42
|
+
method,
|
|
43
|
+
sitekey: config.siteKey,
|
|
44
|
+
pageurl: config.pageUrl,
|
|
45
|
+
proxy: config.proxy,
|
|
46
|
+
proxytype: config.proxy ? 'http' : undefined,
|
|
47
|
+
json: '1', // Force JSON response format
|
|
48
|
+
};
|
|
49
|
+
const formData = this.buildFormData(payload);
|
|
50
|
+
const response = await fetch(`${TWOCAPTCHA_API_URL}/in.php`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
53
|
+
body: formData,
|
|
54
|
+
});
|
|
55
|
+
// 2Captcha may return either JSON or plaintext OK|taskId format
|
|
56
|
+
const text = await response.text();
|
|
57
|
+
let data;
|
|
58
|
+
if (text.startsWith('{')) {
|
|
59
|
+
data = JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Parse plaintext OK|taskId or ERROR|message
|
|
63
|
+
const parts = text.split('|');
|
|
64
|
+
if (parts[0] === 'OK' && parts[1]) {
|
|
65
|
+
return { taskId: parts[1] };
|
|
66
|
+
}
|
|
67
|
+
data = { error_text: parts[1] ?? text, error_id: -1 };
|
|
68
|
+
}
|
|
69
|
+
if (data.status !== 1 || !data.request) {
|
|
70
|
+
const errorMessage = data.error_text ?? `2Captcha error_id: ${data.error_id}`;
|
|
71
|
+
throw makeCaptchaError(this.name, `2Captcha createTask failed: ${errorMessage}`, String(data.error_id));
|
|
72
|
+
}
|
|
73
|
+
return { taskId: data.request };
|
|
74
|
+
}
|
|
75
|
+
async getResult(taskId) {
|
|
76
|
+
const payload = {
|
|
77
|
+
key: this.apiKey,
|
|
78
|
+
action: 'get',
|
|
79
|
+
json: '1', // Force JSON response format
|
|
80
|
+
id: taskId,
|
|
81
|
+
};
|
|
82
|
+
const formData = this.buildFormData(payload);
|
|
83
|
+
const response = await fetch(`${TWOCAPTCHA_API_URL}/res.php`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
86
|
+
body: formData,
|
|
87
|
+
});
|
|
88
|
+
// Handle both JSON and plaintext formats
|
|
89
|
+
const text = await response.text();
|
|
90
|
+
let data;
|
|
91
|
+
if (text.startsWith('{')) {
|
|
92
|
+
data = JSON.parse(text);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// Parse plaintext OK|solution or CAPCHA_NOT_READY
|
|
96
|
+
const parts = text.split('|');
|
|
97
|
+
if (parts[0] === 'OK' && parts[1]) {
|
|
98
|
+
return { token: parts[1], provider: this.name };
|
|
99
|
+
}
|
|
100
|
+
// CAPCHA_NOT_READY or other error
|
|
101
|
+
data = { error_text: parts[0] ?? text, error_id: 1 };
|
|
102
|
+
}
|
|
103
|
+
if (data.error_text && data.error_id !== 0) {
|
|
104
|
+
throw new Error(`2Captcha getResult error: ${data.error_text}`);
|
|
105
|
+
}
|
|
106
|
+
if (data.status === 1 && data.request) {
|
|
107
|
+
return { token: data.request, provider: this.name };
|
|
108
|
+
}
|
|
109
|
+
// Still processing (CAPCHA_NOT_READY)
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Solver factory
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
function resolveProvider() {
|
|
117
|
+
const twoCaptchaKey = process.env.TWOCAPTCHA_API_KEY;
|
|
118
|
+
if (twoCaptchaKey) {
|
|
119
|
+
logger.info('2Captcha client initialized');
|
|
120
|
+
return new TwoCaptchaClient(twoCaptchaKey);
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Public API
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
const DEFAULT_POLL_INTERVAL_MS = 3_000;
|
|
128
|
+
const DEFAULT_MAX_POLL_MS = 60_000;
|
|
129
|
+
export async function solveCaptcha(config, options = {}) {
|
|
130
|
+
const provider = resolveProvider();
|
|
131
|
+
if (!provider) {
|
|
132
|
+
throw new Error('No captcha solver configured. Set TWOCAPTCHA_API_KEY environment variable.');
|
|
133
|
+
}
|
|
134
|
+
const pollInterval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
135
|
+
const maxWait = options.maxWaitMs ?? DEFAULT_MAX_POLL_MS;
|
|
136
|
+
logger.info(`Attempting captcha solve via ${provider.name} (type=${config.type})`);
|
|
137
|
+
try {
|
|
138
|
+
const { taskId } = await provider.createTask(config);
|
|
139
|
+
logger.debug(`Task created: ${taskId} on ${provider.name}`);
|
|
140
|
+
const deadline = Date.now() + maxWait;
|
|
141
|
+
while (Date.now() < deadline) {
|
|
142
|
+
await new Promise((resolve) => {
|
|
143
|
+
setTimeout(() => resolve(), pollInterval);
|
|
144
|
+
});
|
|
145
|
+
const solution = await provider.getResult(taskId);
|
|
146
|
+
if (solution) {
|
|
147
|
+
logger.info(`Captcha solved via ${provider.name}`);
|
|
148
|
+
return solution;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const timeoutError = makeCaptchaError(provider.name, `${provider.name} timed out after ${maxWait}ms`);
|
|
152
|
+
throw timeoutError;
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
let providerError;
|
|
156
|
+
if (err instanceof Error && 'provider' in err) {
|
|
157
|
+
providerError = err;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
providerError = makeCaptchaError(provider.name, err instanceof Error ? err.message : String(err));
|
|
161
|
+
}
|
|
162
|
+
logger.warn(`${provider.name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
throw providerError;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Inject the captcha solution token into a Playwright page.
|
|
168
|
+
*/
|
|
169
|
+
export async function injectCaptchaToken(page, type, token) {
|
|
170
|
+
if (type === 'hcaptcha') {
|
|
171
|
+
await page.evaluate((t) => {
|
|
172
|
+
const textarea = document.querySelector('textarea[name="h-captcha-response"]');
|
|
173
|
+
if (textarea) {
|
|
174
|
+
textarea.value = t;
|
|
175
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
176
|
+
}
|
|
177
|
+
}, token);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
await page.evaluate((t) => {
|
|
181
|
+
const iframe = document.querySelector('iframe[title="reCAPTCHA"], iframe[src*="recaptcha"]');
|
|
182
|
+
if (iframe) {
|
|
183
|
+
const iframeDoc = iframe.contentDocument;
|
|
184
|
+
if (iframeDoc) {
|
|
185
|
+
const textarea = iframeDoc.querySelector('textarea[name="g-recaptcha-response"]');
|
|
186
|
+
if (textarea) {
|
|
187
|
+
textarea.value = t;
|
|
188
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}, token);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Check if a captcha challenge is present on a Playwright page.
|
|
197
|
+
*/
|
|
198
|
+
export async function detectCaptcha(page) {
|
|
199
|
+
return await page.evaluate(() => {
|
|
200
|
+
const hcaptchaIframe = document.querySelector('iframe[src*="hcaptcha.com"]');
|
|
201
|
+
if (hcaptchaIframe)
|
|
202
|
+
return 'hcaptcha';
|
|
203
|
+
const hcaptchaWidget = document.querySelector('.hcaptcha');
|
|
204
|
+
if (hcaptchaWidget)
|
|
205
|
+
return 'hcaptcha';
|
|
206
|
+
const recaptchaIframe = document.querySelector('iframe[src*="recaptcha.net"], iframe[src*="recaptcha"]');
|
|
207
|
+
if (recaptchaIframe) {
|
|
208
|
+
const src = recaptchaIframe.src || '';
|
|
209
|
+
if (src.includes('render=explicit'))
|
|
210
|
+
return 'recaptcha_v3';
|
|
211
|
+
return 'recaptcha_v2';
|
|
212
|
+
}
|
|
213
|
+
const recaptchaWidget = document.querySelector('.g-recaptcha');
|
|
214
|
+
if (recaptchaWidget)
|
|
215
|
+
return 'recaptcha_v2';
|
|
216
|
+
return null;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Extract the captcha site key from the page.
|
|
221
|
+
*/
|
|
222
|
+
export async function extractSiteKey(page, type) {
|
|
223
|
+
return await page.evaluate((ct) => {
|
|
224
|
+
const captchaType = ct;
|
|
225
|
+
if (captchaType === 'hcaptcha') {
|
|
226
|
+
const hcaptchaEl = document.querySelector('.hcaptcha[data-sitekey]');
|
|
227
|
+
if (hcaptchaEl) {
|
|
228
|
+
return hcaptchaEl.getAttribute('data-sitekey');
|
|
229
|
+
}
|
|
230
|
+
const hcaptchaIframe = document.querySelector('iframe[src*="hcaptcha.com"]');
|
|
231
|
+
if (hcaptchaIframe) {
|
|
232
|
+
const src = hcaptchaIframe.src || '';
|
|
233
|
+
const match = /sitekey=([^&]+)/.exec(src);
|
|
234
|
+
if (match)
|
|
235
|
+
return match[1];
|
|
236
|
+
}
|
|
237
|
+
const scripts = document.querySelectorAll('script');
|
|
238
|
+
for (const script of scripts) {
|
|
239
|
+
const text = script.textContent || '';
|
|
240
|
+
const match = /HCaptcha\.render\([^,]+,\s*['"]([^'"]+)['"]/.exec(text);
|
|
241
|
+
if (match)
|
|
242
|
+
return match[1];
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const recaptchaEl = document.querySelector('.g-recaptcha[data-sitekey]');
|
|
248
|
+
if (recaptchaEl) {
|
|
249
|
+
return recaptchaEl.getAttribute('data-sitekey');
|
|
250
|
+
}
|
|
251
|
+
const recaptchaIframe = document.querySelector('iframe[src*="recaptcha"], iframe[src*="recaptcha.net"]');
|
|
252
|
+
if (recaptchaIframe) {
|
|
253
|
+
const src = recaptchaIframe.src || '';
|
|
254
|
+
const match = /k=([^&]+)/.exec(src);
|
|
255
|
+
if (match)
|
|
256
|
+
return match[1];
|
|
257
|
+
}
|
|
258
|
+
const scripts = document.querySelectorAll('script');
|
|
259
|
+
for (const script of scripts) {
|
|
260
|
+
const text = script.textContent || '';
|
|
261
|
+
const match = /grecaptcha\.render\([^,]+,\s*['"]([^'"]+)['"]/.exec(text);
|
|
262
|
+
if (match)
|
|
263
|
+
return match[1];
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}, type);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Helper: wait for captcha to appear on page, then solve and inject.
|
|
271
|
+
*/
|
|
272
|
+
export async function waitForAndSolveCaptcha(page, options) {
|
|
273
|
+
const maxWait = options.maxWaitMs ?? 30_000;
|
|
274
|
+
const checkInterval = options.checkIntervalMs ?? 1_000;
|
|
275
|
+
const deadline = Date.now() + maxWait;
|
|
276
|
+
while (Date.now() < deadline) {
|
|
277
|
+
const captchaType = await detectCaptcha(page);
|
|
278
|
+
if (captchaType) {
|
|
279
|
+
logger.info(`Detected ${captchaType} on ${options.pageUrl}`);
|
|
280
|
+
const siteKey = await extractSiteKey(page, captchaType);
|
|
281
|
+
if (!siteKey) {
|
|
282
|
+
logger.error('Captcha detected but site key could not be extracted');
|
|
283
|
+
throw new Error('Could not extract captcha site key from page');
|
|
284
|
+
}
|
|
285
|
+
logger.debug(`Extracted site key: ${siteKey}`);
|
|
286
|
+
const solution = await solveCaptcha({
|
|
287
|
+
type: captchaType,
|
|
288
|
+
siteKey,
|
|
289
|
+
pageUrl: options.pageUrl,
|
|
290
|
+
proxy: options.proxy,
|
|
291
|
+
});
|
|
292
|
+
await injectCaptchaToken(page, captchaType, solution.token);
|
|
293
|
+
logger.info(`Injected ${captchaType} solution token`);
|
|
294
|
+
return solution;
|
|
295
|
+
}
|
|
296
|
+
await new Promise((resolve) => {
|
|
297
|
+
setTimeout(() => resolve(), checkInterval);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
logger.debug('No captcha detected within timeout');
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
@@ -166,7 +166,7 @@ export function ruNameToEnvironment(ruName) {
|
|
|
166
166
|
*/
|
|
167
167
|
export function validateCredentialsForEnvironment(environment) {
|
|
168
168
|
const config = getEbayConfig(environment);
|
|
169
|
-
const ruName = config.redirectUri;
|
|
169
|
+
const ruName = config.ruName || config.redirectUri;
|
|
170
170
|
const detectedEnv = ruNameToEnvironment(ruName);
|
|
171
171
|
if (detectedEnv === null) {
|
|
172
172
|
// Can't tell from the RuName — treat as valid (no info to contradict it).
|