@scrapeatlas/cli 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 +99 -0
- package/bin/scrapeatlas.mjs +7 -0
- package/catalog.json +4609 -0
- package/package.json +36 -0
- package/src/cli.mjs +320 -0
- package/src/config.mjs +91 -0
- package/src/http.mjs +104 -0
- package/src/schema.mjs +66 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scrapeatlas/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official ScrapeAtlas CLI — discover and call social data API endpoints from your terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"scrapeatlas": "bin/scrapeatlas.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"catalog.json",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"license": "UNLICENSED",
|
|
19
|
+
"homepage": "https://scrapeatlas.com/docs/",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/OnlineDopamine/social-scraper.git",
|
|
23
|
+
"directory": "packages/cli"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"scrapeatlas",
|
|
31
|
+
"cli",
|
|
32
|
+
"social-media",
|
|
33
|
+
"scraping",
|
|
34
|
+
"api"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { lstat, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { readConfig, saveConfig, secretInput } from './config.mjs';
|
|
5
|
+
import { CliError, origin, request } from './http.mjs';
|
|
6
|
+
import { convert, kebab, valid } from './schema.mjs';
|
|
7
|
+
|
|
8
|
+
const catalog = JSON.parse(await readFile(new URL('../catalog.json', import.meta.url), 'utf8'));
|
|
9
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
10
|
+
const globalOptions = {
|
|
11
|
+
help: { type: 'boolean', short: 'h' },
|
|
12
|
+
version: { type: 'boolean', short: 'v' },
|
|
13
|
+
pretty: { type: 'boolean' },
|
|
14
|
+
json: { type: 'boolean' },
|
|
15
|
+
envelope: { type: 'boolean' },
|
|
16
|
+
output: { type: 'string' },
|
|
17
|
+
'api-key': { type: 'string' },
|
|
18
|
+
'base-url': { type: 'string' },
|
|
19
|
+
timeout: { type: 'string' },
|
|
20
|
+
input: { type: 'string' },
|
|
21
|
+
stdin: { type: 'boolean' },
|
|
22
|
+
};
|
|
23
|
+
const generalHelp = `ScrapeAtlas CLI ${pkg.version}
|
|
24
|
+
|
|
25
|
+
Usage: scrapeatlas <platform> <action> [--parameters]
|
|
26
|
+
scrapeatlas call <operationId> --input '{"parameter":"value"}'
|
|
27
|
+
scrapeatlas list [platform] [--json]
|
|
28
|
+
scrapeatlas auth login [--stdin]
|
|
29
|
+
scrapeatlas auth status | auth logout
|
|
30
|
+
|
|
31
|
+
Explore: scrapeatlas bluesky profile --help
|
|
32
|
+
Global options:
|
|
33
|
+
--api-key <key> Override SCRAPEATLAS_API_KEY / stored login
|
|
34
|
+
--base-url <origin> Override SCRAPEATLAS_BASE_URL / stored origin
|
|
35
|
+
--input <json> Request object; @file reads JSON from a file
|
|
36
|
+
--pretty Indented JSON (default: compact JSON)
|
|
37
|
+
--envelope Include httpStatus, requestId and retryAfter around body
|
|
38
|
+
--output <file> Save JSON to a new file (never overwrite)
|
|
39
|
+
--timeout <ms> Total deadline, 1..120000 ms (default: 90000)
|
|
40
|
+
--help, -h Show help for a command
|
|
41
|
+
--version, -v Print version
|
|
42
|
+
|
|
43
|
+
One request per command. Pagination is explicit. Inspect status and coverage.
|
|
44
|
+
Data goes to stdout; diagnostics go to stderr. Exit: 0 usable, 1 API/network, 2 usage.
|
|
45
|
+
Get a customer API key from https://scrapeatlas.com/dashboard/keys/.
|
|
46
|
+
`;
|
|
47
|
+
function parse(args, operation) {
|
|
48
|
+
const options = { ...globalOptions };
|
|
49
|
+
const names = new Map();
|
|
50
|
+
for (const [name, schema] of Object.entries(operation?.inputSchema.properties ?? {})) {
|
|
51
|
+
for (const flag of new Set([name, kebab(name)])) {
|
|
52
|
+
if (Object.hasOwn(globalOptions, flag) || (names.has(flag) && names.get(flag) !== name))
|
|
53
|
+
continue;
|
|
54
|
+
names.set(flag, name);
|
|
55
|
+
options[flag] = { type: schema.type === 'boolean' ? 'boolean' : 'string' };
|
|
56
|
+
if (schema.type === 'boolean') {
|
|
57
|
+
options[`no-${flag}`] = { type: 'boolean' };
|
|
58
|
+
names.set(`no-${flag}`, name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = parseArgs({ args, options, allowPositionals: true, tokens: true });
|
|
65
|
+
} catch {
|
|
66
|
+
throw new CliError(
|
|
67
|
+
'invalid_arguments',
|
|
68
|
+
'Unknown option or missing value. Use --help; use --input for JSON and boolean values.'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const token of parsed.tokens.filter((item) => item.kind === 'option')) {
|
|
73
|
+
const key = names.get(token.name) ?? token.name;
|
|
74
|
+
if (seen.has(key)) throw new CliError('duplicate_option', 'Duplicate options are not allowed.');
|
|
75
|
+
seen.add(key);
|
|
76
|
+
}
|
|
77
|
+
return { ...parsed, names };
|
|
78
|
+
}
|
|
79
|
+
function help(operation) {
|
|
80
|
+
return (
|
|
81
|
+
`scrapeatlas ${operation.platform} ${operation.action}\n${operation.method} ${operation.path}\n\n${operation.description}\n\nParameters:\n` +
|
|
82
|
+
Object.entries(operation.inputSchema.properties)
|
|
83
|
+
.map(
|
|
84
|
+
([name, schema]) =>
|
|
85
|
+
` --${kebab(name)} <${schema.type ?? 'json'}>${operation.inputSchema.required.includes(name) ? ' (required)' : ''}${schema.enum ? ` [${schema.enum.join(', ')}]` : ''}\n ${schema.description ?? ''}`
|
|
86
|
+
)
|
|
87
|
+
.join('\n') +
|
|
88
|
+
'\n\nCamelCase and snake_case API parameter names also work. Use --input for nested objects, arrays, or explicit booleans. Runtime refinements are validated by the gateway.\n\n' +
|
|
89
|
+
generalHelp
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
async function inputObject(value) {
|
|
93
|
+
if (!value) return {};
|
|
94
|
+
let text = value;
|
|
95
|
+
if (value.startsWith('@')) {
|
|
96
|
+
const handle = await import('node:fs/promises').then((fs) => fs.open(value.slice(1), 'r'));
|
|
97
|
+
try {
|
|
98
|
+
if ((await handle.stat()).size > 65536)
|
|
99
|
+
throw new CliError('input_too_large', 'Request input exceeds 64 KiB.');
|
|
100
|
+
const buffer = Buffer.alloc(65537);
|
|
101
|
+
let size = 0;
|
|
102
|
+
while (size < buffer.length) {
|
|
103
|
+
const { bytesRead } = await handle.read(buffer, size, buffer.length - size, null);
|
|
104
|
+
if (!bytesRead) break;
|
|
105
|
+
size += bytesRead;
|
|
106
|
+
}
|
|
107
|
+
if (size > 65536) throw new CliError('input_too_large', 'Request input exceeds 64 KiB.');
|
|
108
|
+
text = buffer.subarray(0, size).toString('utf8');
|
|
109
|
+
} finally {
|
|
110
|
+
await handle.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (Buffer.byteLength(text) > 65536)
|
|
114
|
+
throw new CliError('input_too_large', 'Request input exceeds 64 KiB.');
|
|
115
|
+
try {
|
|
116
|
+
const value = JSON.parse(text);
|
|
117
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
|
|
118
|
+
return value;
|
|
119
|
+
} catch {
|
|
120
|
+
throw new CliError('invalid_input', '--input must contain a JSON object.');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function apiKey(value) {
|
|
124
|
+
if (typeof value !== 'string' || !value.trim())
|
|
125
|
+
throw new CliError('missing_key', 'Run scrapeatlas auth login or set SCRAPEATLAS_API_KEY.');
|
|
126
|
+
if (!/^[\x21-\x7e]{1,8192}$/.test(value))
|
|
127
|
+
throw new CliError('invalid_key', 'API key must be a nonempty token without whitespace.');
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
async function emit(value, options) {
|
|
131
|
+
const text = JSON.stringify(value, null, options.pretty ? 2 : undefined) + '\n';
|
|
132
|
+
if (options.output) {
|
|
133
|
+
await writeFile(resolve(options.output), text, { flag: 'wx', mode: 0o600 });
|
|
134
|
+
process.stdout.write(resolve(options.output) + '\n');
|
|
135
|
+
} else process.stdout.write(text);
|
|
136
|
+
}
|
|
137
|
+
export async function run(args) {
|
|
138
|
+
try {
|
|
139
|
+
// Identify positionals with a permissive first pass; strict parsing follows with endpoint options.
|
|
140
|
+
const rough = parseArgs({
|
|
141
|
+
args,
|
|
142
|
+
strict: false,
|
|
143
|
+
allowPositionals: true,
|
|
144
|
+
options: globalOptions,
|
|
145
|
+
});
|
|
146
|
+
const words = rough.positionals;
|
|
147
|
+
const operation =
|
|
148
|
+
words[0] === 'call'
|
|
149
|
+
? catalog.find((entry) => entry.operation === words[1])
|
|
150
|
+
: catalog.find((entry) => entry.platform === words[0] && entry.action === words[1]);
|
|
151
|
+
const { values: options, positionals, names } = parse(args, operation);
|
|
152
|
+
if (options.output) {
|
|
153
|
+
try {
|
|
154
|
+
await lstat(resolve(options.output));
|
|
155
|
+
throw new CliError('output_exists', 'Output file already exists; choose a new path.');
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error.code !== 'ENOENT') throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (options.version) {
|
|
161
|
+
process.stdout.write(pkg.version + '\n');
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
if (options.help || !positionals.length) {
|
|
165
|
+
process.stdout.write(operation ? help(operation) : generalHelp);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
if (positionals[0] === 'list') {
|
|
169
|
+
if (positionals.length > 2)
|
|
170
|
+
throw new CliError('invalid_arguments', 'Usage: scrapeatlas list [platform]');
|
|
171
|
+
const entries = catalog.filter(
|
|
172
|
+
(entry) => !positionals[1] || entry.platform === positionals[1]
|
|
173
|
+
);
|
|
174
|
+
if (!entries.length)
|
|
175
|
+
throw new CliError('unknown_platform', 'Unknown platform. Run scrapeatlas list.');
|
|
176
|
+
if (options.json || options.output || options.pretty) await emit(entries, options);
|
|
177
|
+
else
|
|
178
|
+
process.stdout.write(
|
|
179
|
+
positionals[1]
|
|
180
|
+
? entries
|
|
181
|
+
.map((entry) => `${entry.platform} ${entry.action} ${entry.method} ${entry.path}`)
|
|
182
|
+
.join('\n') + '\n'
|
|
183
|
+
: [...new Set(entries.map((entry) => entry.platform))]
|
|
184
|
+
.map(
|
|
185
|
+
(platform) =>
|
|
186
|
+
`${platform} (${entries.filter((entry) => entry.platform === platform).length} commands)`
|
|
187
|
+
)
|
|
188
|
+
.join('\n') + '\n'
|
|
189
|
+
);
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
if (!operation && positionals[0] !== 'auth')
|
|
193
|
+
throw new CliError('unknown_command', 'Unknown command. Run scrapeatlas list or --help.');
|
|
194
|
+
if (positionals.length !== 2)
|
|
195
|
+
throw new CliError(
|
|
196
|
+
'invalid_arguments',
|
|
197
|
+
'Expected exactly a platform and action, or call and operation ID.'
|
|
198
|
+
);
|
|
199
|
+
const config = await readConfig();
|
|
200
|
+
const base = origin(
|
|
201
|
+
options['base-url'] ||
|
|
202
|
+
process.env.SCRAPEATLAS_BASE_URL ||
|
|
203
|
+
config.baseUrl ||
|
|
204
|
+
'https://api.scrapeatlas.com'
|
|
205
|
+
);
|
|
206
|
+
const timeout = Number(options.timeout ?? 90000);
|
|
207
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 120000)
|
|
208
|
+
throw new CliError('invalid_timeout', 'Timeout must be 1..120000 milliseconds.');
|
|
209
|
+
const explicitKey = options['api-key'] || process.env.SCRAPEATLAS_API_KEY;
|
|
210
|
+
// Saved credentials belong to the origin at which they were verified.
|
|
211
|
+
const savedKey =
|
|
212
|
+
(config.baseUrl || 'https://api.scrapeatlas.com') === base ? config.apiKey : undefined;
|
|
213
|
+
if (positionals[0] === 'auth') {
|
|
214
|
+
if (positionals[1] === 'logout') {
|
|
215
|
+
await saveConfig({});
|
|
216
|
+
await emit(
|
|
217
|
+
{
|
|
218
|
+
authenticated: false,
|
|
219
|
+
storedKeyRemoved: true,
|
|
220
|
+
environmentKeyPresent: Boolean(process.env.SCRAPEATLAS_API_KEY),
|
|
221
|
+
},
|
|
222
|
+
options
|
|
223
|
+
);
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
if (positionals[1] === 'status') {
|
|
227
|
+
await emit(
|
|
228
|
+
{
|
|
229
|
+
authenticated: Boolean(explicitKey || savedKey),
|
|
230
|
+
source: options['api-key']
|
|
231
|
+
? 'flag'
|
|
232
|
+
: process.env.SCRAPEATLAS_API_KEY
|
|
233
|
+
? 'environment'
|
|
234
|
+
: savedKey
|
|
235
|
+
? 'config'
|
|
236
|
+
: null,
|
|
237
|
+
baseUrl: base,
|
|
238
|
+
verified: false,
|
|
239
|
+
},
|
|
240
|
+
options
|
|
241
|
+
);
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
if (positionals[1] !== 'login')
|
|
245
|
+
throw new CliError('unknown_command', 'Use auth login, auth status, or auth logout.');
|
|
246
|
+
const key = apiKey(
|
|
247
|
+
options.stdin ? await secretInput(true) : explicitKey || (await secretInput(false))
|
|
248
|
+
);
|
|
249
|
+
const result = await request(
|
|
250
|
+
base,
|
|
251
|
+
key,
|
|
252
|
+
{ method: 'GET', path: '/v1/auth/verify' },
|
|
253
|
+
{},
|
|
254
|
+
Math.min(timeout, 10000)
|
|
255
|
+
);
|
|
256
|
+
if (result.status !== 200 || result.body?.valid !== true)
|
|
257
|
+
throw new CliError(
|
|
258
|
+
'authentication_failed',
|
|
259
|
+
'Gateway did not verify the key; nothing was saved.',
|
|
260
|
+
1
|
|
261
|
+
);
|
|
262
|
+
await saveConfig({ apiKey: key, baseUrl: base });
|
|
263
|
+
await emit({ authenticated: true, verified: true, baseUrl: base }, options);
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
const input = await inputObject(options.input);
|
|
267
|
+
for (const [flag, name] of names)
|
|
268
|
+
if (options[flag] !== undefined) {
|
|
269
|
+
if (Object.hasOwn(input, name))
|
|
270
|
+
throw new CliError('duplicate_parameter', 'A parameter was supplied more than once.');
|
|
271
|
+
input[name] = flag.startsWith('no-')
|
|
272
|
+
? false
|
|
273
|
+
: convert(operation.inputSchema.properties[name], String(options[flag]));
|
|
274
|
+
}
|
|
275
|
+
if (!valid(operation.inputSchema, input))
|
|
276
|
+
throw new CliError(
|
|
277
|
+
'invalid_parameters',
|
|
278
|
+
'Parameters do not match this endpoint. Use --help or list <platform> --json for its schema.'
|
|
279
|
+
);
|
|
280
|
+
if (Buffer.byteLength(JSON.stringify(input)) > 65536)
|
|
281
|
+
throw new CliError('input_too_large', 'Request input exceeds 64 KiB.');
|
|
282
|
+
const result = await request(base, apiKey(explicitKey || savedKey), operation, input, timeout);
|
|
283
|
+
const failed =
|
|
284
|
+
result.status >= 400 ||
|
|
285
|
+
result.body?.success === false ||
|
|
286
|
+
['failed', 'challenged'].includes(result.body?.status);
|
|
287
|
+
await emit(
|
|
288
|
+
options.envelope
|
|
289
|
+
? {
|
|
290
|
+
httpStatus: result.status,
|
|
291
|
+
body: result.body,
|
|
292
|
+
...(result.requestId ? { requestId: result.requestId } : {}),
|
|
293
|
+
...(result.retryAfter ? { retryAfter: result.retryAfter } : {}),
|
|
294
|
+
}
|
|
295
|
+
: result.body,
|
|
296
|
+
options
|
|
297
|
+
);
|
|
298
|
+
if (failed)
|
|
299
|
+
process.stderr.write(
|
|
300
|
+
JSON.stringify({
|
|
301
|
+
error: true,
|
|
302
|
+
code: `HTTP_${result.status}`,
|
|
303
|
+
message: 'Gateway returned a failure; inspect the response body.',
|
|
304
|
+
}) + '\n'
|
|
305
|
+
);
|
|
306
|
+
return failed ? 1 : 0;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const known = error instanceof CliError;
|
|
309
|
+
process.stderr.write(
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
error: true,
|
|
312
|
+
code: known ? error.code : 'local_error',
|
|
313
|
+
message: known
|
|
314
|
+
? error.message
|
|
315
|
+
: 'CLI operation failed. Check file permissions and configuration; output files must not already exist.',
|
|
316
|
+
}) + '\n'
|
|
317
|
+
);
|
|
318
|
+
return known ? error.exitCode : 2;
|
|
319
|
+
}
|
|
320
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { CliError } from './http.mjs';
|
|
6
|
+
|
|
7
|
+
export function configDirectory() {
|
|
8
|
+
return resolve(
|
|
9
|
+
process.env.SCRAPEATLAS_CONFIG_DIR ||
|
|
10
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'scrapeatlas')
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
export async function readConfig() {
|
|
14
|
+
try {
|
|
15
|
+
const value = JSON.parse(await readFile(join(configDirectory(), 'config.json'), 'utf8'));
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
|
|
17
|
+
return value;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT') return {};
|
|
20
|
+
throw new CliError(
|
|
21
|
+
'config_error',
|
|
22
|
+
'Cannot read CLI config. Repair or remove config.json in the ScrapeAtlas config directory.'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function saveConfig(config) {
|
|
27
|
+
const directory = configDirectory();
|
|
28
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
29
|
+
if (!(await lstat(directory)).isDirectory())
|
|
30
|
+
throw new CliError('config_error', 'Config directory must be a real directory.');
|
|
31
|
+
await chmod(directory, 0o700);
|
|
32
|
+
const temporary = join(directory, `.config-${randomUUID()}.tmp`);
|
|
33
|
+
try {
|
|
34
|
+
await writeFile(temporary, JSON.stringify(config) + '\n', { mode: 0o600, flag: 'wx' });
|
|
35
|
+
await rename(temporary, join(directory, 'config.json'));
|
|
36
|
+
} finally {
|
|
37
|
+
await rm(temporary, { force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function secretInput(fromStdin) {
|
|
41
|
+
if (fromStdin) {
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let size = 0;
|
|
44
|
+
for await (const chunk of process.stdin) {
|
|
45
|
+
size += chunk.length;
|
|
46
|
+
if (size > 8192) throw new CliError('invalid_key', 'API key input is too large.');
|
|
47
|
+
chunks.push(chunk);
|
|
48
|
+
}
|
|
49
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
50
|
+
}
|
|
51
|
+
if (!process.stdin.isTTY)
|
|
52
|
+
throw new CliError('missing_key', 'Use SCRAPEATLAS_API_KEY or auth login --stdin.');
|
|
53
|
+
process.stderr.write('ScrapeAtlas API key (hidden): ');
|
|
54
|
+
const previous = process.stdin.isRaw;
|
|
55
|
+
process.stdin.setRawMode(true);
|
|
56
|
+
process.stdin.resume();
|
|
57
|
+
try {
|
|
58
|
+
return await new Promise((resolve, reject) => {
|
|
59
|
+
let value = '';
|
|
60
|
+
function onData(chunk) {
|
|
61
|
+
for (const character of chunk.toString('utf8')) {
|
|
62
|
+
if (character === '\r' || character === '\n') {
|
|
63
|
+
cleanup();
|
|
64
|
+
resolve(value.trim());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (character === '\u0003' || character === '\u0004') {
|
|
68
|
+
cleanup();
|
|
69
|
+
reject(new CliError('cancelled', 'Login cancelled.', 130));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
|
|
73
|
+
else if (character >= ' ') value += character;
|
|
74
|
+
if (value.length > 8192) {
|
|
75
|
+
cleanup();
|
|
76
|
+
reject(new CliError('invalid_key', 'API key input is too large.'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function cleanup() {
|
|
82
|
+
process.stdin.off('data', onData);
|
|
83
|
+
}
|
|
84
|
+
process.stdin.on('data', onData);
|
|
85
|
+
});
|
|
86
|
+
} finally {
|
|
87
|
+
process.stdin.setRawMode(previous);
|
|
88
|
+
process.stdin.pause();
|
|
89
|
+
process.stderr.write('\n');
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/http.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
constructor(code, message, exitCode = 2) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.code = code;
|
|
5
|
+
this.exitCode = exitCode;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function origin(value) {
|
|
9
|
+
let url;
|
|
10
|
+
try {
|
|
11
|
+
url = new URL(value);
|
|
12
|
+
} catch {
|
|
13
|
+
throw new CliError(
|
|
14
|
+
'invalid_origin',
|
|
15
|
+
'Base URL must be an HTTPS origin (or HTTP loopback for development).'
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (
|
|
19
|
+
url.username ||
|
|
20
|
+
url.password ||
|
|
21
|
+
url.pathname !== '/' ||
|
|
22
|
+
url.search ||
|
|
23
|
+
url.hash ||
|
|
24
|
+
!(
|
|
25
|
+
url.protocol === 'https:' ||
|
|
26
|
+
(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
throw new CliError(
|
|
30
|
+
'invalid_origin',
|
|
31
|
+
'Base URL must be an HTTPS origin (or HTTP loopback for development).'
|
|
32
|
+
);
|
|
33
|
+
return url.origin;
|
|
34
|
+
}
|
|
35
|
+
export async function request(base, key, operation, input, timeout) {
|
|
36
|
+
const url = new URL(operation.path, base);
|
|
37
|
+
if (operation.method === 'GET')
|
|
38
|
+
for (const [name, value] of Object.entries(input)) {
|
|
39
|
+
url.searchParams.set(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
40
|
+
}
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
43
|
+
let reader;
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(url, {
|
|
46
|
+
method: operation.method,
|
|
47
|
+
redirect: 'manual',
|
|
48
|
+
credentials: 'omit',
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
headers: {
|
|
51
|
+
authorization: `Bearer ${key}`,
|
|
52
|
+
accept: 'application/json',
|
|
53
|
+
'user-agent': 'scrapeatlas-cli/0.1.0',
|
|
54
|
+
...(operation.method === 'POST' ? { 'content-type': 'application/json' } : {}),
|
|
55
|
+
},
|
|
56
|
+
...(operation.method === 'POST' ? { body: JSON.stringify(input) } : {}),
|
|
57
|
+
});
|
|
58
|
+
if (response.status >= 300 && response.status < 400) {
|
|
59
|
+
await response.body?.cancel();
|
|
60
|
+
throw new CliError('redirect_refused', 'Gateway redirect refused; verify your base URL.', 1);
|
|
61
|
+
}
|
|
62
|
+
if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '')) {
|
|
63
|
+
await response.body?.cancel();
|
|
64
|
+
throw new CliError('invalid_response', 'Gateway did not return JSON.', 1);
|
|
65
|
+
}
|
|
66
|
+
reader = response.body?.getReader();
|
|
67
|
+
let size = 0;
|
|
68
|
+
const chunks = [];
|
|
69
|
+
while (reader) {
|
|
70
|
+
const chunk = await reader.read();
|
|
71
|
+
if (chunk.done) break;
|
|
72
|
+
size += chunk.value.byteLength;
|
|
73
|
+
if (size > 16 * 1024 * 1024)
|
|
74
|
+
throw new CliError(
|
|
75
|
+
'response_too_large',
|
|
76
|
+
'Response exceeds 16 MiB; request a smaller page.',
|
|
77
|
+
1
|
|
78
|
+
);
|
|
79
|
+
chunks.push(chunk.value);
|
|
80
|
+
}
|
|
81
|
+
let body;
|
|
82
|
+
try {
|
|
83
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
84
|
+
} catch {
|
|
85
|
+
throw new CliError('invalid_response', 'Gateway returned invalid JSON.', 1);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
body,
|
|
89
|
+
status: response.status,
|
|
90
|
+
requestId: response.headers.get('x-request-id'),
|
|
91
|
+
retryAfter: response.headers.get('retry-after'),
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof CliError) throw error;
|
|
95
|
+
throw new CliError(
|
|
96
|
+
controller.signal.aborted ? 'timeout' : 'network_error',
|
|
97
|
+
controller.signal.aborted ? 'Request deadline exceeded.' : 'Gateway request failed.',
|
|
98
|
+
1
|
|
99
|
+
);
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
await reader?.cancel().catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/schema.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const kebab = (value) =>
|
|
2
|
+
value
|
|
3
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
4
|
+
.replaceAll('_', '-')
|
|
5
|
+
.toLowerCase();
|
|
6
|
+
export function valid(schema, value) {
|
|
7
|
+
if (schema.anyOf) return schema.anyOf.some((option) => valid(option, value));
|
|
8
|
+
if ('const' in schema && value !== schema.const) return false;
|
|
9
|
+
if (schema.enum && !schema.enum.includes(value)) return false;
|
|
10
|
+
switch (schema.type) {
|
|
11
|
+
case 'null':
|
|
12
|
+
return value === null;
|
|
13
|
+
case 'string':
|
|
14
|
+
return (
|
|
15
|
+
typeof value === 'string' &&
|
|
16
|
+
(schema.minLength === undefined || value.length >= schema.minLength) &&
|
|
17
|
+
(schema.maxLength === undefined || value.length <= schema.maxLength) &&
|
|
18
|
+
(!schema.pattern || new RegExp(schema.pattern).test(value))
|
|
19
|
+
);
|
|
20
|
+
case 'number':
|
|
21
|
+
case 'integer':
|
|
22
|
+
return (
|
|
23
|
+
typeof value === 'number' &&
|
|
24
|
+
Number.isFinite(value) &&
|
|
25
|
+
(schema.type !== 'integer' || Number.isInteger(value)) &&
|
|
26
|
+
(schema.minimum === undefined || value >= schema.minimum) &&
|
|
27
|
+
(schema.maximum === undefined || value <= schema.maximum)
|
|
28
|
+
);
|
|
29
|
+
case 'boolean':
|
|
30
|
+
return typeof value === 'boolean';
|
|
31
|
+
case 'array':
|
|
32
|
+
return (
|
|
33
|
+
Array.isArray(value) &&
|
|
34
|
+
(schema.minItems === undefined || value.length >= schema.minItems) &&
|
|
35
|
+
(schema.maxItems === undefined || value.length <= schema.maxItems) &&
|
|
36
|
+
value.every((item) => valid(schema.items, item))
|
|
37
|
+
);
|
|
38
|
+
case 'object':
|
|
39
|
+
return (
|
|
40
|
+
value !== null &&
|
|
41
|
+
typeof value === 'object' &&
|
|
42
|
+
!Array.isArray(value) &&
|
|
43
|
+
(schema.required ?? []).every((key) => Object.hasOwn(value, key)) &&
|
|
44
|
+
Object.entries(value).every(([key, item]) => {
|
|
45
|
+
if (schema.propertyNames && !valid(schema.propertyNames, key)) return false;
|
|
46
|
+
if (Object.hasOwn(schema.properties ?? {}, key))
|
|
47
|
+
return valid(schema.properties[key], item);
|
|
48
|
+
return (
|
|
49
|
+
schema.additionalProperties !== false &&
|
|
50
|
+
(typeof schema.additionalProperties !== 'object' ||
|
|
51
|
+
valid(schema.additionalProperties, item))
|
|
52
|
+
);
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
default:
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function convert(schema, raw) {
|
|
60
|
+
if (schema.type === 'string') return raw;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
} catch {
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
}
|