@scrapecreators/cli 1.0.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.
@@ -0,0 +1,148 @@
1
+ import * as prompts from "@clack/prompts";
2
+ import chalk from "chalk";
3
+ import { resolveApiKey, storeApiKey } from "./auth.js";
4
+ import { callApi } from "./api-client.js";
5
+ import { printResult } from "./output.js";
6
+ import { getPlatformMap } from "./command-registry.js";
7
+
8
+ export async function runInteractive(globalOpts) {
9
+ prompts.intro(chalk.bold("ScrapeCreators CLI"));
10
+
11
+ let apiKey = resolveApiKey(globalOpts);
12
+ if (!apiKey) {
13
+ const key = await prompts.text({
14
+ message: "Enter your API key to get started",
15
+ placeholder: "paste from https://app.scrapecreators.com",
16
+ validate: (v) => (v.length < 5 ? "Too short" : undefined),
17
+ });
18
+
19
+ if (prompts.isCancel(key)) {
20
+ prompts.cancel("Cancelled.");
21
+ return;
22
+ }
23
+
24
+ storeApiKey(key);
25
+ apiKey = key;
26
+ console.log(chalk.green(" API key saved.\n"));
27
+ }
28
+
29
+ const platforms = getPlatformMap();
30
+ const platformChoices = [...platforms.keys()]
31
+ .filter((p) => p !== "credit")
32
+ .sort()
33
+ .map((p) => ({ value: p, label: p }));
34
+
35
+ const platform = await prompts.select({
36
+ message: "Select a platform",
37
+ options: platformChoices,
38
+ });
39
+
40
+ if (prompts.isCancel(platform)) {
41
+ prompts.cancel("Cancelled.");
42
+ return;
43
+ }
44
+
45
+ const tools = platforms.get(platform);
46
+ const actionChoices = tools.map((t) => ({
47
+ value: t._action,
48
+ label: t._action,
49
+ hint: t.title,
50
+ }));
51
+
52
+ const action = await prompts.select({
53
+ message: "Select an action",
54
+ options: actionChoices,
55
+ });
56
+
57
+ if (prompts.isCancel(action)) {
58
+ prompts.cancel("Cancelled.");
59
+ return;
60
+ }
61
+
62
+ const tool = tools.find((t) => t._action === action);
63
+ const params = {};
64
+
65
+ // collect required params
66
+ const requiredParams = tool.params.filter((p) => p.required);
67
+ for (const param of requiredParams) {
68
+ if (param.type === "boolean") {
69
+ const val = await prompts.confirm({
70
+ message: param.description || param.name,
71
+ });
72
+ if (prompts.isCancel(val)) { prompts.cancel("Cancelled."); return; }
73
+ params[param.name] = val;
74
+ } else if (param.type === "select" && param.options?.length) {
75
+ const val = await prompts.select({
76
+ message: param.description || param.name,
77
+ options: param.options.map((o) => ({ value: o, label: o })),
78
+ });
79
+ if (prompts.isCancel(val)) { prompts.cancel("Cancelled."); return; }
80
+ params[param.name] = val;
81
+ } else {
82
+ const val = await prompts.text({
83
+ message: `${param.name}${param.description ? ` (${param.description})` : ""}`,
84
+ placeholder: param.default ? String(param.default) : undefined,
85
+ });
86
+ if (prompts.isCancel(val)) { prompts.cancel("Cancelled."); return; }
87
+ params[param.name] = val;
88
+ }
89
+ }
90
+
91
+ // ask if they want to set optional params
92
+ const optionalParams = tool.params.filter((p) => !p.required);
93
+ if (optionalParams.length > 0) {
94
+ const setOptional = await prompts.confirm({
95
+ message: `Set optional parameters? (${optionalParams.length} available)`,
96
+ initialValue: false,
97
+ });
98
+
99
+ if (!prompts.isCancel(setOptional) && setOptional) {
100
+ for (const param of optionalParams) {
101
+ if (param.type === "boolean") {
102
+ const val = await prompts.confirm({
103
+ message: param.description || param.name,
104
+ initialValue: param.default ?? false,
105
+ });
106
+ if (prompts.isCancel(val)) break;
107
+ if (val) params[param.name] = val;
108
+ } else if (param.type === "select" && param.options?.length) {
109
+ const val = await prompts.select({
110
+ message: param.description || param.name,
111
+ options: [
112
+ { value: "__skip__", label: "(skip)" },
113
+ ...param.options.map((o) => ({ value: o, label: o })),
114
+ ],
115
+ });
116
+ if (prompts.isCancel(val)) break;
117
+ if (val !== "__skip__") params[param.name] = val;
118
+ } else {
119
+ const val = await prompts.text({
120
+ message: `${param.name}${param.description ? ` (${param.description})` : ""}`,
121
+ placeholder: "leave empty to skip",
122
+ });
123
+ if (prompts.isCancel(val)) break;
124
+ if (val) params[param.name] = val;
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ if (globalOpts.trim) params.trim = true;
131
+ if (globalOpts.region) params.region = globalOpts.region;
132
+
133
+ const spinner = prompts.spinner();
134
+ spinner.start("Fetching...");
135
+
136
+ try {
137
+ const result = await callApi(apiKey, tool.method, tool.path, params);
138
+ spinner.stop("Done.");
139
+ printResult(result, globalOpts);
140
+ } catch (err) {
141
+ spinner.stop("Failed.");
142
+ console.error(chalk.red("Request failed."));
143
+ if (globalOpts.verbose && err?.message) console.error(chalk.dim(err.message));
144
+ process.exitCode = 1;
145
+ }
146
+
147
+ prompts.outro(chalk.dim("scrapecreators.com"));
148
+ }
package/src/output.js ADDED
@@ -0,0 +1,400 @@
1
+ import chalk from "chalk";
2
+ import Table from "cli-table3";
3
+ import { closeSync, constants, existsSync, lstatSync, openSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "fs";
4
+ import { basename, dirname, isAbsolute, relative, resolve } from "path";
5
+
6
+ const isTTY = process.stdout.isTTY;
7
+
8
+ // strips CSI, OSC, and single-character C1 escape sequences
9
+ const ANSI_RE = /[\x1b\x9b](?:\[[0-9;]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[A-Z@[\\\]^_])/g;
10
+
11
+ export function stripAnsi(str) {
12
+ return typeof str === "string" ? str.replace(ANSI_RE, "") : str;
13
+ }
14
+
15
+ export function sanitizeData(obj) {
16
+ if (typeof obj === "string") return stripAnsi(obj);
17
+ if (Array.isArray(obj)) return obj.map(sanitizeData);
18
+ if (typeof obj === "object" && obj !== null) {
19
+ const out = {};
20
+ for (const [k, v] of Object.entries(obj)) out[k] = sanitizeData(v);
21
+ return out;
22
+ }
23
+ return obj;
24
+ }
25
+
26
+ export function safePath(outputPath) {
27
+ if (!outputPath || !outputPath.trim()) {
28
+ throw new Error("--output path cannot be empty");
29
+ }
30
+
31
+ const cwdReal = realpathSync(process.cwd());
32
+ const resolved = resolve(outputPath);
33
+ const parent = dirname(resolved);
34
+
35
+ if (!existsSync(parent)) {
36
+ throw new Error(`--output parent directory does not exist (got: ${outputPath})`);
37
+ }
38
+
39
+ const parentReal = realpathSync(parent);
40
+ const dest = resolve(parentReal, basename(resolved));
41
+ const rel = relative(cwdReal, dest);
42
+ if (isAbsolute(rel) || rel.startsWith("..")) {
43
+ throw new Error(`--output path must be within the current directory (got: ${outputPath})`);
44
+ }
45
+
46
+ if (existsSync(resolved) && lstatSync(resolved).isSymbolicLink()) {
47
+ throw new Error(`--output path cannot be a symlink (got: ${outputPath})`);
48
+ }
49
+
50
+ return dest;
51
+ }
52
+
53
+ export function writeOutputFile(dest, output) {
54
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
55
+ const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | noFollow;
56
+ let fd;
57
+
58
+ try {
59
+ fd = openSync(dest, flags, 0o600);
60
+ } catch (err) {
61
+ const noFollowUnsupported = noFollow && (
62
+ err?.code === "EINVAL" ||
63
+ err?.code === "ENOTSUP" ||
64
+ err?.code === "EOPNOTSUPP"
65
+ );
66
+ if (noFollowUnsupported) {
67
+ const tmpPath = `${dest}.${process.pid}.${Date.now()}.tmp`;
68
+ writeFileSync(tmpPath, output, { encoding: "utf-8", mode: 0o600, flag: "wx" });
69
+ try {
70
+ if (existsSync(dest) && lstatSync(dest).isSymbolicLink()) {
71
+ throw new Error("refusing to overwrite symlinked output path");
72
+ }
73
+ renameSync(tmpPath, dest);
74
+ } catch (fallbackErr) {
75
+ try {
76
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
77
+ } catch {
78
+ // ignore cleanup errors
79
+ }
80
+ throw fallbackErr;
81
+ }
82
+ return;
83
+ }
84
+ throw err;
85
+ }
86
+
87
+ try {
88
+ writeFileSync(fd, output, "utf-8");
89
+ } finally {
90
+ closeSync(fd);
91
+ }
92
+ }
93
+
94
+ function redactUrlForLog(urlString) {
95
+ try {
96
+ const url = new URL(urlString);
97
+ return `${url.origin}${url.pathname}`;
98
+ } catch {
99
+ return "(invalid request url)";
100
+ }
101
+ }
102
+
103
+ export function resolveFormat(opts) {
104
+ if (opts.pretty) return "pretty";
105
+ if (opts.json) return "json";
106
+ if (opts.format && opts.format !== "auto") return opts.format;
107
+ return "json";
108
+ }
109
+
110
+ export function formatOutput(data, format, opts = {}) {
111
+ const d = opts.clean && format !== "csv" ? cleanData(data) ?? {} : data;
112
+ switch (format) {
113
+ case "json":
114
+ return JSON.stringify(d);
115
+ case "pretty":
116
+ return JSON.stringify(d, null, 2);
117
+ case "table":
118
+ return formatTable(d);
119
+ case "csv":
120
+ return formatCsv(data, opts);
121
+ case "markdown":
122
+ return formatMarkdown(d);
123
+ default:
124
+ return JSON.stringify(d);
125
+ }
126
+ }
127
+
128
+ export function printResult(result, opts = {}) {
129
+ const format = resolveFormat(opts);
130
+
131
+ if (!result.ok) {
132
+ printError(result, opts);
133
+ return;
134
+ }
135
+
136
+ const output = formatOutput(sanitizeData(result.data), format, opts);
137
+
138
+ if (opts.output) {
139
+ const dest = safePath(opts.output);
140
+ writeOutputFile(dest, output);
141
+ console.log(opts.output);
142
+ return;
143
+ }
144
+
145
+ console.log(output);
146
+
147
+ if (opts.verbose && isTTY) {
148
+ console.error(chalk.dim(`\n${result.status} ${redactUrlForLog(result.url)} (${result.elapsed}ms)`));
149
+ }
150
+ }
151
+
152
+ export function printError(result, opts = {}) {
153
+ const format = resolveFormat(opts);
154
+ const data = sanitizeData(result.data);
155
+
156
+ if (format === "json" || format === "pretty" || !isTTY) {
157
+ const structured = {
158
+ error: true,
159
+ code: `HTTP_${result.status}`,
160
+ message: typeof data === "object" ? data.message || data.error || JSON.stringify(data) : String(data),
161
+ status: result.status,
162
+ suggestion: getSuggestion(result.status),
163
+ };
164
+ console.error(JSON.stringify(structured, null, format === "pretty" ? 2 : 0));
165
+ } else {
166
+ console.error(chalk.red(`\nError ${result.status}: ${formatErrorMessage(data)}`));
167
+ const suggestion = getSuggestion(result.status);
168
+ if (suggestion) console.error(chalk.yellow(`Suggestion: ${suggestion}`));
169
+ }
170
+
171
+ process.exitCode = 1;
172
+ }
173
+
174
+ function getSuggestion(status) {
175
+ switch (status) {
176
+ case 401: return "Run 'scrapecreators auth login' to set your API key, or pass --api-key";
177
+ case 402: return "No credits remaining. Purchase more at https://app.scrapecreators.com/billing";
178
+ case 429: return "Rate limited. Wait a moment and retry.";
179
+ case 404: return "Endpoint or resource not found. Run 'scrapecreators list' to see available endpoints.";
180
+ default: return status >= 500 ? "Server error. Retry in a few seconds." : null;
181
+ }
182
+ }
183
+
184
+ function formatErrorMessage(data) {
185
+ if (typeof data === "string") return data;
186
+ return data.message || data.error || JSON.stringify(data);
187
+ }
188
+
189
+ function isNoisy(key, value) {
190
+ if (value === "" || value === null || value === undefined) return true;
191
+ if (typeof value === "boolean") return true;
192
+ if (typeof key === "string" && /setting/i.test(key)) return true;
193
+ if (Array.isArray(value) && value.length === 0) return true;
194
+ if ((value === 0 || value === "0") && !/like|count|heart|follower|comment|repl|repost|post|tag|up|score|rat|view|down/i.test(key)) return true;
195
+ return false;
196
+ }
197
+
198
+ function cleanData(obj) {
199
+ if (Array.isArray(obj)) {
200
+ const cleaned = obj.map(cleanData).filter((v) => v !== undefined);
201
+ return cleaned.length ? cleaned : undefined;
202
+ }
203
+ if (typeof obj === "object" && obj !== null) {
204
+ const result = {};
205
+ for (const [key, value] of Object.entries(obj)) {
206
+ if (typeof value === "object" && value !== null) {
207
+ const nested = cleanData(value);
208
+ if (nested !== undefined) result[key] = nested;
209
+ } else if (!isNoisy(key, value)) {
210
+ result[key] = value;
211
+ }
212
+ }
213
+ return Object.keys(result).length ? result : undefined;
214
+ }
215
+ return obj;
216
+ }
217
+
218
+ function flattenObject(obj, prefix = "") {
219
+ const rows = [];
220
+ for (const [key, value] of Object.entries(obj)) {
221
+ const fullKey = prefix ? `${prefix}.${key}` : key;
222
+ if (isNoisy(key, value)) continue;
223
+
224
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
225
+ rows.push(...flattenObject(value, fullKey));
226
+ } else if (Array.isArray(value)) {
227
+ rows.push([fullKey, `[${value.length} items]`]);
228
+ } else {
229
+ rows.push([fullKey, String(value)]);
230
+ }
231
+ }
232
+ return rows;
233
+ }
234
+
235
+ function formatTable(data) {
236
+ if (Array.isArray(data)) {
237
+ if (data.length === 0) return chalk.dim("(empty array)");
238
+ return formatArrayAsTable(data);
239
+ }
240
+
241
+ if (typeof data === "object" && data !== null) {
242
+ const arrayKey = Object.keys(data).find((k) => Array.isArray(data[k]) && data[k].length > 0);
243
+ if (arrayKey) {
244
+ const meta = Object.entries(data)
245
+ .filter(([k]) => k !== arrayKey)
246
+ .map(([k, v]) => `${chalk.bold(k)}: ${typeof v === "object" ? JSON.stringify(v) : v}`)
247
+ .join(" ");
248
+ if (meta) console.error(chalk.dim(meta));
249
+ return formatArrayAsTable(data[arrayKey]);
250
+ }
251
+
252
+ return formatObjectAsTable(data);
253
+ }
254
+
255
+ return String(data);
256
+ }
257
+
258
+ function formatArrayAsTable(arr) {
259
+ const sample = arr[0];
260
+ if (typeof sample !== "object" || sample === null) {
261
+ return arr.map((v) => String(v)).join("\n");
262
+ }
263
+
264
+ const keys = Object.keys(sample).filter((k) => {
265
+ const val = sample[k];
266
+ return (typeof val !== "object" || val === null) && !isNoisy(k, val);
267
+ }).slice(0, 10);
268
+
269
+ if (keys.length === 0) return JSON.stringify(arr, null, 2);
270
+
271
+ const table = new Table({
272
+ head: keys.map((k) => chalk.cyan(k)),
273
+ wordWrap: true,
274
+ wrapOnWordBoundary: false,
275
+ });
276
+
277
+ for (const row of arr.slice(0, 50)) {
278
+ table.push(keys.map((k) => truncate(String(row[k] ?? ""), 60)));
279
+ }
280
+
281
+ let out = table.toString();
282
+ if (arr.length > 50) out += chalk.dim(`\n... and ${arr.length - 50} more rows`);
283
+ return out;
284
+ }
285
+
286
+ function formatObjectAsTable(obj) {
287
+ const rows = flattenObject(obj);
288
+ const table = new Table();
289
+ for (const [key, value] of rows) {
290
+ table.push({ [chalk.cyan(key)]: truncate(value, 80) });
291
+ }
292
+ return table.toString();
293
+ }
294
+
295
+ function flattenForCsv(obj, prefix = "") {
296
+ const result = {};
297
+ for (const [key, value] of Object.entries(obj)) {
298
+ const fullKey = prefix ? `${prefix}.${key}` : key;
299
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
300
+ Object.assign(result, flattenForCsv(value, fullKey));
301
+ } else if (Array.isArray(value)) {
302
+ result[fullKey] = JSON.stringify(value);
303
+ } else {
304
+ result[fullKey] = String(value ?? "");
305
+ }
306
+ }
307
+ return result;
308
+ }
309
+
310
+ function flattenForCsvTrimmed(obj, prefix = "") {
311
+ const result = {};
312
+ for (const [key, value] of Object.entries(obj)) {
313
+ const fullKey = prefix ? `${prefix}.${key}` : key;
314
+ if (isNoisy(key, value)) continue;
315
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
316
+ Object.assign(result, flattenForCsvTrimmed(value, fullKey));
317
+ } else if (Array.isArray(value)) {
318
+ result[fullKey] = `[${value.length} items]`;
319
+ } else {
320
+ result[fullKey] = String(value ?? "");
321
+ }
322
+ }
323
+ return result;
324
+ }
325
+
326
+ function extractRows(data) {
327
+ if (Array.isArray(data)) return data;
328
+
329
+ if (typeof data === "object" && data !== null) {
330
+ const arrayKey = Object.keys(data).find(
331
+ (k) => Array.isArray(data[k]) && data[k].length > 0 && typeof data[k][0] === "object"
332
+ );
333
+ if (arrayKey) {
334
+ const meta = {};
335
+ for (const [k, v] of Object.entries(data)) {
336
+ if (k === arrayKey) continue;
337
+ if (typeof v !== "object" || v === null) meta[k] = String(v ?? "");
338
+ }
339
+ return data[arrayKey].map((item) => ({ ...meta, ...item }));
340
+ }
341
+ return [data];
342
+ }
343
+
344
+ return null;
345
+ }
346
+
347
+ function formatCsv(data, opts = {}) {
348
+ const rows = extractRows(data);
349
+ if (!rows) return String(data ?? "");
350
+ if (rows.length === 0) return "";
351
+
352
+ const flatten = opts.clean ? flattenForCsvTrimmed : flattenForCsv;
353
+ const flatRows = rows.map(flatten);
354
+
355
+ const keySet = new Set();
356
+ for (const r of flatRows) for (const k of Object.keys(r)) keySet.add(k);
357
+ const keys = [...keySet];
358
+
359
+ const header = keys.map(csvEscape).join(",");
360
+ const lines = flatRows.map((row) =>
361
+ keys.map((k) => csvEscape(row[k] ?? "")).join(",")
362
+ );
363
+ return [header, ...lines].join("\n");
364
+ }
365
+
366
+ function formatMarkdown(data) {
367
+ if (Array.isArray(data) && data.length > 0 && typeof data[0] === "object") {
368
+ const flatRows = data.slice(0, 100).map(flattenForCsv);
369
+ const keys = Object.keys(flatRows[0]).slice(0, 10);
370
+
371
+ const header = `| ${keys.join(" | ")} |`;
372
+ const sep = `| ${keys.map(() => "---").join(" | ")} |`;
373
+ const rows = flatRows.map((row) =>
374
+ `| ${keys.map((k) => (row[k] ?? "").replace(/\|/g, "\\|")).join(" | ")} |`
375
+ );
376
+ return [header, sep, ...rows].join("\n");
377
+ }
378
+
379
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) {
380
+ const flat = flattenForCsv(data);
381
+ const rows = Object.entries(flat).map(([k, v]) =>
382
+ `| ${k} | ${v.replace(/\|/g, "\\|")} |`
383
+ );
384
+ return ["| Key | Value |", "| --- | --- |", ...rows].join("\n");
385
+ }
386
+
387
+ return "```json\n" + JSON.stringify(data, null, 2) + "\n```";
388
+ }
389
+
390
+ export function csvEscape(str) {
391
+ const cell = /^[=+\-@\t\r]/.test(str) ? `'${str}` : str;
392
+ if (cell.includes(",") || cell.includes('"') || cell.includes("\n")) {
393
+ return `"${cell.replace(/"/g, '""')}"`;
394
+ }
395
+ return cell;
396
+ }
397
+
398
+ function truncate(str, max) {
399
+ return str.length > max ? str.slice(0, max - 1) + "…" : str;
400
+ }