litescrape-sdk 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/LICENSE +21 -0
- package/README.md +283 -0
- package/dist/cjs/batch.d.ts +12 -0
- package/dist/cjs/batch.d.ts.map +1 -0
- package/dist/cjs/batch.js +131 -0
- package/dist/cjs/batch.js.map +1 -0
- package/dist/cjs/cache.d.ts +21 -0
- package/dist/cjs/cache.d.ts.map +1 -0
- package/dist/cjs/cache.js +112 -0
- package/dist/cjs/cache.js.map +1 -0
- package/dist/cjs/client.d.ts +38 -0
- package/dist/cjs/client.d.ts.map +1 -0
- package/dist/cjs/client.js +143 -0
- package/dist/cjs/client.js.map +1 -0
- package/dist/cjs/errors.d.ts +53 -0
- package/dist/cjs/errors.d.ts.map +1 -0
- package/dist/cjs/errors.js +121 -0
- package/dist/cjs/errors.js.map +1 -0
- package/dist/cjs/index.d.ts +8 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +40 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/models.d.ts +1735 -0
- package/dist/cjs/models.d.ts.map +1 -0
- package/dist/cjs/models.js +389 -0
- package/dist/cjs/models.js.map +1 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/result.d.ts +23 -0
- package/dist/cjs/result.d.ts.map +1 -0
- package/dist/cjs/result.js +49 -0
- package/dist/cjs/result.js.map +1 -0
- package/dist/cjs/runtime.d.ts +53 -0
- package/dist/cjs/runtime.d.ts.map +1 -0
- package/dist/cjs/runtime.js +237 -0
- package/dist/cjs/runtime.js.map +1 -0
- package/dist/cjs/stores.d.ts +143 -0
- package/dist/cjs/stores.d.ts.map +1 -0
- package/dist/cjs/stores.js +194 -0
- package/dist/cjs/stores.js.map +1 -0
- package/dist/cjs/validation.d.ts +65 -0
- package/dist/cjs/validation.d.ts.map +1 -0
- package/dist/cjs/validation.js +122 -0
- package/dist/cjs/validation.js.map +1 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.d.ts.map +1 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/version.js.map +1 -0
- package/dist/esm/batch.d.ts +12 -0
- package/dist/esm/batch.d.ts.map +1 -0
- package/dist/esm/batch.js +128 -0
- package/dist/esm/batch.js.map +1 -0
- package/dist/esm/cache.d.ts +21 -0
- package/dist/esm/cache.d.ts.map +1 -0
- package/dist/esm/cache.js +106 -0
- package/dist/esm/cache.js.map +1 -0
- package/dist/esm/client.d.ts +38 -0
- package/dist/esm/client.d.ts.map +1 -0
- package/dist/esm/client.js +138 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/errors.d.ts +53 -0
- package/dist/esm/errors.d.ts.map +1 -0
- package/dist/esm/errors.js +106 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/index.d.ts +8 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/models.d.ts +1735 -0
- package/dist/esm/models.d.ts.map +1 -0
- package/dist/esm/models.js +384 -0
- package/dist/esm/models.js.map +1 -0
- package/dist/esm/result.d.ts +23 -0
- package/dist/esm/result.d.ts.map +1 -0
- package/dist/esm/result.js +45 -0
- package/dist/esm/result.js.map +1 -0
- package/dist/esm/runtime.d.ts +53 -0
- package/dist/esm/runtime.d.ts.map +1 -0
- package/dist/esm/runtime.js +226 -0
- package/dist/esm/runtime.js.map +1 -0
- package/dist/esm/stores.d.ts +143 -0
- package/dist/esm/stores.d.ts.map +1 -0
- package/dist/esm/stores.js +190 -0
- package/dist/esm/stores.js.map +1 -0
- package/dist/esm/validation.d.ts +65 -0
- package/dist/esm/validation.d.ts.map +1 -0
- package/dist/esm/validation.js +109 -0
- package/dist/esm/validation.js.map +1 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.d.ts.map +1 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/version.js.map +1 -0
- package/package.json +74 -0
- package/src/batch.ts +158 -0
- package/src/cache.ts +123 -0
- package/src/client.ts +199 -0
- package/src/errors.ts +120 -0
- package/src/index.ts +17 -0
- package/src/models.ts +570 -0
- package/src/package.json +3 -0
- package/src/result.ts +49 -0
- package/src/runtime.ts +255 -0
- package/src/stores.ts +206 -0
- package/src/validation.ts +140 -0
- package/src/version.ts +1 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { APIError, LitescrapeError, TransportError, apiError, isObject } from "./errors.js";
|
|
4
|
+
import { VERSION } from "./version.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_BASE_URL = "https://api.litescrape.com";
|
|
7
|
+
export const USER_AGENT = `litescrape-sdk/${VERSION} (Node/${process.versions.node}; ${process.platform}; TypeScript)`;
|
|
8
|
+
export const CLIENT_HEADER_VALUE = `typescript-sdk/${VERSION}`;
|
|
9
|
+
export type Fetch = typeof globalThis.fetch;
|
|
10
|
+
|
|
11
|
+
export interface Outcome {
|
|
12
|
+
data: Record<string, unknown> | null;
|
|
13
|
+
error: LitescrapeError | null;
|
|
14
|
+
statusCode: number | null;
|
|
15
|
+
requestId: string;
|
|
16
|
+
attempts: number;
|
|
17
|
+
}
|
|
18
|
+
export const emptyOutcome = (): Outcome => ({
|
|
19
|
+
data: null,
|
|
20
|
+
error: null,
|
|
21
|
+
statusCode: null,
|
|
22
|
+
requestId: "",
|
|
23
|
+
attempts: 0,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** FIFO gate. Waiting and backoff do not hold a network slot. */
|
|
27
|
+
export class Semaphore {
|
|
28
|
+
private active = 0;
|
|
29
|
+
private readonly queue: Array<{ start: () => void; cancel: () => void }> = [];
|
|
30
|
+
constructor(public limit: number) {}
|
|
31
|
+
|
|
32
|
+
async acquire(signal?: AbortSignal): Promise<() => void> {
|
|
33
|
+
signal?.throwIfAborted();
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const waiter = {
|
|
36
|
+
start: () => {
|
|
37
|
+
signal?.removeEventListener("abort", waiter.cancel);
|
|
38
|
+
this.active++;
|
|
39
|
+
let released = false;
|
|
40
|
+
resolve(() => {
|
|
41
|
+
if (released) return;
|
|
42
|
+
released = true;
|
|
43
|
+
this.active--;
|
|
44
|
+
this.drain();
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
cancel: () => {
|
|
48
|
+
const index = this.queue.indexOf(waiter);
|
|
49
|
+
if (index !== -1) this.queue.splice(index, 1);
|
|
50
|
+
reject(signal?.reason);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
if (this.active < this.limit) waiter.start();
|
|
54
|
+
else {
|
|
55
|
+
this.queue.push(waiter);
|
|
56
|
+
signal?.addEventListener("abort", waiter.cancel, { once: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
resize(limit: number): void {
|
|
62
|
+
this.limit = limit;
|
|
63
|
+
this.drain();
|
|
64
|
+
}
|
|
65
|
+
private drain(): void {
|
|
66
|
+
while (this.active < this.limit && this.queue.length) this.queue.shift()!.start();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One key-wide cap shared by overlapping scrape calls. Per-call limits are separate,
|
|
71
|
+
// so two callers with different overrides cannot accidentally multiply the key limit.
|
|
72
|
+
const sharedLimits = new Map<string, { semaphore: Semaphore; users: number }>();
|
|
73
|
+
export function keySemaphore(
|
|
74
|
+
key: string,
|
|
75
|
+
baseUrl: string,
|
|
76
|
+
limit: number,
|
|
77
|
+
): { semaphore: Semaphore; release: () => void } {
|
|
78
|
+
const identity = createHash("sha256")
|
|
79
|
+
.update(JSON.stringify([key, baseUrl]))
|
|
80
|
+
.digest("hex");
|
|
81
|
+
let entry = sharedLimits.get(identity);
|
|
82
|
+
if (!entry) {
|
|
83
|
+
entry = { semaphore: new Semaphore(limit), users: 0 };
|
|
84
|
+
sharedLimits.set(identity, entry);
|
|
85
|
+
} else entry.semaphore.resize(limit);
|
|
86
|
+
entry.users++;
|
|
87
|
+
const held = entry;
|
|
88
|
+
return {
|
|
89
|
+
semaphore: held.semaphore,
|
|
90
|
+
release: () => {
|
|
91
|
+
held.users--;
|
|
92
|
+
if (held.users === 0) sharedLimits.delete(identity);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function shouldRetry(error: LitescrapeError): boolean {
|
|
98
|
+
if (error instanceof TransportError) return error.retryable;
|
|
99
|
+
return (
|
|
100
|
+
error instanceof APIError &&
|
|
101
|
+
(error.retryable ||
|
|
102
|
+
error.statusCode === 429 ||
|
|
103
|
+
(error.statusCode ?? 0) >= 500 ||
|
|
104
|
+
["proxy_capacity_unavailable", "upstream_session_unavailable", "service_unavailable"].includes(
|
|
105
|
+
error.errorCode,
|
|
106
|
+
))
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function backoff(attempt: number, retryAfter: number | null = null): number {
|
|
111
|
+
if (retryAfter !== null) return Math.min(Math.max(retryAfter, 0), 30);
|
|
112
|
+
const ceiling = Math.min(30, 2 ** Math.min(Math.max(attempt - 1, 0), 6));
|
|
113
|
+
return ceiling * (0.5 + Math.random() / 2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function sleep(seconds: number, signal?: AbortSignal): Promise<void> {
|
|
117
|
+
signal?.throwIfAborted();
|
|
118
|
+
try {
|
|
119
|
+
await delay(seconds * 1000, undefined, { signal });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
signal?.throwIfAborted();
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RuntimeOptions {
|
|
127
|
+
baseUrl: string;
|
|
128
|
+
apiKey: string;
|
|
129
|
+
attempts: number;
|
|
130
|
+
timeout: number;
|
|
131
|
+
fetch: Fetch;
|
|
132
|
+
signal?: AbortSignal;
|
|
133
|
+
}
|
|
134
|
+
interface RequestOptions extends RuntimeOptions {
|
|
135
|
+
params?: Record<string, string>;
|
|
136
|
+
method?: "GET" | "POST";
|
|
137
|
+
body?: Record<string, string>;
|
|
138
|
+
headers?: Record<string, string>;
|
|
139
|
+
acceptedStatuses?: number[];
|
|
140
|
+
semaphores?: Semaphore[];
|
|
141
|
+
gate?: () => APIError | null;
|
|
142
|
+
onOutcome?: (outcome: Outcome) => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function requestWithRetries(path: string, options: RequestOptions): Promise<Outcome> {
|
|
146
|
+
const { signal } = options;
|
|
147
|
+
const outcome = emptyOutcome();
|
|
148
|
+
for (let attempt = 1; attempt <= options.attempts; attempt++) {
|
|
149
|
+
signal?.throwIfAborted();
|
|
150
|
+
const release: Array<() => void> = [];
|
|
151
|
+
try {
|
|
152
|
+
for (const semaphore of options.semaphores ?? []) release.push(await semaphore.acquire(signal));
|
|
153
|
+
signal?.throwIfAborted();
|
|
154
|
+
const blocked = options.gate?.();
|
|
155
|
+
if (blocked) {
|
|
156
|
+
outcome.error = blocked;
|
|
157
|
+
outcome.statusCode = blocked.statusCode;
|
|
158
|
+
outcome.requestId = blocked.requestId;
|
|
159
|
+
return outcome;
|
|
160
|
+
}
|
|
161
|
+
Object.assign(outcome, emptyOutcome(), { attempts: attempt });
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const timeout = setTimeout(
|
|
164
|
+
() => controller.abort(new DOMException("HTTP transport timed out", "TimeoutError")),
|
|
165
|
+
options.timeout * 1000,
|
|
166
|
+
);
|
|
167
|
+
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
168
|
+
try {
|
|
169
|
+
const url = new URL(options.baseUrl + path);
|
|
170
|
+
for (const [key, value] of Object.entries(options.params ?? {})) url.searchParams.set(key, value);
|
|
171
|
+
const response = await options.fetch(url, {
|
|
172
|
+
method: options.method ?? "GET",
|
|
173
|
+
redirect: "manual",
|
|
174
|
+
signal: combined,
|
|
175
|
+
headers: {
|
|
176
|
+
Accept: "application/json",
|
|
177
|
+
"User-Agent": USER_AGENT,
|
|
178
|
+
"X-Litescrape-Client": CLIENT_HEADER_VALUE,
|
|
179
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
180
|
+
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
181
|
+
...options.headers,
|
|
182
|
+
},
|
|
183
|
+
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
|
184
|
+
});
|
|
185
|
+
outcome.statusCode = response.status;
|
|
186
|
+
outcome.requestId = response.headers.get("x-request-id") ?? "";
|
|
187
|
+
let data: unknown;
|
|
188
|
+
let bodyError: unknown;
|
|
189
|
+
try {
|
|
190
|
+
data = await response.json();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
bodyError = error;
|
|
193
|
+
}
|
|
194
|
+
signal?.throwIfAborted();
|
|
195
|
+
if (!(options.acceptedStatuses ?? [200]).includes(response.status)) {
|
|
196
|
+
outcome.error = apiError(response.status, data, response.headers);
|
|
197
|
+
outcome.requestId = outcome.error instanceof APIError ? outcome.error.requestId : outcome.requestId;
|
|
198
|
+
} else if (bodyError) {
|
|
199
|
+
outcome.error = new TransportError("Response body was not JSON", {
|
|
200
|
+
retryable: true,
|
|
201
|
+
cause: bodyError,
|
|
202
|
+
});
|
|
203
|
+
} else if (!isObject(data)) {
|
|
204
|
+
outcome.error = new TransportError("Response body was not a JSON object", { retryable: true });
|
|
205
|
+
} else outcome.data = data;
|
|
206
|
+
} catch (cause) {
|
|
207
|
+
signal?.throwIfAborted();
|
|
208
|
+
const retryable =
|
|
209
|
+
controller.signal.aborted ||
|
|
210
|
+
cause instanceof TypeError ||
|
|
211
|
+
(cause instanceof DOMException && ["AbortError", "TimeoutError"].includes(cause.name));
|
|
212
|
+
outcome.error = new TransportError(cause instanceof Error ? cause.message : String(cause), {
|
|
213
|
+
retryable,
|
|
214
|
+
cause,
|
|
215
|
+
});
|
|
216
|
+
} finally {
|
|
217
|
+
clearTimeout(timeout);
|
|
218
|
+
}
|
|
219
|
+
// Set the fatal gate while the slot is held, before queued requests can start.
|
|
220
|
+
options.onOutcome?.(outcome);
|
|
221
|
+
} finally {
|
|
222
|
+
for (const unlock of release.reverse()) unlock();
|
|
223
|
+
}
|
|
224
|
+
if (!outcome.error || attempt === options.attempts || !shouldRetry(outcome.error)) return outcome;
|
|
225
|
+
await sleep(
|
|
226
|
+
backoff(attempt, outcome.error instanceof APIError ? outcome.error.retryAfter : null),
|
|
227
|
+
signal,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return outcome;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A bounded worker pool; errors stop new work and settle all workers before returning. */
|
|
234
|
+
export async function each<T>(
|
|
235
|
+
items: readonly T[],
|
|
236
|
+
limit: number,
|
|
237
|
+
operation: (item: T) => Promise<void>,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
let next = 0;
|
|
240
|
+
let failed = false;
|
|
241
|
+
const worker = async () => {
|
|
242
|
+
while (!failed && next < items.length) {
|
|
243
|
+
const item = items[next++]!;
|
|
244
|
+
try {
|
|
245
|
+
await operation(item);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
failed = true;
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const settled = await Promise.allSettled(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
253
|
+
const failure = settled.find((result) => result.status === "rejected");
|
|
254
|
+
if (failure?.status === "rejected") throw failure.reason;
|
|
255
|
+
}
|
package/src/stores.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { isObject } from "./errors.js";
|
|
4
|
+
import { optional as opt, has, issue, type Rule } from "./validation.js";
|
|
5
|
+
|
|
6
|
+
const cleanText = z
|
|
7
|
+
.string()
|
|
8
|
+
.refine((value) => !/[\x00-\x1f\x7f]/.test(value), "Control characters are not allowed")
|
|
9
|
+
.refine(
|
|
10
|
+
(value) => !Array.from(value).some((c) => c.length === 1 && /[\ud800-\udfff]/.test(c)),
|
|
11
|
+
"Use valid Unicode text",
|
|
12
|
+
);
|
|
13
|
+
const query = cleanText
|
|
14
|
+
.refine((value) => Buffer.byteLength(value.trim(), "utf8") <= 2048, "Use at most 2,048 UTF-8 bytes")
|
|
15
|
+
.trim()
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(2048);
|
|
18
|
+
const whole = z
|
|
19
|
+
.union([
|
|
20
|
+
z.number().int(),
|
|
21
|
+
cleanText
|
|
22
|
+
.trim()
|
|
23
|
+
.regex(/^[+-]?\d+$/)
|
|
24
|
+
.transform(Number),
|
|
25
|
+
])
|
|
26
|
+
.pipe(z.number().int());
|
|
27
|
+
const category = cleanText
|
|
28
|
+
.min(1)
|
|
29
|
+
.max(128)
|
|
30
|
+
.regex(/^[a-zA-Z0-9_-]+$/);
|
|
31
|
+
const identifier = cleanText
|
|
32
|
+
.min(1)
|
|
33
|
+
.max(512)
|
|
34
|
+
.regex(/^[a-zA-Z0-9_.-]+$/);
|
|
35
|
+
const token = cleanText
|
|
36
|
+
.min(1)
|
|
37
|
+
.max(65536)
|
|
38
|
+
.regex(/^[a-zA-Z0-9_=+/:.-]+$/);
|
|
39
|
+
const age = z.enum(["AGE_RANGE1", "AGE_RANGE2", "AGE_RANGE3"]);
|
|
40
|
+
const store = z.enum(["apps", "books", "audiobooks", "movies", "tv"]).default("apps");
|
|
41
|
+
const playLocale = {
|
|
42
|
+
hl: cleanText
|
|
43
|
+
.max(32)
|
|
44
|
+
.regex(/^[a-zA-Z]{2,3}(?:[-_][a-zA-Z0-9]{2,8}){0,3}$/)
|
|
45
|
+
.default("en"),
|
|
46
|
+
gl: cleanText
|
|
47
|
+
.regex(/^[a-zA-Z]{2}$/)
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.default("us"),
|
|
50
|
+
};
|
|
51
|
+
const listing = {
|
|
52
|
+
...playLocale,
|
|
53
|
+
q: opt(query),
|
|
54
|
+
chart: opt(category),
|
|
55
|
+
next_page_token: opt(token),
|
|
56
|
+
section_page_token: opt(token),
|
|
57
|
+
see_more_token: opt(token),
|
|
58
|
+
};
|
|
59
|
+
export const playApps = {
|
|
60
|
+
...listing,
|
|
61
|
+
apps_category: opt(category),
|
|
62
|
+
age: opt(age),
|
|
63
|
+
store_device: opt(z.enum(["phone", "tablet", "tv", "chromebook", "watch", "car"])),
|
|
64
|
+
};
|
|
65
|
+
export const playGames = {
|
|
66
|
+
...listing,
|
|
67
|
+
games_category: opt(category),
|
|
68
|
+
store_device: opt(z.enum(["phone", "tablet", "tv", "chromebook", "watch", "windows"])),
|
|
69
|
+
};
|
|
70
|
+
export const playBooks = {
|
|
71
|
+
...listing,
|
|
72
|
+
books_category: opt(category),
|
|
73
|
+
age: opt(age),
|
|
74
|
+
price: opt(whole.pipe(z.number().min(1).max(2))),
|
|
75
|
+
};
|
|
76
|
+
export const playMovies = { ...listing, movies_category: opt(category), age: opt(age) };
|
|
77
|
+
export const playProduct = { ...playLocale, product_id: identifier, store, season_id: opt(identifier) };
|
|
78
|
+
export const playReviews = {
|
|
79
|
+
...playLocale,
|
|
80
|
+
product_id: identifier,
|
|
81
|
+
store,
|
|
82
|
+
platform: z.enum(["phone", "tablet", "watch", "chromebook", "tv"]).default("phone"),
|
|
83
|
+
rating: opt(whole.pipe(z.number().min(1).max(5))),
|
|
84
|
+
sort_by: whole.pipe(z.number().min(1).max(3)).default(1),
|
|
85
|
+
num: whole.pipe(z.number().min(1).max(199)).default(40),
|
|
86
|
+
next_page_token: opt(token),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const tokenFields = ["next_page_token", "section_page_token", "see_more_token"];
|
|
90
|
+
export const playRules = (operation: string): Rule[] => [
|
|
91
|
+
(p, c) => {
|
|
92
|
+
if (["apps", "games", "books", "movies"].includes(operation)) {
|
|
93
|
+
if ([...tokenFields, "chart"].filter((f) => p[f]).length > 1)
|
|
94
|
+
issue(c, "chart and pagination selectors are mutually exclusive");
|
|
95
|
+
if (p.chart && p.q) issue(c, "chart and q are mutually exclusive");
|
|
96
|
+
if (p.chart && has(p, "store_device") && p.store_device !== "phone")
|
|
97
|
+
issue(c, "Device-specific storefronts do not support charts; omit chart or use phone");
|
|
98
|
+
const categoryValue = p[`${operation}_category`];
|
|
99
|
+
if (p.q && categoryValue) issue(c, "q and category are mutually exclusive");
|
|
100
|
+
if (has(p, "store_device") && (p.q || categoryValue)) issue(c, "store_device excludes q and category");
|
|
101
|
+
if (p.age && categoryValue !== (operation === "books" ? "coll_1689" : "FAMILY"))
|
|
102
|
+
issue(c, "age requires the children's category");
|
|
103
|
+
if (has(p, "price") && !p.q) issue(c, "price requires q");
|
|
104
|
+
}
|
|
105
|
+
if (p.season_id && p.store !== "tv") issue(c, "season_id requires store=tv");
|
|
106
|
+
|
|
107
|
+
const context = Object.fromEntries(
|
|
108
|
+
Object.entries(p).filter(([key, value]) => value != null && ![...tokenFields, "chart"].includes(key)),
|
|
109
|
+
);
|
|
110
|
+
if (["apps", "games"].includes(operation) && !p.q && !p[`${operation}_category`])
|
|
111
|
+
context.store_device ??= "phone";
|
|
112
|
+
for (const field of tokenFields) {
|
|
113
|
+
const encoded = p[field];
|
|
114
|
+
if (typeof encoded !== "string") continue;
|
|
115
|
+
const kind =
|
|
116
|
+
field === "next_page_token"
|
|
117
|
+
? operation === "reviews"
|
|
118
|
+
? "reviews"
|
|
119
|
+
: "page"
|
|
120
|
+
: field === "section_page_token"
|
|
121
|
+
? "section"
|
|
122
|
+
: "collection";
|
|
123
|
+
try {
|
|
124
|
+
if (!/^[a-zA-Z0-9+/_-]*={0,2}$/.test(encoded) || encoded.replace(/=+$/, "").length % 4 === 1)
|
|
125
|
+
throw new Error();
|
|
126
|
+
const decoded: unknown = JSON.parse(
|
|
127
|
+
new TextDecoder("utf8", { fatal: true }).decode(Buffer.from(encoded, "base64url")),
|
|
128
|
+
);
|
|
129
|
+
if (
|
|
130
|
+
!isObject(decoded) ||
|
|
131
|
+
decoded.v !== 1 ||
|
|
132
|
+
decoded.operation !== operation ||
|
|
133
|
+
decoded.kind !== kind ||
|
|
134
|
+
!isDeepStrictEqual(decoded.context, context) ||
|
|
135
|
+
typeof decoded.token !== "string" ||
|
|
136
|
+
!/^[a-zA-Z0-9_=+/:.-]{1,12000}$/.test(decoded.token)
|
|
137
|
+
)
|
|
138
|
+
throw new Error();
|
|
139
|
+
} catch {
|
|
140
|
+
issue(c, "Use a returned token with the same operation and search parameters", [field]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const storefronts = new Set(
|
|
147
|
+
"ae af ag ai al am ao ar at au az ba bb be bf bg bh bj bm bn bo br bs bt bw by bz ca cd cg ch ci cl cm cn co cr cv cy cz de dk dm do dz ec ee eg es fi fj fm fr ga gb gd ge gh gm gr gt gw gy hk hn hr hu id ie il in iq is it jm jo jp ke kg kh kn kr kw ky kz la lb lc lk lr lt lu lv ly ma md me mg mk ml mm mn mo mr ms mt mu mv mw mx my mz na ne ng ni nl no np nr nz om pa pe pg ph pk pl pt pw py qa ro rs ru rw sa sb sc se sg si sk sl sn sr st sv sz tc td th tj tm tn to tr tt tw tz ua ug us uy uz vc ve vg vn vu xk ye za zm zw".split(
|
|
148
|
+
" ",
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
const appleLocale = {
|
|
152
|
+
country: cleanText
|
|
153
|
+
.regex(/^[a-zA-Z]{2}$/)
|
|
154
|
+
.toLowerCase()
|
|
155
|
+
.transform((value) => (value === "uk" ? "gb" : value))
|
|
156
|
+
.refine((value) => storefronts.has(value), "Choose a supported Apple storefront country")
|
|
157
|
+
.default("us"),
|
|
158
|
+
};
|
|
159
|
+
const productId = cleanText.regex(/^[1-9][0-9]{0,19}$/);
|
|
160
|
+
const quotePlus = (value: string) =>
|
|
161
|
+
encodeURIComponent(value)
|
|
162
|
+
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
163
|
+
.replace(/%20/g, "+");
|
|
164
|
+
export const appleSearch = {
|
|
165
|
+
...appleLocale,
|
|
166
|
+
term: query.refine((value) => {
|
|
167
|
+
try {
|
|
168
|
+
return quotePlus(value).length <= 4096;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}, "Use at most 4,096 URL-encoded bytes"),
|
|
173
|
+
lang: cleanText
|
|
174
|
+
.regex(/^[a-zA-Z]{2,3}-[a-zA-Z]{2}$/)
|
|
175
|
+
.toLowerCase()
|
|
176
|
+
.default("en-us"),
|
|
177
|
+
num: whole.pipe(z.number().min(1).max(200)).default(10),
|
|
178
|
+
disallow_explicit: z
|
|
179
|
+
.union([z.boolean(), z.enum(["true", "false"])])
|
|
180
|
+
.transform((value) => value === true || value === "true")
|
|
181
|
+
.default(false),
|
|
182
|
+
property: opt(z.literal("developer")),
|
|
183
|
+
category_id: opt(
|
|
184
|
+
whole.pipe(
|
|
185
|
+
z
|
|
186
|
+
.number()
|
|
187
|
+
.min(1)
|
|
188
|
+
.max(2 ** 31 - 1),
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
device: z.enum(["mobile", "tablet", "desktop"]).default("mobile"),
|
|
192
|
+
};
|
|
193
|
+
export const appleProduct = { ...appleLocale, product_id: productId, type: z.literal("app").default("app") };
|
|
194
|
+
export const appleReviews = {
|
|
195
|
+
...appleLocale,
|
|
196
|
+
product_id: productId,
|
|
197
|
+
sort: z.enum(["mostrecent", "mosthelpful"]).default("mostrecent"),
|
|
198
|
+
page: whole
|
|
199
|
+
.pipe(
|
|
200
|
+
z
|
|
201
|
+
.number()
|
|
202
|
+
.min(1)
|
|
203
|
+
.max(2 ** 31 - 1),
|
|
204
|
+
)
|
|
205
|
+
.default(1),
|
|
206
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ValidationError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
export const optional = <S extends z.ZodType>(schema: S) => schema.nullish();
|
|
5
|
+
export const text = z.union([z.string(), z.number().finite(), z.bigint()]).transform(String);
|
|
6
|
+
export const number = z
|
|
7
|
+
.union([
|
|
8
|
+
z.number(),
|
|
9
|
+
z
|
|
10
|
+
.string()
|
|
11
|
+
.trim()
|
|
12
|
+
.regex(/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/)
|
|
13
|
+
.transform(Number),
|
|
14
|
+
z.boolean().transform(Number),
|
|
15
|
+
])
|
|
16
|
+
.pipe(z.number().finite());
|
|
17
|
+
export const integer = number.pipe(z.number().int());
|
|
18
|
+
export const boolean = z
|
|
19
|
+
.union([
|
|
20
|
+
z.boolean(),
|
|
21
|
+
z.literal(0),
|
|
22
|
+
z.literal(1),
|
|
23
|
+
z
|
|
24
|
+
.string()
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.pipe(z.enum(["0", "1", "true", "false", "yes", "no", "on", "off", "t", "f", "y", "n"])),
|
|
27
|
+
])
|
|
28
|
+
.transform((value) => [true, 1, "1", "true", "yes", "on", "t", "y"].includes(value));
|
|
29
|
+
export const flag = z
|
|
30
|
+
.union([z.enum(["0", "1"]), z.literal(0), z.literal(1), z.boolean()])
|
|
31
|
+
.transform((value) => (value === true || value === 1 || value === "1" ? ("1" as const) : ("0" as const)));
|
|
32
|
+
export const strictBoolean = z.union([z.boolean(), z.enum(["true", "false"])]);
|
|
33
|
+
export const timeoutSeconds = z.number().finite().gt(0).max(90);
|
|
34
|
+
export const device = z.enum(["desktop", "tablet", "mobile"]);
|
|
35
|
+
|
|
36
|
+
type Params = Record<string, unknown>;
|
|
37
|
+
export type Rule = (params: Params, context: z.RefinementCtx) => void;
|
|
38
|
+
export const issue = (context: z.RefinementCtx, message: string, path: string[] = []) =>
|
|
39
|
+
context.addIssue({ code: "custom", message, path });
|
|
40
|
+
export const has = (params: Params, field: string) => params[field] !== null && params[field] !== undefined;
|
|
41
|
+
export const requireAny =
|
|
42
|
+
(...fields: string[]): Rule =>
|
|
43
|
+
(p, c) => {
|
|
44
|
+
if (!fields.some((f) => has(p, f))) issue(c, `one of ${fields.join(", ")} is required`);
|
|
45
|
+
};
|
|
46
|
+
export const exactlyOne =
|
|
47
|
+
(...fields: string[]): Rule =>
|
|
48
|
+
(p, c) => {
|
|
49
|
+
if (fields.filter((f) => has(p, f)).length !== 1)
|
|
50
|
+
issue(c, `exactly one of ${fields.join(", ")} is required`);
|
|
51
|
+
};
|
|
52
|
+
export const atMostOne =
|
|
53
|
+
(...fields: string[]): Rule =>
|
|
54
|
+
(p, c) => {
|
|
55
|
+
const found = fields.filter((f) => has(p, f));
|
|
56
|
+
if (found.length > 1) issue(c, `${found.join(" and ")} cannot be combined`);
|
|
57
|
+
};
|
|
58
|
+
export const paired =
|
|
59
|
+
(a: string, b: string): Rule =>
|
|
60
|
+
(p, c) => {
|
|
61
|
+
if (has(p, a) !== has(p, b)) issue(c, `${a} and ${b} must be given together`);
|
|
62
|
+
};
|
|
63
|
+
export const requires =
|
|
64
|
+
(field: string, dependency: string): Rule =>
|
|
65
|
+
(p, c) => {
|
|
66
|
+
if (has(p, field) && !has(p, dependency)) issue(c, `${field} requires ${dependency}`);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Validated, immutable request. Construct an endpoint class or pass a plain object to scrape. */
|
|
70
|
+
export class ScrapeRequest<E extends string = string> {
|
|
71
|
+
declare readonly endpoint: E;
|
|
72
|
+
declare readonly timeout?: number | null;
|
|
73
|
+
readonly #path: string;
|
|
74
|
+
|
|
75
|
+
constructor(path: string, params: object & { endpoint: E }, prototype?: object) {
|
|
76
|
+
this.#path = path;
|
|
77
|
+
Object.assign(this, params);
|
|
78
|
+
if (prototype) Object.setPrototypeOf(this, prototype);
|
|
79
|
+
Object.freeze(this);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get path(): string {
|
|
83
|
+
return this.#path;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
queryParams(): Record<string, string> {
|
|
87
|
+
return Object.fromEntries(
|
|
88
|
+
Object.entries(this)
|
|
89
|
+
.filter(([key, value]) => key !== "endpoint" && value != null)
|
|
90
|
+
.map(([key, value]) => [key, String(value)]),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
query_params(): Record<string, string> {
|
|
95
|
+
return this.queryParams();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Copy a validated request with a default deadline without revalidating its cursor context. */
|
|
99
|
+
withTimeout(timeout: number): this {
|
|
100
|
+
const checked = timeoutSeconds.parse(timeout);
|
|
101
|
+
return new ScrapeRequest(this.path, { ...this, timeout: checked }, Object.getPrototypeOf(this)) as this;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RequestClass<E extends string, Input, Output> {
|
|
106
|
+
new (params: Input): ScrapeRequest<E> & Readonly<Output>;
|
|
107
|
+
readonly endpoint: E;
|
|
108
|
+
readonly path: string;
|
|
109
|
+
readonly parameters: readonly string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function defineRequest<E extends string, Shape extends z.ZodRawShape>(
|
|
113
|
+
endpoint: E,
|
|
114
|
+
path: string,
|
|
115
|
+
shape: Shape,
|
|
116
|
+
rules: Rule[] = [],
|
|
117
|
+
): RequestClass<E, z.input<z.ZodObject<Shape>>, z.output<z.ZodObject<Shape>>> {
|
|
118
|
+
const schema = z.strictObject(shape).superRefine((params, ctx) => {
|
|
119
|
+
for (const rule of rules) rule(params, ctx);
|
|
120
|
+
});
|
|
121
|
+
class Request extends ScrapeRequest<E> {
|
|
122
|
+
static readonly endpoint = endpoint;
|
|
123
|
+
static readonly path = path;
|
|
124
|
+
static readonly parameters = Object.freeze(Object.keys(shape));
|
|
125
|
+
constructor(params: z.input<typeof schema>) {
|
|
126
|
+
const result = schema.safeParse(params);
|
|
127
|
+
if (!result.success)
|
|
128
|
+
throw new ValidationError([
|
|
129
|
+
[
|
|
130
|
+
0,
|
|
131
|
+
result.error.issues
|
|
132
|
+
.map((i) => `${i.path.join(".")}${i.path.length ? ": " : ""}${i.message}`)
|
|
133
|
+
.join("; "),
|
|
134
|
+
],
|
|
135
|
+
]);
|
|
136
|
+
super(path, { ...result.data, endpoint });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return Request as unknown as RequestClass<E, z.input<z.ZodObject<Shape>>, z.output<z.ZodObject<Shape>>>;
|
|
140
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = "0.1.0";
|