feat: ebay splashui challenge solver
argon2id pow → /challengesvc/answer → chlgref cookie warm homepage for akamai cookies, detect 307 redirect, solve + retry transparently in fetchEbayItems flow
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -32,6 +32,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"argon2-wasm-pro": "1.1.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"unidecode": "^1.1.0",
|
"unidecode": "^1.1.0",
|
||||||
@@ -120,6 +121,8 @@
|
|||||||
|
|
||||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"argon2-wasm-pro": ["argon2-wasm-pro@1.1.0", "", {}, "sha512-ApZAKEgbWQILckY+IdjrETB0oTC8L9YHT3JVQhdun77tilExkXNyM/T/qbkvX+Uv68+IQmVwewQwg6yJnSwVxQ=="],
|
||||||
|
|
||||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"argon2-wasm-pro": "1.1.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"unidecode": "^1.1.0"
|
"unidecode": "^1.1.0"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export * from "./types/common";
|
|||||||
// Export shared utilities
|
// Export shared utilities
|
||||||
export * from "./utils/cookies";
|
export * from "./utils/cookies";
|
||||||
export * from "./utils/delay";
|
export * from "./utils/delay";
|
||||||
|
export * from "./utils/ebay-challenge";
|
||||||
export * from "./utils/format";
|
export * from "./utils/format";
|
||||||
export * from "./utils/http";
|
export * from "./utils/http";
|
||||||
export * from "./utils/unstable";
|
export * from "./utils/unstable";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
formatCookiesForHeader,
|
formatCookiesForHeader,
|
||||||
} from "../utils/cookies";
|
} from "../utils/cookies";
|
||||||
import { delay } from "../utils/delay";
|
import { delay } from "../utils/delay";
|
||||||
|
import { solveEbayChallenge } from "../utils/ebay-challenge";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { classifyUnstableListings } from "../utils/unstable";
|
import { classifyUnstableListings } from "../utils/unstable";
|
||||||
|
|
||||||
@@ -611,10 +612,10 @@ function parseEbayListings(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------- Cookie Loading -----------------------------
|
// ----------------------------- Session & Challenge -----------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load eBay cookies from EBAY_COOKIE
|
* Load eBay cookies from EBAY_COOKIE env var
|
||||||
*/
|
*/
|
||||||
async function loadEbayCookies(): Promise<string | undefined> {
|
async function loadEbayCookies(): Promise<string | undefined> {
|
||||||
try {
|
try {
|
||||||
@@ -628,6 +629,92 @@ async function loadEbayCookies(): Promise<string | undefined> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EBAY_UA =
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visit eBay homepage to collect Akamai fingerprinting cookies.
|
||||||
|
* These are required to pass the edge layer before any search request.
|
||||||
|
*/
|
||||||
|
async function warmEbaySession(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://www.ebay.ca", {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": EBAY_UA,
|
||||||
|
Accept:
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||||
|
"Accept-Language": "en-CA,en-US;q=0.9,en;q=0.8",
|
||||||
|
},
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return undefined;
|
||||||
|
|
||||||
|
const setCookies = res.headers.getSetCookie?.() ?? [];
|
||||||
|
const jar: Record<string, string> = {};
|
||||||
|
for (const header of setCookies) {
|
||||||
|
const match = header.match(/^([^=]+)=([^;]+)/);
|
||||||
|
if (match?.[1] && match[2]) jar[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieKeys = Object.keys(jar);
|
||||||
|
if (cookieKeys.length === 0) return undefined;
|
||||||
|
|
||||||
|
return cookieKeys.map((k) => `${k}=${jar[k] ?? ""}`).join("; ");
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCookies(
|
||||||
|
base: string,
|
||||||
|
...additions: (string | undefined)[]
|
||||||
|
): string {
|
||||||
|
const jar: Record<string, string> = {};
|
||||||
|
const all = [base, ...additions.filter(Boolean)] as string[];
|
||||||
|
for (const str of all) {
|
||||||
|
for (const pair of str.split(";")) {
|
||||||
|
const eq = pair.indexOf("=");
|
||||||
|
if (eq > 0) {
|
||||||
|
jar[pair.substring(0, eq).trim()] = pair.substring(eq + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.entries(jar)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectResponseCookies(res: Response, jar: Record<string, string>) {
|
||||||
|
for (const header of res.headers.getSetCookie?.() ?? []) {
|
||||||
|
const match = header.match(/^([^=]+)=([^;]+)/);
|
||||||
|
if (match?.[1] && match[2]) jar[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookiesToString(jar: Record<string, string>): string {
|
||||||
|
return Object.entries(jar)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHALLENGE_REDIRECT = 307;
|
||||||
|
const CHALLENGE_MARKER = "splashui/challenge";
|
||||||
|
|
||||||
|
function isChallengeRedirect(res: Response): boolean {
|
||||||
|
return (
|
||||||
|
res.status === CHALLENGE_REDIRECT &&
|
||||||
|
(res.headers.get("location") ?? "").includes(CHALLENGE_MARKER)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChallengeHtml(html: string): boolean {
|
||||||
|
return (
|
||||||
|
html.length < 50000 &&
|
||||||
|
(html.includes("_crefId") || html.includes("_cdetail"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------- Main -----------------------------
|
// ----------------------------- Main -----------------------------
|
||||||
|
|
||||||
export default async function fetchEbayItems(
|
export default async function fetchEbayItems(
|
||||||
@@ -703,7 +790,10 @@ export default async function fetchEbayItems(
|
|||||||
return classifyUnstableListings(limitedListings);
|
return classifyUnstableListings(limitedListings);
|
||||||
};
|
};
|
||||||
|
|
||||||
const cookies = await loadEbayCookies();
|
// Collect cookies from env var + warm-up session
|
||||||
|
const envCookies = await loadEbayCookies();
|
||||||
|
const warmCookies = await warmEbaySession();
|
||||||
|
const baseCookies = mergeCookies(envCookies ?? "", warmCookies);
|
||||||
|
|
||||||
// Build eBay search URL - use Canadian site, Buy It Now filter, and Canada-only preference
|
// Build eBay search URL - use Canadian site, Buy It Now filter, and Canada-only preference
|
||||||
const urlParams = new URLSearchParams({
|
const urlParams = new URLSearchParams({
|
||||||
@@ -727,35 +817,113 @@ export default async function fetchEbayItems(
|
|||||||
logger.log(`Fetching eBay search: ${searchUrl}`);
|
logger.log(`Fetching eBay search: ${searchUrl}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use custom headers modeled after real browser requests to bypass bot detection
|
const searchHeaders: Record<string, string> = {
|
||||||
const headers: Record<string, string> = {
|
"User-Agent": EBAY_UA,
|
||||||
"User-Agent":
|
Accept:
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "en-CA,en-US;q=0.9,en;q=0.8",
|
"Accept-Language": "en-CA,en-US;q=0.9,en;q=0.8",
|
||||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
||||||
Referer: "https://www.ebay.ca/",
|
Referer: "https://www.ebay.ca/",
|
||||||
Connection: "keep-alive",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
Pragma: "no-cache",
|
|
||||||
"Upgrade-Insecure-Requests": "1",
|
|
||||||
"Sec-Fetch-Dest": "document",
|
|
||||||
"Sec-Fetch-Mode": "navigate",
|
|
||||||
"Sec-Fetch-Site": "same-origin",
|
|
||||||
"Sec-Fetch-User": "?1",
|
|
||||||
Priority: "u=0, i",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add cookies if available (helps bypass bot detection)
|
if (baseCookies) {
|
||||||
if (cookies) {
|
searchHeaders.Cookie = baseCookies;
|
||||||
headers.Cookie = cookies;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(searchUrl, {
|
// Step 1: Make search request (follow redirects for challenge flow)
|
||||||
|
let res = await fetch(searchUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers: searchHeaders,
|
||||||
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cookieJar: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Collect cookies from homepage warm-up
|
||||||
|
if (baseCookies) {
|
||||||
|
for (const pair of baseCookies.split(";")) {
|
||||||
|
const eq = pair.indexOf("=");
|
||||||
|
if (eq > 0) {
|
||||||
|
cookieJar[pair.substring(0, eq).trim()] = pair
|
||||||
|
.substring(eq + 1)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Follow challenge redirect if present
|
||||||
|
if (isChallengeRedirect(res)) {
|
||||||
|
const chalUrl = res.headers.get("location") ?? "";
|
||||||
|
collectResponseCookies(res, cookieJar);
|
||||||
|
|
||||||
|
logger.log("Challenge detected, fetching challenge page...");
|
||||||
|
res = await fetch(chalUrl, {
|
||||||
|
headers: { ...searchHeaders, Cookie: cookiesToString(cookieJar) },
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
collectResponseCookies(res, cookieJar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: If response is challenge HTML, solve and submit
|
||||||
|
const responseHtml = await res.text();
|
||||||
|
|
||||||
|
if (isChallengeHtml(responseHtml)) {
|
||||||
|
logger.log("Solving challenge...");
|
||||||
|
const result = await solveEbayChallenge(
|
||||||
|
responseHtml,
|
||||||
|
cookiesToString(cookieJar),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// Merge answer cookies into jar
|
||||||
|
if (baseCookies) {
|
||||||
|
searchHeaders.Cookie = mergeCookies(baseCookies, result.cookies);
|
||||||
|
} else {
|
||||||
|
searchHeaders.Cookie = result.cookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log("Challenge solved, retrying search...");
|
||||||
|
|
||||||
|
// Delay briefly before retry
|
||||||
|
await delay(DELAY_MS);
|
||||||
|
|
||||||
|
res = await fetch(searchUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: searchHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok && res.status !== 200) {
|
||||||
|
logger.warn(`Retry after challenge returned ${res.status}`);
|
||||||
|
return finalizeResults([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryHtml = await res.text();
|
||||||
|
await delay(DELAY_MS);
|
||||||
|
|
||||||
|
const listings = parseEbayListings(
|
||||||
|
retryHtml,
|
||||||
|
keywords,
|
||||||
|
exclusions,
|
||||||
|
strictMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredListings = listings.filter((listing) => {
|
||||||
|
const cents = listing.listingPrice?.cents;
|
||||||
|
return (
|
||||||
|
typeof cents === "number" && cents >= minPrice && cents <= maxPrice
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
`Parsed ${filteredListings.length} eBay listings (after challenge).`,
|
||||||
|
);
|
||||||
|
return finalizeResults(filteredListings);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("Challenge solve failed, returning empty results.");
|
||||||
|
return finalizeResults([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Normal flow — no challenge
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
`Request failed with status ${res.status}`,
|
`Request failed with status ${res.status}`,
|
||||||
@@ -764,20 +932,17 @@ export default async function fetchEbayItems(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchHtml = await res.text();
|
|
||||||
// Respect per-request delay to keep at or under REQUESTS_PER_SECOND
|
|
||||||
await delay(DELAY_MS);
|
await delay(DELAY_MS);
|
||||||
|
|
||||||
logger.log(`\nParsing eBay listings...`);
|
logger.log(`\nParsing eBay listings...`);
|
||||||
|
|
||||||
const listings = parseEbayListings(
|
const listings = parseEbayListings(
|
||||||
searchHtml,
|
responseHtml,
|
||||||
keywords,
|
keywords,
|
||||||
exclusions,
|
exclusions,
|
||||||
strictMode,
|
strictMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter by price range (additional safety check)
|
|
||||||
const filteredListings = listings.filter((listing) => {
|
const filteredListings = listings.filter((listing) => {
|
||||||
const cents = listing.listingPrice?.cents;
|
const cents = listing.listingPrice?.cents;
|
||||||
return (
|
return (
|
||||||
|
|||||||
25
packages/core/src/types/argon2-wasm-pro.d.ts
vendored
Normal file
25
packages/core/src/types/argon2-wasm-pro.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
declare module "argon2-wasm-pro" {
|
||||||
|
interface Argon2Options {
|
||||||
|
pass: string | Uint8Array;
|
||||||
|
salt: Uint8Array;
|
||||||
|
time: number;
|
||||||
|
mem: number;
|
||||||
|
hashLen: number;
|
||||||
|
parallelism: number;
|
||||||
|
type: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Argon2Result {
|
||||||
|
hash: Uint8Array;
|
||||||
|
hashHex: string;
|
||||||
|
encoded: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(options: Argon2Options): Promise<Argon2Result>;
|
||||||
|
|
||||||
|
const argon2: {
|
||||||
|
hash: typeof hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default argon2;
|
||||||
|
}
|
||||||
239
packages/core/src/utils/ebay-challenge.ts
Normal file
239
packages/core/src/utils/ebay-challenge.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import argon2 from "argon2-wasm-pro";
|
||||||
|
|
||||||
|
// ------------------ Types ------------------
|
||||||
|
|
||||||
|
interface ChallengeDetails {
|
||||||
|
p2: number;
|
||||||
|
p6: number;
|
||||||
|
p7: number;
|
||||||
|
p9: string;
|
||||||
|
p11: string;
|
||||||
|
p12: number;
|
||||||
|
p13: number;
|
||||||
|
p15: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChallengeParams {
|
||||||
|
crefId: string;
|
||||||
|
cdetail: ChallengeDetails;
|
||||||
|
iid: string;
|
||||||
|
chlghost: string;
|
||||||
|
appName: string;
|
||||||
|
p: string;
|
||||||
|
destUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChallengeResult {
|
||||||
|
cookies: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Helpers ------------------
|
||||||
|
|
||||||
|
function memcmp(a: Uint8Array, b: number[], len: number): number {
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const va = a[i] ?? 0;
|
||||||
|
const vb = b[i] ?? 0;
|
||||||
|
if (va !== vb) return (va & 0xff) - (vb & 0xff);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intToBytes(val: number, arr: Uint8Array, offset: number) {
|
||||||
|
arr[offset] = val >>> 24;
|
||||||
|
arr[offset + 1] = val >>> 16;
|
||||||
|
arr[offset + 2] = val >>> 8;
|
||||||
|
arr[offset + 3] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function string2Bin(str: string): number[] {
|
||||||
|
const result: number[] = [];
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
result.push(str.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64(buf: Uint8Array): string {
|
||||||
|
return btoa(String.fromCharCode(...buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookiesFromSetCookie(cookies: string[]): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const header of cookies) {
|
||||||
|
const match = header.match(/^([^=]+)=([^;]+)/);
|
||||||
|
if (match?.[1] && match[2]) {
|
||||||
|
result[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Default headers ------------------
|
||||||
|
|
||||||
|
const BROWSER_UA =
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
const _EBAY_HEADERS: Record<string, string> = {
|
||||||
|
"User-Agent": BROWSER_UA,
|
||||||
|
Accept:
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||||
|
"Accept-Language": "en-CA,en-US;q=0.9,en;q=0.8",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------ Parser ------------------
|
||||||
|
|
||||||
|
export function parseChallengePage(html: string): ChallengeParams | null {
|
||||||
|
const getHidden = (id: string): string => {
|
||||||
|
const re = new RegExp(
|
||||||
|
`id=${id}\\s+value='([^']*)'` +
|
||||||
|
`|id=${id}\\s+value="([^"]*)"` +
|
||||||
|
`|id=${id}\\s+value=([^\\s>]+)`,
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
const m = html.match(re);
|
||||||
|
if (!m) return "";
|
||||||
|
return m[1] ?? m[2] ?? m[3] ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const crefId = getHidden("_crefId");
|
||||||
|
const cdetailRaw = getHidden("_cdetail");
|
||||||
|
const iid = getHidden("_iid");
|
||||||
|
const chlghost = getHidden("_chlghost");
|
||||||
|
const appName = getHidden("_appName");
|
||||||
|
const p = getHidden("_p");
|
||||||
|
|
||||||
|
const formActionMatch = html.match(
|
||||||
|
/<form\s+id=destForm\s+[^>]*action=([^\s>]+)/i,
|
||||||
|
);
|
||||||
|
const destUrl = formActionMatch?.[1]?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!crefId || !cdetailRaw) return null;
|
||||||
|
|
||||||
|
let cdetail: ChallengeDetails;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cdetailRaw);
|
||||||
|
const d = parsed.details;
|
||||||
|
cdetail = {
|
||||||
|
p2: Number(d.p2),
|
||||||
|
p6: Number(d.p6),
|
||||||
|
p7: Number(d.p7),
|
||||||
|
p9: d.p9,
|
||||||
|
p11: d.p11,
|
||||||
|
p12: Number(d.p12),
|
||||||
|
p13: Number(d.p13),
|
||||||
|
p15: Number(d.p15),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
crefId,
|
||||||
|
cdetail,
|
||||||
|
iid,
|
||||||
|
chlghost: chlghost || "https://www.ebay.ca",
|
||||||
|
appName: appName || "orch",
|
||||||
|
p,
|
||||||
|
destUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Solver ------------------
|
||||||
|
|
||||||
|
async function solveArgon2Challenge(
|
||||||
|
cdetail: ChallengeDetails,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const targetBytes = string2Bin(atob(cdetail.p11));
|
||||||
|
const targetLen = targetBytes.length;
|
||||||
|
const nonceLen = cdetail.p6;
|
||||||
|
const answerCount = cdetail.p15;
|
||||||
|
const salt = new Uint8Array(
|
||||||
|
Uint8Array.from(atob(cdetail.p9), (c) => c.charCodeAt(0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const answers: string[] = [];
|
||||||
|
let nonce = new Uint8Array(nonceLen);
|
||||||
|
crypto.getRandomValues(nonce);
|
||||||
|
intToBytes(0, nonce, nonce.length - 4);
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
while (answers.length < answerCount) {
|
||||||
|
const result = await argon2.hash({
|
||||||
|
pass: nonce,
|
||||||
|
salt,
|
||||||
|
time: cdetail.p2,
|
||||||
|
mem: cdetail.p13,
|
||||||
|
hashLen: cdetail.p7,
|
||||||
|
parallelism: cdetail.p12,
|
||||||
|
type: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hashBytes = result.hash as Uint8Array;
|
||||||
|
|
||||||
|
if (memcmp(hashBytes, targetBytes, targetLen) <= 0) {
|
||||||
|
answers.push(bufferToBase64(nonce));
|
||||||
|
nonce = new Uint8Array(nonceLen);
|
||||||
|
crypto.getRandomValues(nonce);
|
||||||
|
intToBytes(0, nonce, nonce.length - 4);
|
||||||
|
counter = 0;
|
||||||
|
} else {
|
||||||
|
counter++;
|
||||||
|
intToBytes(counter, nonce, nonce.length - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return answers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------ Public API ------------------
|
||||||
|
|
||||||
|
export async function solveEbayChallenge(
|
||||||
|
html: string,
|
||||||
|
cookieHeader?: string,
|
||||||
|
): Promise<ChallengeResult | null> {
|
||||||
|
const params = parseChallengePage(html);
|
||||||
|
if (!params) return null;
|
||||||
|
|
||||||
|
const answers = await solveArgon2Challenge(params.cdetail);
|
||||||
|
const encodedAnswers = encodeURIComponent(answers.join(","));
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
iid: params.iid,
|
||||||
|
appName: params.appName,
|
||||||
|
referenceId: params.crefId,
|
||||||
|
pvt: Date.now().toString(),
|
||||||
|
crt: Date.now().toString(),
|
||||||
|
encodedAnswers,
|
||||||
|
p: params.p,
|
||||||
|
ru: params.destUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
accept: "application/json, text/plain, */*",
|
||||||
|
"user-agent": BROWSER_UA,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cookieHeader) {
|
||||||
|
headers.cookie = cookieHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${params.chlghost}/splashui/challengesvc/answer`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
// Collect cookies from answer response
|
||||||
|
const setCookies = res.headers.getSetCookie?.() ?? [];
|
||||||
|
const answerCookies = parseCookiesFromSetCookie(setCookies);
|
||||||
|
|
||||||
|
const cookieEntries = Object.entries(answerCookies);
|
||||||
|
if (cookieEntries.length === 0) return null;
|
||||||
|
|
||||||
|
const cookies = cookieEntries.map(([k, v]) => `${k}=${v}`).join("; ");
|
||||||
|
|
||||||
|
return { cookies };
|
||||||
|
}
|
||||||
@@ -47,17 +47,22 @@ describe("eBay Scraper Cookie Handling", () => {
|
|||||||
test("should ignore request cookie overrides and rely on EBAY_COOKIE", async () => {
|
test("should ignore request cookie overrides and rely on EBAY_COOKIE", async () => {
|
||||||
await fetchEbayItems("laptop", 1000);
|
await fetchEbayItems("laptop", 1000);
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
// First call is homepage warm-up, second is search
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
const firstFetchCall = (global.fetch as unknown as ReturnType<typeof mock>)
|
// The search request is the second call
|
||||||
.mock.calls[0];
|
const secondFetchCall = (global.fetch as unknown as ReturnType<typeof mock>)
|
||||||
if (!firstFetchCall) {
|
.mock.calls[1];
|
||||||
throw new Error("Expected fetch to be called");
|
if (!secondFetchCall) {
|
||||||
|
throw new Error("Expected search fetch to be called");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, init] = firstFetchCall;
|
const [searchUrl, init] = secondFetchCall;
|
||||||
const headers = (init as RequestInit).headers as Record<string, string>;
|
const headers = (init as RequestInit).headers as Record<string, string>;
|
||||||
|
|
||||||
|
expect(searchUrl).toBe(
|
||||||
|
"https://www.ebay.ca/sch/i.html?_nkw=laptop&_sacat=0&_from=R40&LH_BIN=1&LH_PrefLoc=1",
|
||||||
|
);
|
||||||
expect(headers.Cookie).toBeUndefined();
|
expect(headers.Cookie).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user