apick-api 4.3.1 → 4.5.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/src/index.cjs CHANGED
@@ -1,1351 +1,1354 @@
1
- 'use strict';
2
-
3
- const DEFAULT_BASE_URL = 'https://apick.app';
4
- const DEFAULT_TIMEOUT_MS = 30_000;
5
- const MAX_OCR_BYTES = 50 * 1024 * 1024;
6
- const MAX_IMAGE_AI_BYTES = 50 * 1024 * 1024;
7
- const IMAGE_AI_SIZES = Object.freeze(['1024x1024', '1536x1024', '1024x1536', '1152x864', '864x1152']);
8
- const IMAGE_AI_SIZE_SET = new Set(IMAGE_AI_SIZES);
9
- // 품질별 장당 고정가: basic 40P(기본) · advanced 350P · premium 1,400P
10
- const IMAGE_AI_QUALITIES = Object.freeze(['basic', 'advanced', 'premium']);
11
- const TTS_VOICE_IDS = Object.freeze([
12
- 'v2_ann_m_30s_01', 'v2_ann_m_30s_02', 'v2_ann_m_30s_04', 'v2_ann_m_30s_05', 'v2_ann_f_30s_02', 'v2_ann_f_30s_03', 'v2_ann_f_30s_04', 'v2_ann_f_30s_05', 'v2_m_teen_01', 'v2_m_young_01', 'v2_m_mid_01', 'v2_m_senior_01', 'v2_f_young_01', 'v2_f_senior_01'
13
- ]);
14
- const TTS_VOICE_ID_SET = new Set(TTS_VOICE_IDS);
15
- const AUTH_PROVIDERS = Object.freeze([
16
- 'kakao', 'naver', 'toss', 'pass', 'samsung', 'kb', 'shinhan', 'hana', 'woori', 'ibk', 'nh', 'kakaobank', 'banksalad'
17
- ]);
18
- const AUTH_PROVIDER_SET = new Set(AUTH_PROVIDERS);
19
-
20
- const SERVICE_DEFINITIONS = Object.freeze({
21
- businessDetails: { endpoint: '/rest/biz_detail', timeoutMs: 50_000, output: 'json' },
22
- ventureBusiness: { endpoint: '/rest/venture_biz_info', timeoutMs: 50_000, output: 'json' },
23
- trackParcel: { endpoint: '/rest/parcel_tracking', timeoutMs: 30_000, output: 'json' },
24
- trackParcelAuto: { endpoint: '/rest/parcel_tracking_auto', timeoutMs: 30_000, output: 'json' },
25
- validateEmail: { endpoint: '/rest/check_email_valid', timeoutMs: 20_000, output: 'json' },
26
- validatePhone: { endpoint: '/rest/check_phone_valid', timeoutMs: 20_000, output: 'json' },
27
- holidays: { endpoint: '/rest/holiday_info', timeoutMs: 35_000, output: 'json' },
28
- searchAddress: { endpoint: '/rest/search_juso', timeoutMs: 20_000, output: 'json' },
29
- ocr: { endpoint: '/rest/ocr', timeoutMs: 35_000, output: 'json' },
30
- maskResidentNumber: { endpoint: '/rest/hide_rrn', timeoutMs: 35_000, output: 'binary', filename: 'masked.png' },
31
- maskResidenceCard: { endpoint: '/rest/identity_document_residence_card', timeoutMs: 35_000, output: 'json' },
32
- maskPassport: { endpoint: '/rest/identity_document_passport', timeoutMs: 35_000, output: 'json' },
33
- maskIdCard: { endpoint: '/rest/identity_document_id_card', timeoutMs: 35_000, output: 'json' },
34
- maskDriverLicense: { endpoint: '/rest/identity_document_driver_license', timeoutMs: 35_000, output: 'json' },
35
- dnsLookup: { endpoint: '/rest/nslookup', timeoutMs: 16_000, output: 'json' },
36
- geolocate: { endpoint: '/rest/location', timeoutMs: 35_000, output: 'json' },
37
- whois: { endpoint: '/rest/whois', timeoutMs: 35_000, output: 'json' },
38
- googleSearch: { endpoint: '/rest/google_search', timeoutMs: 50_000, output: 'json' },
39
- googleImageSearch: { endpoint: '/rest/google_image_search', timeoutMs: 50_000, output: 'json' },
40
- googleNewsSearch: { endpoint: '/rest/google_news_search', timeoutMs: 50_000, output: 'json' },
41
- googleShoppingSearch: { endpoint: '/rest/google_shopping_search', timeoutMs: 50_000, output: 'json' },
42
- googleMapsSearch: { endpoint: '/rest/google_maps_search', timeoutMs: 100_000, output: 'json' },
43
- googleRankCheck: { endpoint: '/rest/google_rank_check', timeoutMs: 50_000, output: 'json' },
44
- instagramProfile: { endpoint: '/rest/instagram_profile', timeoutMs: 110_000, output: 'json' },
45
- instagramPost: { endpoint: '/rest/instagram_post', timeoutMs: 110_000, output: 'json' },
46
- tiktokProfile: { endpoint: '/rest/tiktok_profile', timeoutMs: 110_000, output: 'json' },
47
- amazonProduct: { endpoint: '/rest/amazon_product', timeoutMs: 110_000, output: 'json' },
48
- xProfile: { endpoint: '/rest/x_profile', timeoutMs: 110_000, output: 'json' },
49
- xPost: { endpoint: '/rest/x_post', timeoutMs: 110_000, output: 'json' },
50
- getScrapeJob: { endpoint: '/rest/scrape_jobs', timeoutMs: 35_000, output: 'json' },
51
- screenshot: { endpoint: '/rest/url_screenshot', timeoutMs: 75_000, output: 'binary', filename: 'screenshot.jpeg' },
52
- youtubeMetadata: { endpoint: '/rest/youtube_metadata', timeoutMs: 60_000, output: 'json' },
53
- youtubeThumbnail: { endpoint: '/rest/youtube_thumbnail', timeoutMs: 60_000, output: 'binary', filename: 'thumbnail.jpg' },
54
- youtubeSubtitleList: { endpoint: '/rest/youtube_subtitle_list', timeoutMs: 60_000, output: 'json' },
55
- youtubeSubtitle: { endpoint: '/rest/youtube_subtitle', timeoutMs: 60_000, output: 'binary', filename: 'subtitle.vtt' },
56
- youtubeSearch: { endpoint: '/rest/youtube_search', timeoutMs: 60_000, output: 'json' },
57
- youtubeChannel: { endpoint: '/rest/youtube_channel', timeoutMs: 60_000, output: 'json' },
58
- youtubePlaylist: { endpoint: '/rest/youtube_playlist', timeoutMs: 60_000, output: 'json' },
59
- youtubeHashtag: { endpoint: '/rest/youtube_hashtag', timeoutMs: 60_000, output: 'json' },
60
- youtubeFormats: { endpoint: '/rest/youtube_formats', timeoutMs: 60_000, output: 'json' },
61
- youtubeComments: { endpoint: '/rest/youtube_comments', timeoutMs: 120_000, output: 'json' },
62
- downloadYoutubeVideo: { endpoint: '/rest/download_youtube_video', timeoutMs: 610_000, output: 'json' },
63
- downloadYoutubeAudio: { endpoint: '/rest/youtube_audio_download', timeoutMs: 610_000, output: 'json' },
64
- createTtsJob: { endpoint: '/rest/tts/jobs', timeoutMs: 35_000, output: 'json' },
65
- createVideoJob: { endpoint: '/rest/seedance/jobs', timeoutMs: 60_000, output: 'json' },
66
- htmlToPdf: { endpoint: '/rest/html_to_pdf', timeoutMs: 25_000, output: 'binary', filename: 'document.pdf' },
67
- jsonToExcel: { endpoint: '/rest/json_to_excel', timeoutMs: 45_000, output: 'binary', filename: 'data.xlsx' },
68
- summarize: { endpoint: '/rest/llm/text_summary', timeoutMs: 75_000, output: 'json' },
69
- polish: { endpoint: '/rest/llm/text_polish', timeoutMs: 105_000, output: 'json' },
70
- generateImages: { endpoint: '/rest/image-generation/generate', timeoutMs: 190_000, output: 'json' },
71
- requestEmployment: { endpoint: '/rest/req_employment', timeoutMs: 35_000, output: 'json' },
72
- requestPersonalIncome: { endpoint: '/rest/req_personal_income', timeoutMs: 35_000, output: 'json' },
73
- requestNpsJoinHistory: { endpoint: '/rest/req_nps_join_history', timeoutMs: 35_000, output: 'json' },
74
- requestDrivingLicense: { endpoint: '/rest/req_driving_license', timeoutMs: 35_000, output: 'json' },
75
- requestHealthCheckup: { endpoint: '/rest/req_health_checkup', timeoutMs: 35_000, output: 'json' },
76
- requestCashReceiptDeduction: { endpoint: '/rest/req_cash_receipt_deduction', timeoutMs: 35_000, output: 'json' },
77
- requestTaxReturnHistory: { endpoint: '/rest/req_tax_return_history', timeoutMs: 35_000, output: 'json' }
78
- });
79
-
80
- const SERVICES = Object.freeze(Object.fromEntries(
81
- Object.entries(SERVICE_DEFINITIONS).map(([name, definition]) => [
82
- name,
83
- Object.freeze({ endpoint: definition.endpoint, output: definition.output })
84
- ])
85
- ));
86
-
87
- function normalizeSubagentId(value,prefix){if(typeof value!=='string'||!new RegExp('^'+prefix+'_[a-f0-9]{32}$').test(value))throw new TypeError('Invalid subagent identifier.');return value;}
88
- function redact(value, apiKey) {
89
- let text = String(value || '');
90
- if (apiKey) text = text.split(apiKey).join('***');
91
- return text.replace(/(Authorization\s*:\s*Bearer\s*)\S+/gi, '$1***').replace(/(CL_AUTH_KEY\s*[:=]\s*)\S+/gi, '$1***');
92
- }
93
-
94
- // SNS 프로필 입력: 주소면 url, 아니면 username 으로 보낸다.
95
- function profileInput(value) {
96
- const text = requiredString('usernameOrUrl', value, 2048);
97
- return /^https?:\/\//i.test(text) ? { url: text } : { username: text.replace(/^@/, '') };
98
- }
99
-
100
- // 구글 지도 장소 입력: 주소면 url, 아니면 place_id(googleMapsSearch 결과의 place_id)로 보낸다.
101
- function placeInput(value) {
102
- const text = requiredString('placeIdOrUrl', value, 2048);
103
- return /^https?:\/\//i.test(text) ? { url: text } : { place_id: text };
104
- }
105
-
106
- // 아마존 상품 입력: 주소면 url, 10자리 ASIN 이면 asin 으로 보낸다.
107
- function amazonInput(value) {
108
- const text = requiredString('urlOrAsin', value, 2048);
109
- return /^https?:\/\//i.test(text) ? { url: text } : { asin: text.toUpperCase() };
110
- }
111
-
112
- const SCRAPE_JOB_PRODUCTS = Object.freeze({ instagram_posts: 100, instagram_comments: 15, tiktok_search: 50, tiktok_video: 1, tiktok_comments: 100, amazon_reviews: 100, google_maps_place: 1 });
113
-
114
- // 수집 작업 공통 옵션: maxResults(상품별 상한), idempotencyKey(응답을 못 받아 다시 보낼 때 같은 접수로 처리).
115
- function scrapeJobOptions(product, payload, options) {
116
- const config = options || {};
117
- if (config.maxResults !== undefined && SCRAPE_JOB_PRODUCTS[product] > 1) {
118
- const max = Number(config.maxResults);
119
- if (!Number.isInteger(max) || max < 1 || max > SCRAPE_JOB_PRODUCTS[product]) throw new RangeError(`maxResults must be an integer from 1 to ${SCRAPE_JOB_PRODUCTS[product]}.`);
120
- payload.max_results = max;
121
- }
122
- if (config.idempotencyKey !== undefined) {
123
- if (typeof config.idempotencyKey !== 'string' || !/^[A-Za-z0-9_.:-]{8,128}$/.test(config.idempotencyKey)) throw new TypeError('idempotencyKey must be 8-128 characters of letters, digits, and . _ : -');
124
- payload.idempotency_key = config.idempotencyKey;
125
- }
126
- return payload;
127
- }
128
-
129
- function requiredString(name, value, maxLength) {
130
- if (typeof value !== 'string' || !value.trim()) {
131
- throw new TypeError(`${name} must be a non-empty string.`);
132
- }
133
- const normalized = value.trim();
134
- if (maxLength && normalized.length > maxLength) {
135
- throw new RangeError(`${name} must not exceed ${maxLength} characters.`);
136
- }
137
- return normalized;
138
- }
139
-
140
- function positiveInteger(name, value, defaultValue) {
141
- if (value === undefined || value === null || value === '') return defaultValue;
142
- const number = Number(value);
143
- if (!Number.isInteger(number) || number < 1) {
144
- throw new RangeError(`${name} must be a positive integer.`);
145
- }
146
- return number;
147
- }
148
-
149
- function normalizeBusinessNumber(value) {
150
- const normalized = requiredString('businessNumber', value).replace(/-/g, '');
151
- if (!/^\d{10}$/.test(normalized)) {
152
- throw new TypeError('businessNumber must contain exactly 10 digits.');
153
- }
154
- return normalized;
155
- }
156
-
157
- function normalizeUrl(value) {
158
- const input = requiredString('url', value);
159
- let parsed;
160
- try {
161
- parsed = new URL(input);
162
- } catch {
163
- throw new TypeError('url must be a valid HTTP or HTTPS URL.');
164
- }
165
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
166
- throw new TypeError('url must use HTTP or HTTPS.');
167
- }
168
- return parsed.toString();
169
- }
170
-
171
- function normalizeTtsJobId(value) {
172
- const jobId = requiredString('jobId', value);
173
- if (!/^[a-f0-9]{32}$/.test(jobId)) throw new TypeError('jobId must be a 32-character lowercase hexadecimal string.');
174
- return jobId;
175
- }
176
-
177
- function normalizeTtsVoice(value) {
178
- const voiceId = requiredString('voiceId', value, 160);
179
- if (TTS_VOICE_ID_SET.has(voiceId)) throw new RangeError('This voiceId has retired. Select a current Gemini voice with listGeminiTtsVoices().');
180
- return voiceId;
181
- }
182
-
183
- function normalizeAuthProvider(value) {
184
- const provider = requiredString('authProvider', value).toLowerCase();
185
- if (!AUTH_PROVIDER_SET.has(provider)) throw new RangeError('authProvider must be one of the supported simple-auth providers.');
186
- return provider;
187
- }
188
-
189
- function normalizeBirthDate(value) {
190
- const birthDate = requiredString('birthDate', value);
191
- if (!/^(18|19|20)\d{6}$/.test(birthDate)) throw new TypeError('birthDate must be 8 digits (YYYYMMDD).');
192
- return birthDate;
193
- }
194
-
195
- function normalizeKoreanMobile(value) {
196
- const phone = requiredString('phone', value).replace(/[^0-9]/g, '');
197
- if (!/^01[0-9]{8,9}$/.test(phone)) throw new TypeError('phone must be a Korean mobile number (digits only).');
198
- return phone;
199
- }
200
-
201
- function normalizeTransactionId(value) {
202
- const transactionId = requiredString('transactionId', value);
203
- if (!/^[a-f0-9]{32}$/.test(transactionId)) throw new TypeError('transactionId must be a 32-character lowercase hexadecimal string.');
204
- return transactionId;
205
- }
206
-
207
- function normalizeYearMonth(name, value) {
208
- if (value === undefined || value === null || value === '') return undefined;
209
- const text = requiredString(name, value);
210
- if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(text)) throw new TypeError(`${name} must be in YYYY-MM format.`);
211
- return text;
212
- }
213
-
214
- // 정해진 값 중 하나. 비어 있으면 서버 기본값을 쓰도록 보내지 않는다.
215
- function optionalChoice(name, value, choices) {
216
- if (value === undefined || value === null || value === '') return undefined;
217
- const text = String(value);
218
- if (!choices.includes(text)) throw new RangeError(`${name} must be one of ${choices.join(', ')}.`);
219
- return text;
220
- }
221
-
222
- // 구간 시작·끝: 초(숫자) 또는 시:분:초 문자열.
223
- function optionalTimePoint(name, value) {
224
- if (value === undefined || value === null || value === '') return undefined;
225
- const text = String(value).trim();
226
- if (!/^(?:\d+(?:\.\d+)?|(?:\d{1,2}:)?\d{1,2}:\d{1,2}(?:\.\d+)?)$/.test(text)) throw new TypeError(`${name} must be seconds or [hh:]mm:ss.`);
227
- return text;
228
- }
229
-
230
- function compact(payload) {
231
- return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined));
232
- }
233
-
234
- function optionalRangeInteger(name, value, min, max) {
235
- if (value === undefined || value === null || value === '') return undefined;
236
- const number = Number(value);
237
- if (!Number.isInteger(number) || number < min || number > max) {
238
- throw new RangeError(`${name} must be an integer between ${min} and ${max}.`);
239
- }
240
- return number;
241
- }
242
-
243
- // 재직·소득·연금·면허·건강검진 등 간편인증 기반 조회 상품의 공통 입력.
244
- // 서버는 최상위 평면 필드명을 그대로 받으므로 여기서 만든 객체가 폼 필드 이름이 된다.
245
- function dataRequestInput(input) {
246
- const config = input || {};
247
- return {
248
- name: requiredString('name', config.name),
249
- birthDate: normalizeBirthDate(config.birthDate),
250
- phone: normalizeKoreanMobile(config.phone),
251
- authProvider: normalizeAuthProvider(config.authProvider)
252
- };
253
- }
254
-
255
- const SKILL_CATEGORIES = Object.freeze(['data', 'ai', 'dev', 'document', 'marketing', 'finance', 'productivity', 'video', 'etc']);
256
- const SKILL_CATEGORY_SET = new Set(SKILL_CATEGORIES);
257
- const SKILL_SORTS = Object.freeze(['recommended', 'popular', 'used', 'likes', 'rating', 'new', 'mine', 'liked']);
258
- const SKILL_SORT_SET = new Set(SKILL_SORTS);
259
-
260
- function normalizeSkillId(value) {
261
- return encodeURIComponent(requiredString('skillId', value, 80));
262
- }
263
-
264
- function normalizeSkillRunId(value) {
265
- return encodeURIComponent(requiredString('runId', value, 64));
266
- }
267
-
268
- function skillInput(value) {
269
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('input must be a plain object matching the Skill input_schema.');
270
- return value;
271
- }
272
-
273
- function optionalSkillText(name, value, maxLength) {
274
- if (value === undefined || value === null || value === '') return undefined;
275
- return requiredString(name, String(value), maxLength);
276
- }
277
-
278
- function numberOrNull(value) {
279
- if (value === undefined || value === null || value === '') return null;
280
- const number = Number(value);
281
- return Number.isFinite(number) ? number : null;
282
- }
283
-
284
- function responseMeta(api, headers) {
285
- const metadata = api && typeof api === 'object' ? api : {};
286
- const readHeader = (name) => headers && typeof headers.get === 'function' ? headers.get(name) : null;
287
- return Object.freeze({
288
- cost: numberOrNull(metadata.cost ?? readHeader('cost')),
289
- durationMs: numberOrNull(metadata.ms ?? readHeader('ms'))
290
- });
291
- }
292
-
293
- function publicErrorMessage(body, fallback) {
294
- if (!body || typeof body !== 'object') return fallback;
295
- const candidates = [
296
- body.data && body.data.error,
297
- body.result && body.result.error,
298
- body.error,
299
- body.message,
300
- body.msg
301
- ];
302
- for (const candidate of candidates) {
303
- if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
304
- }
305
- return fallback;
306
- }
307
-
308
- function parseFilename(headerValue, fallback) {
309
- if (typeof headerValue !== 'string') return fallback;
310
- const encoded = /filename\*=UTF-8''([^;]+)/i.exec(headerValue);
311
- if (encoded) {
312
- try { return decodeURIComponent(encoded[1]).replace(/[\\/]/g, '_'); } catch { /* use fallback parser */ }
313
- }
314
- const plain = /filename="?([^";]+)"?/i.exec(headerValue);
315
- return plain ? plain[1].trim().replace(/[\\/]/g, '_') : fallback;
316
- }
317
-
318
- function inferImageType(filename) {
319
- const lower = String(filename || '').toLowerCase();
320
- if (lower.endsWith('.png')) return 'image/png';
321
- if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
322
- if (lower.endsWith('.webp')) return 'image/webp';
323
- return '';
324
- }
325
-
326
- async function normalizeImage(image, options) {
327
- const config = options || {};
328
- let blob;
329
- let filename = config.filename || '';
330
-
331
- if (typeof image === 'string') {
332
- const [{ readFile }, path] = await Promise.all([
333
- import('node:fs/promises'),
334
- import('node:path')
335
- ]);
336
- const bytes = await readFile(image);
337
- filename = filename || path.basename(image);
338
- blob = new Blob([bytes], { type: config.contentType || inferImageType(filename) });
339
- } else if (typeof Blob !== 'undefined' && image instanceof Blob) {
340
- blob = image;
341
- filename = filename || (typeof image.name === 'string' ? image.name : 'image.png');
342
- } else if (image && typeof image.arrayBuffer === 'function') {
343
- const bytes = await image.arrayBuffer();
344
- filename = filename || (typeof image.name === 'string' ? image.name : 'image.png');
345
- blob = new Blob([bytes], { type: config.contentType || image.type || inferImageType(filename) });
346
- } else if (image instanceof ArrayBuffer || ArrayBuffer.isView(image)) {
347
- filename = filename || 'image.png';
348
- blob = new Blob([image], { type: config.contentType || inferImageType(filename) });
349
- } else {
350
- throw new TypeError('image must be a file path, Blob, ArrayBuffer, or typed array.');
351
- }
352
-
353
- const contentType = config.contentType || blob.type || inferImageType(filename);
354
- const allowedTypes = config.allowedTypes || ['image/png', 'image/jpeg'];
355
- if (!allowedTypes.includes(contentType)) {
356
- throw new TypeError(config.typeError || 'OCR supports PNG and JPEG images only.');
357
- }
358
- if (blob.size > (config.maxBytes || MAX_OCR_BYTES)) {
359
- throw new RangeError('image must not exceed 50 MB.');
360
- }
361
- return { blob, filename, contentType };
362
- }
363
-
364
- class ApickApiError extends Error {
365
- constructor(message, options) {
366
- super(message);
367
- this.name = 'ApickApiError';
368
- this.status = options && options.status || 0;
369
- this.code = options && options.code || 'APICK_API_ERROR';
370
- this.serviceCode = options && options.serviceCode || undefined;
371
- if (options && options.details !== undefined) this.details = options.details;
372
- }
373
-
374
- toJSON() {
375
- return {
376
- name: this.name,
377
- message: this.message,
378
- status: this.status,
379
- code: this.code,
380
- serviceCode: this.serviceCode
381
- };
382
- }
383
- }
384
-
385
- class ApickBinaryResult {
386
- constructor(bytes, options) {
387
- this.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
388
- this.contentType = options.contentType || 'application/octet-stream';
389
- this.filename = options.filename || 'result.bin';
390
- this.meta = options.meta;
391
- Object.freeze(this.meta);
392
- }
393
-
394
- get size() {
395
- return this.bytes.byteLength;
396
- }
397
-
398
- toArrayBuffer() {
399
- return this.bytes.buffer.slice(this.bytes.byteOffset, this.bytes.byteOffset + this.bytes.byteLength);
400
- }
401
-
402
- toBlob() {
403
- return new Blob([this.bytes], { type: this.contentType });
404
- }
405
-
406
- async save(filePath) {
407
- const target = requiredString('filePath', filePath);
408
- const { writeFile } = await import('node:fs/promises');
409
- await writeFile(target, this.bytes);
410
- return target;
411
- }
412
- }
413
-
414
- class ApickClient {
415
- #apiKey;
416
- #fetch;
417
- #baseUrl;
418
- #timeoutMs;
419
-
420
- constructor(apiKeyOrOptions) {
421
- const options = typeof apiKeyOrOptions === 'string'
422
- ? { apiKey: apiKeyOrOptions }
423
- : (apiKeyOrOptions || {});
424
-
425
- this.#apiKey = requiredString('apiKey', options.apiKey);
426
- this.#fetch = options.fetch || globalThis.fetch;
427
- if (typeof this.#fetch !== 'function') {
428
- throw new TypeError('A Fetch API implementation is required. Use Node.js 18+ or pass options.fetch.');
429
- }
430
-
431
- let baseUrl;
432
- try {
433
- baseUrl = new URL(options.baseUrl || DEFAULT_BASE_URL);
434
- } catch {
435
- throw new TypeError('baseUrl must be a valid HTTPS URL.');
436
- }
437
- if (baseUrl.protocol !== 'https:' || baseUrl.username || baseUrl.password) {
438
- throw new TypeError('baseUrl must be an HTTPS URL without embedded credentials.');
439
- }
440
- this.#baseUrl = baseUrl.toString().replace(/\/+$/, '');
441
- this.#timeoutMs = options.timeoutMs === undefined
442
- ? null
443
- : positiveInteger('timeoutMs', options.timeoutMs);
444
- }
445
-
446
- async _call(serviceName, payload, formData, requestOptions) {
447
- const baseDefinition = SERVICE_DEFINITIONS[serviceName];
448
- if (!baseDefinition) throw new TypeError(`Unknown APICK service: ${serviceName}`);
449
- const definition = Object.assign({}, baseDefinition, requestOptions || {});
450
-
451
- const controller = new AbortController();
452
- const timeoutMs = this.#timeoutMs || definition.timeoutMs || DEFAULT_TIMEOUT_MS;
453
- const timer = setTimeout(() => controller.abort(), timeoutMs);
454
- const headers = {
455
- Accept: definition.output === 'binary' ? '*/*' : 'application/json',
456
- Authorization: `Bearer ${this.#apiKey}`
457
- };
458
- const method = definition.method || 'POST';
459
- if (definition.idempotencyKey) headers['X-Idempotency-Key'] = requiredString('idempotencyKey', definition.idempotencyKey, 128);
460
- const request = {
461
- method,
462
- headers,
463
- signal: controller.signal,
464
- redirect: 'error'
465
- };
466
- if (method !== 'GET') {
467
- if (definition.json) { headers['Content-Type']='application/json'; request.body=JSON.stringify(payload||{}); }
468
- else request.body = formData || require('./form.cjs').createForm(payload || {}, definition.endpoint);
469
- }
470
-
471
- let response;
472
- try {
473
- response = await this.#fetch(this.#baseUrl + definition.endpoint, request);
474
- } catch (error) {
475
- const timedOut = controller.signal.aborted || error && error.name === 'AbortError';
476
- throw new ApickApiError(
477
- timedOut ? `APICK request timed out after ${timeoutMs} ms.` : redact(error && error.message || 'APICK network request failed.', this.#apiKey),
478
- { code: timedOut ? 'APICK_TIMEOUT' : 'APICK_NETWORK_ERROR' }
479
- );
480
- } finally {
481
- clearTimeout(timer);
482
- }
483
-
484
- const contentType = String(response.headers.get('content-type') || '').toLowerCase();
485
- const isJson = contentType.includes('application/json') || contentType.includes('+json');
486
-
487
- if (definition.output === 'binary' && !isJson && response.ok) {
488
- const bytes = new Uint8Array(await response.arrayBuffer());
489
- return new ApickBinaryResult(bytes, {
490
- contentType: contentType.split(';')[0] || 'application/octet-stream',
491
- filename: parseFilename(response.headers.get('content-disposition'), definition.filename),
492
- meta: responseMeta(null, response.headers)
493
- });
494
- }
495
-
496
- const raw = await response.text();
497
- let body;
498
- try {
499
- body = raw ? JSON.parse(raw) : null;
500
- } catch {
501
- throw new ApickApiError(`APICK returned an unexpected response (HTTP ${response.status}).`, {
502
- status: response.status,
503
- code: 'APICK_INVALID_RESPONSE'
504
- });
505
- }
506
-
507
- const failureMessage = publicErrorMessage(body, 'APICK request failed.');
508
- const bodyFailed = Boolean(
509
- body && body.data && body.data.error
510
- || body && body.result && body.result.error
511
- || body && body.api && body.api.success === false
512
- );
513
- if (!response.ok || bodyFailed || definition.output === 'binary') {
514
- throw new ApickApiError(failureMessage, {
515
- status: response.status,
516
- code: response.status === 401 ? 'APICK_AUTH_ERROR' : 'APICK_API_ERROR',
517
- serviceCode: body && body.data && typeof (body.data.code || body.data.error_code) === 'string' ? (body.data.code || body.data.error_code) : undefined
518
- });
519
- }
520
-
521
- return Object.freeze({
522
- data: body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body,
523
- meta: responseMeta(body && body.api, response.headers)
524
- });
525
- }
526
-
527
- businessDetails(businessNumber) {
528
- return this._call('businessDetails', { biz_no: normalizeBusinessNumber(businessNumber) });
529
- }
530
-
531
- ventureBusiness(businessNumber) {
532
- return this._call('ventureBusiness', { biz_no: normalizeBusinessNumber(businessNumber) });
533
- }
534
-
535
- trackParcel(carrier, trackingNumber) {
536
- return this._call('trackParcel', {
537
- carrier: requiredString('carrier', carrier),
538
- trackingNumber: requiredString('trackingNumber', trackingNumber)
539
- });
540
- }
541
-
542
- trackParcelAuto(trackingNumber) {
543
- return this._call('trackParcelAuto', { trackingNumber: requiredString('trackingNumber', trackingNumber) });
544
- }
545
-
546
- validateEmail(email) {
547
- return this._call('validateEmail', { email: requiredString('email', email) });
548
- }
549
-
550
- validatePhone(number) {
551
- return this._call('validatePhone', { number: requiredString('number', number) });
552
- }
553
-
554
- holidays(year, month) {
555
- const normalizedYear = Number(year);
556
- const normalizedMonth = Number(month);
557
- if (!Number.isInteger(normalizedYear) || normalizedYear < 1900 || normalizedYear > 2200) {
558
- throw new RangeError('year must be an integer from 1900 through 2200.');
559
- }
560
- if (!Number.isInteger(normalizedMonth) || normalizedMonth < 1 || normalizedMonth > 12) {
561
- throw new RangeError('month must be an integer from 1 through 12.');
562
- }
563
- return this._call('holidays', {
564
- year: String(normalizedYear),
565
- month: String(normalizedMonth).padStart(2, '0')
566
- });
567
- }
568
-
569
- searchAddress(query, options) {
570
- const config = options || {};
571
- return this._call('searchAddress', {
572
- juso: requiredString('query', query),
573
- page: String(positiveInteger('page', config.page, 1))
574
- });
575
- }
576
-
577
- async ocr(image, options) {
578
- const upload = await normalizeImage(image, options);
579
- const form = new FormData();
580
- form.append('image', upload.blob, upload.filename);
581
- return this._call('ocr', null, form);
582
- }
583
-
584
- async _maskImage(serviceName, image, options, type) {
585
- const upload = await normalizeImage(image, options);
586
- const form = new FormData();
587
- form.append('image', upload.blob, upload.filename);
588
- if (type !== undefined) form.append('type', String(type));
589
- return this._call(serviceName, null, form);
590
- }
591
-
592
- maskResidentNumber(image, options) {
593
- const config = options || {};
594
- const type = Number(config.type);
595
- if (![1, 2, 3, 4].includes(type)) throw new RangeError('type must be one of: 1, 2, 3, 4.');
596
- return this._maskImage('maskResidentNumber', image, config, type);
597
- }
598
-
599
- maskResidenceCard(image, options) { return this._maskImage('maskResidenceCard', image, options); }
600
- maskPassport(image, options) { return this._maskImage('maskPassport', image, options); }
601
- maskIdCard(image, options) { return this._maskImage('maskIdCard', image, options); }
602
- maskDriverLicense(image, options) { return this._maskImage('maskDriverLicense', image, options); }
603
-
604
- dnsLookup(domain) {
605
- return this._call('dnsLookup', { domain: requiredString('domain', domain) });
606
- }
607
-
608
- geolocate(address) {
609
- return this._call('geolocate', { address: requiredString('address', address) });
610
- }
611
-
612
- whois(address) {
613
- return this._call('whois', { address: requiredString('address', address) });
614
- }
615
-
616
- googleSearch(keyword, options) {
617
- const config = options || {};
618
- return this._call('googleSearch', {
619
- keyword: requiredString('keyword', keyword),
620
- page: String(positiveInteger('page', config.page, 1))
621
- });
622
- }
623
-
624
- googleImageSearch(keyword, options) {
625
- const config = options || {};
626
- return this._call('googleImageSearch', {
627
- keyword: requiredString('keyword', keyword),
628
- page: String(positiveInteger('page', config.page, 1))
629
- });
630
- }
631
-
632
- googleNewsSearch(keyword, options) {
633
- const config = options || {};
634
- return this._call('googleNewsSearch', {
635
- keyword: requiredString('keyword', keyword, 200),
636
- page: String(positiveInteger('page', config.page, 1))
637
- });
638
- }
639
-
640
- googleShoppingSearch(keyword, options) {
641
- const config = options || {};
642
- return this._call('googleShoppingSearch', {
643
- keyword: requiredString('keyword', keyword, 200),
644
- page: String(positiveInteger('page', config.page, 1))
645
- });
646
- }
647
-
648
- // 지도 장소 검색은 처리에 보통 30~60초가 걸린다.
649
- googleMapsSearch(keyword) {
650
- return this._call('googleMapsSearch', { keyword: requiredString('keyword', keyword, 200) });
651
- }
652
-
653
- // keyword 로 구글을 검색했을 때 domain 이 1~100위 중 몇 위인지 확인한다.
654
- googleRankCheck(keyword, domain) {
655
- return this._call('googleRankCheck', {
656
- keyword: requiredString('keyword', keyword, 200),
657
- domain: requiredString('domain', domain, 253)
658
- });
659
- }
660
-
661
- // 사용자명 또는 프로필 주소. 처리에 보통 40~60초가 걸린다.
662
- instagramProfile(usernameOrUrl) {
663
- return this._call('instagramProfile', profileInput(usernameOrUrl));
664
- }
665
-
666
- // 게시물(/p/) 또는 릴스(/reel/) 주소.
667
- instagramPost(url) {
668
- return this._call('instagramPost', { url: requiredString('url', url, 2048) });
669
- }
670
-
671
- tiktokProfile(usernameOrUrl) {
672
- return this._call('tiktokProfile', profileInput(usernameOrUrl));
673
- }
674
-
675
- // 상품 주소(/dp/ASIN) 또는 10자리 ASIN(미국 아마존).
676
- amazonProduct(urlOrAsin) {
677
- return this._call('amazonProduct', amazonInput(urlOrAsin));
678
- }
679
-
680
- xProfile(usernameOrUrl) {
681
- return this._call('xProfile', profileInput(usernameOrUrl));
682
- }
683
-
684
- xPost(url) {
685
- return this._call('xPost', { url: requiredString('url', url, 2048) });
686
- }
687
-
688
- // 수집 작업: 접수 즉시 job_id 를 돌려준다. maxResults × 단가를 예약하고 결과를 받을 때 실제 건수만 차감한다.
689
- _createScrapeJob(product, payload, options) {
690
- return this._call('getScrapeJob', scrapeJobOptions(product, payload, options), null, { endpoint: '/rest/' + product + '/jobs', json: true });
691
- }
692
-
693
- createInstagramPostsJob(usernameOrUrl, options) {
694
- return this._createScrapeJob('instagram_posts', profileInput(usernameOrUrl), options);
695
- }
696
-
697
- createInstagramCommentsJob(url, options) {
698
- return this._createScrapeJob('instagram_comments', { url: requiredString('url', url, 2048) }, options);
699
- }
700
-
701
- createTiktokSearchJob(keyword, options) {
702
- return this._createScrapeJob('tiktok_search', { keyword: requiredString('keyword', keyword, 100) }, options);
703
- }
704
-
705
- createTiktokVideoJob(url, options) {
706
- return this._createScrapeJob('tiktok_video', { url: requiredString('url', url, 2048) }, options);
707
- }
708
-
709
- createTiktokCommentsJob(url, options) {
710
- return this._createScrapeJob('tiktok_comments', { url: requiredString('url', url, 2048) }, options);
711
- }
712
-
713
- createAmazonReviewsJob(urlOrAsin, options) {
714
- return this._createScrapeJob('amazon_reviews', amazonInput(urlOrAsin), options);
715
- }
716
-
717
- // 구글 지도 장소 상세·대표 리뷰(건당 고정가). 텍스트는 영어로 온다.
718
- createGoogleMapsPlaceJob(placeIdOrUrl, options) {
719
- return this._createScrapeJob('google_maps_place', placeInput(placeIdOrUrl), options);
720
- }
721
-
722
- // 상태·결과 조회(무료). 끝난 작업은 실제 결과 건수로 정산돼 있다.
723
- getScrapeJob(jobId) {
724
- if (typeof jobId !== 'string' || !/^[a-f0-9]{32}$/.test(jobId)) throw new TypeError('jobId must be a 32-character job ID.');
725
- return this._call('getScrapeJob', null, null, { endpoint: '/rest/scrape_jobs/' + jobId, method: 'GET', timeoutMs: 70_000 });
726
- }
727
-
728
- // completed 또는 failed 가 될 때까지 조회한다. 기본 10초 간격, 최대 10분.
729
- async waitForScrapeJob(jobId, options) {
730
- const config = options || {};
731
- const intervalMs = Math.max(3_000, positiveInteger('intervalMs', config.intervalMs, 10_000));
732
- const deadline = Date.now() + positiveInteger('timeoutMs', config.timeoutMs, 600_000);
733
- for (;;) {
734
- const result = await this.getScrapeJob(jobId);
735
- const status = result && result.data && result.data.status;
736
- if (status === 'completed' || status === 'failed') return result;
737
- if (Date.now() + intervalMs > deadline) throw new ApickApiError('Scrape job did not finish in time.', { code: 'APICK_TIMEOUT' });
738
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
739
- }
740
- }
741
-
742
- screenshot(url) {
743
- return this._call('screenshot', { url: normalizeUrl(url) });
744
- }
745
-
746
- // 유튜브 공개 영상. url 은 영상 주소(watch·youtu.be·shorts) 또는 11자리 영상 ID를 받는다.
747
- youtubeMetadata(url) {
748
- return this._call('youtubeMetadata', { url: requiredString('url', url, 2048) });
749
- }
750
-
751
- youtubeThumbnail(url) {
752
- return this._call('youtubeThumbnail', { url: requiredString('url', url, 2048) });
753
- }
754
-
755
- youtubeSubtitleList(url) {
756
- return this._call('youtubeSubtitleList', { url: requiredString('url', url, 2048) });
757
- }
758
-
759
- youtubeSubtitle(url, lang, options) {
760
- const config = options || {};
761
- const payload = { url: requiredString('url', url, 2048), lang: requiredString('lang', lang, 32) };
762
- if (config.format !== undefined) {
763
- if (!['vtt', 'srt', 'txt'].includes(config.format)) throw new RangeError('format must be vtt, srt or txt.');
764
- payload.format = config.format;
765
- }
766
- if (config.type !== undefined) {
767
- if (!['any', 'manual', 'auto'].includes(config.type)) throw new RangeError('type must be any, manual or auto.');
768
- payload.type = config.type;
769
- }
770
- return this._call('youtubeSubtitle', payload, null, { filename: 'subtitle.' + (payload.format || 'vtt') });
771
- }
772
-
773
- // 키워드 검색. 영상·쇼츠·채널·재생목록을 돌려준다.
774
- youtubeSearch(query, options) {
775
- const config = options || {};
776
- return this._call('youtubeSearch', compact({
777
- query: requiredString('query', query, 200),
778
- count: optionalRangeInteger('count', config.count, 1, 50),
779
- sort: optionalChoice('sort', config.sort, ['relevance', 'date', 'views', 'rating']),
780
- type: optionalChoice('type', config.type, ['any', 'video', 'channel', 'playlist', 'movie']),
781
- upload_date: optionalChoice('uploadDate', config.uploadDate, ['any', 'hour', 'today', 'week', 'month', 'year']),
782
- duration: optionalChoice('duration', config.duration, ['any', 'short', 'medium', 'long'])
783
- }));
784
- }
785
-
786
- // channel 은 채널 주소, 핸들(@이름) 또는 채널 ID(UC…).
787
- youtubeChannel(channel, options) {
788
- const config = options || {};
789
- return this._call('youtubeChannel', compact({
790
- channel: requiredString('channel', channel, 2048),
791
- tab: optionalChoice('tab', config.tab, ['videos', 'shorts', 'streams', 'playlists']),
792
- count: optionalRangeInteger('count', config.count, 1, 100)
793
- }));
794
- }
795
-
796
- youtubePlaylist(url, options) {
797
- const config = options || {};
798
- return this._call('youtubePlaylist', compact({ url: requiredString('url', url, 2048), count: optionalRangeInteger('count', config.count, 1, 200) }));
799
- }
800
-
801
- youtubeHashtag(hashtag, options) {
802
- const config = options || {};
803
- return this._call('youtubeHashtag', compact({ hashtag: requiredString('hashtag', hashtag, 101), count: optionalRangeInteger('count', config.count, 1, 100) }));
804
- }
805
-
806
- // 받을 수 있는 화질·오디오 형식과 화질별 예상 다운로드 요금.
807
- youtubeFormats(url) {
808
- return this._call('youtubeFormats', { url: requiredString('url', url, 2048) });
809
- }
810
-
811
- youtubeComments(url, options) {
812
- const config = options || {};
813
- if (config.replies !== undefined && typeof config.replies !== 'boolean') throw new TypeError('replies must be a boolean.');
814
- return this._call('youtubeComments', compact({
815
- url: requiredString('url', url, 2048),
816
- count: optionalRangeInteger('count', config.count, 1, 200),
817
- sort: optionalChoice('sort', config.sort, ['top', 'new']),
818
- replies: config.replies === undefined ? undefined : String(config.replies)
819
- }));
820
- }
821
-
822
- // 영상을 MP4 로 받아 1시간 유효한 다운로드 주소(data.download_url)를 돌려준다.
823
- // 요금은 기본요금 + 받은 파일 10MB 마다 추가 요금이며 처리에 최대 10분이 걸릴 수 있다.
824
- downloadYoutubeVideo(url, options) {
825
- const config = options || {};
826
- return this._call('downloadYoutubeVideo', compact({
827
- url: requiredString('url', url, 2048),
828
- quality: optionalChoice('quality', config.quality === undefined ? undefined : String(config.quality), ['best', '2160', '1440', '1080', '720', '480', '360', '240', '144']),
829
- codec: optionalChoice('codec', config.codec, ['any', 'h264']),
830
- start: optionalTimePoint('start', config.start),
831
- end: optionalTimePoint('end', config.end),
832
- delivery: 'link'
833
- }));
834
- }
835
-
836
- // 소리만 MP3·M4A·Opus 로 받아 1시간 유효한 다운로드 주소를 돌려준다.
837
- downloadYoutubeAudio(url, options) {
838
- const config = options || {};
839
- return this._call('downloadYoutubeAudio', compact({
840
- url: requiredString('url', url, 2048),
841
- format: optionalChoice('format', config.format, ['mp3', 'm4a', 'opus']),
842
- bitrate: optionalChoice('bitrate', config.bitrate === undefined ? undefined : String(config.bitrate), ['128', '192', '320']),
843
- start: optionalTimePoint('start', config.start),
844
- end: optionalTimePoint('end', config.end),
845
- delivery: 'link'
846
- }));
847
- }
848
-
849
- async createVideoJob(model, prompt, options) {
850
- if (!['seedance', 'veo', 'kling'].includes(model)) throw new RangeError('model must be seedance, veo or kling.');
851
- const config = options || {};
852
- const payload = { prompt: config.mode === 'reference' && prompt === '' ? '' : requiredString('prompt', prompt, 2000) };
853
- for (const [key, field] of Object.entries({ version:'version', tier:'tier', mode:'mode', duration:'duration', aspectRatio:'aspect_ratio', resolution:'resolution', audio:'audio', negativePrompt:'negative_prompt', seed:'seed', cfgScale:'cfg_scale', idempotencyKey:'idempotency_key' })) {
854
- if (config[key] !== undefined) payload[field] = config[key];
855
- }
856
- const files = [['image', config.image], ['last_image', config.lastImage]];
857
- for (const input of config.referenceImages || []) files.push(['reference_image', input]);
858
- for (const input of config.referenceVideos || []) files.push(['reference_video', input]);
859
- for (const input of config.referenceAudios || []) files.push(['reference_audio', input]);
860
- const present = files.filter(([, input]) => input !== undefined);
861
- const request = { endpoint: '/rest/' + model + '/jobs' };
862
- if (!present.length) return this._call('createVideoJob', payload, null, request);
863
- const form = new FormData();
864
- for (const [key, value] of Object.entries(payload)) form.append(key, String(value));
865
- for (const [field, input] of present) {
866
- if (field !== 'reference_video' && field !== 'reference_audio') {
867
- const upload = await normalizeImage(input);
868
- form.append(field, upload.blob, upload.filename);
869
- } else {
870
- let bytes, filename, contentType;
871
- if (typeof input === 'string') {
872
- bytes = await require('node:fs/promises').readFile(input);
873
- filename = require('node:path').basename(input);
874
- contentType = field === 'reference_audio' ? (/\.wav$/i.test(filename) ? 'audio/wav' : 'audio/mpeg') : /\.webm$/i.test(filename) ? 'video/webm' : /\.mov$/i.test(filename) ? 'video/quicktime' : 'video/mp4';
875
- } else { bytes = await input.arrayBuffer(); filename = input.name || (field === 'reference_audio' ? 'reference.mp3' : 'reference.mp4'); contentType = input.type || (field === 'reference_audio' ? 'audio/mpeg' : 'video/mp4'); }
876
- const allowed = field === 'reference_audio' ? ['audio/mpeg', 'audio/wav', 'audio/x-wav'] : ['video/mp4', 'video/quicktime', 'video/webm'];
877
- const maxBytes = field === 'reference_audio' ? 15 * 1024 * 1024 : 100 * 1024 * 1024;
878
- if (!allowed.includes(contentType) || bytes.byteLength < 1 || bytes.byteLength > maxBytes) throw new RangeError(field === 'reference_audio' ? 'Invalid reference audio.' : 'Invalid reference video.');
879
- form.append(field, new Blob([bytes], { type: contentType }), filename);
880
- }
881
- }
882
- return this._call('createVideoJob', null, form, request);
883
- }
884
-
885
- getVideoJob(model, jobId) {
886
- if (!['seedance', 'veo', 'kling'].includes(model) || !/^[a-f0-9]{32}$/.test(jobId)) throw new RangeError('Invalid video model or job ID.');
887
- return this._call('createVideoJob', null, null, { endpoint:'/rest/'+model+'/jobs/'+jobId, method:'GET', timeoutMs:30_000 });
888
- }
889
-
890
- downloadVideoResult(model, jobId) {
891
- if (!['seedance', 'veo', 'kling'].includes(model) || !/^[a-f0-9]{32}$/.test(jobId)) throw new RangeError('Invalid video model or job ID.');
892
- return this._call('createVideoJob', null, null, { endpoint:'/rest/'+model+'/jobs/'+jobId+'/result', method:'GET', output:'binary', filename:jobId+'.mp4', timeoutMs:60_000 });
893
- }
894
-
895
- createTtsJob(text, options) {
896
- const config = options || {};
897
- const payload = typeof text==='object' && text!==null ? {...text} : {
898
- voice_id: normalizeTtsVoice(config.voiceId || 'Kore'),
899
- text: requiredString('text', text, config.normalizeText === false ? 8000 : 2000)
900
- };
901
- if(config.normalizeText!==undefined)payload.normalize_text=config.normalizeText;
902
- if(config.fallbackPolicy!==undefined||config.fallbackOptions!==undefined||payload.fallback_policy!==undefined||payload.fallback_options!==undefined)throw new TypeError('TTS fallback has retired. Use createGeminiTtsJob or createOpenAiTtsJob.');
903
- if(payload.normalize_text!==undefined&&typeof payload.normalize_text!=='boolean')throw new TypeError('normalizeText must be boolean.');
904
- if(payload.voice_id!==undefined)normalizeTtsVoice(payload.voice_id);
905
- return this._call('createTtsJob',payload,null,{json:true,idempotencyKey:config.idempotencyKey});
906
- }
907
-
908
- createGeminiTtsJob(input, options) {
909
- if(!input||typeof input!=='object'||Array.isArray(input))throw new TypeError('Gemini TTS input is required.');
910
- return this._call('createTtsJob', input, null, {endpoint:'/rest/tts/gemini/jobs',json:true,idempotencyKey:options?.idempotencyKey});
911
- }
912
- listGeminiTtsVoices() { return this._call('createTtsJob',null,null,{endpoint:'/rest/tts/gemini/voices',method:'GET'}); }
913
- createOpenAiTtsJob(input, options) {
914
- if(!input||typeof input!=='object'||Array.isArray(input))throw new TypeError('OpenAI TTS input is required.');
915
- return this._call('createTtsJob',input,null,{endpoint:'/rest/tts/openai/jobs',json:true,idempotencyKey:options?.idempotencyKey});
916
- }
917
- listOpenAiTtsVoices() { return this._call('createTtsJob',null,null,{endpoint:'/rest/tts/openai/voices',method:'GET'}); }
918
- getTtsOptions() { return this._call('createTtsJob',null,null,{endpoint:'/rest/tts/options',method:'GET'}); }
919
- quoteTts(input) { return this._call('createTtsJob',input,null,{endpoint:'/rest/tts/quote',json:true}); }
920
-
921
- getTtsJob(jobId) {
922
- const id = normalizeTtsJobId(jobId);
923
- return this._call('createTtsJob', null, null, { endpoint: '/rest/tts/jobs/' + id, method: 'GET' });
924
- }
925
-
926
- cancelTtsJob(jobId) {
927
- const id = normalizeTtsJobId(jobId);
928
- return this._call('createTtsJob', null, null, { endpoint: '/rest/tts/jobs/' + id + '/cancel' });
929
- }
930
-
931
- getTtsQuality(jobId) {
932
- const id = normalizeTtsJobId(jobId);
933
- return this._call('createTtsJob', null, null, { endpoint: '/rest/tts/jobs/' + id + '/quality', method: 'GET' });
934
- }
935
-
936
- retryTtsJob(jobId, utteranceIds, idempotencyKey) {
937
- const id = normalizeTtsJobId(jobId);
938
- if (!Array.isArray(utteranceIds) || utteranceIds.length > 100 || utteranceIds.some(value => typeof value !== 'string' || !/^u\d{3}$/.test(value))) {
939
- throw new TypeError('utteranceIds must contain up to 100 uNNN identifiers.');
940
- }
941
- if (typeof idempotencyKey !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(idempotencyKey)) {
942
- throw new TypeError('idempotencyKey must contain 8 to 128 letters, digits, underscores or hyphens.');
943
- }
944
- return this._call('createTtsJob', { utterance_ids: [...new Set(utteranceIds)].sort(), idempotency_key: idempotencyKey }, null,
945
- { endpoint: '/rest/tts/jobs/' + id + '/retry' });
946
- }
947
-
948
- downloadTtsCandidate(jobId, candidateId) {
949
- const id = normalizeTtsJobId(jobId);
950
- if (typeof candidateId !== 'string' || !/^[a-f0-9]{32}$/.test(candidateId)) throw new TypeError('candidateId must be a 32-character hexadecimal identifier.');
951
- return this._call('createTtsJob', null, null, { endpoint: '/rest/tts/jobs/' + id + '/candidates/' + candidateId + '/audio',
952
- method: 'GET', output: 'binary', filename: candidateId + '.wav' });
953
- }
954
-
955
- downloadTtsResult(jobId) {
956
- const id = normalizeTtsJobId(jobId);
957
- return this._call('createTtsJob', null, null, {
958
- endpoint: '/rest/tts/jobs/' + id + '/result',
959
- method: 'GET',
960
- output: 'binary',
961
- filename: id + '.mp3'
962
- });
963
- }
964
-
965
- downloadTtsSubtitles(jobId) {
966
- const id = normalizeTtsJobId(jobId);
967
- return this._call('createTtsJob', null, null, {
968
- endpoint: '/rest/tts/jobs/' + id + '/subtitles',
969
- method: 'GET',
970
- output: 'binary',
971
- filename: id + '.ass'
972
- });
973
- }
974
-
975
- htmlToPdf(html, options) {
976
- const config = options || {};
977
- return this._call('htmlToPdf', {
978
- html: requiredString('html', html),
979
- pagination: config.pagination ? 1 : 0
980
- });
981
- }
982
-
983
- jsonToExcel(data, options) {
984
- if (!Array.isArray(data)) throw new TypeError('data must be an array.');
985
- const config = options || {};
986
- const payload = { data_list: data };
987
- if (config.sheetName !== undefined) payload.sheet_name = requiredString('sheetName', config.sheetName);
988
- return this._call('jsonToExcel', payload);
989
- }
990
-
991
- summarize(text) {
992
- return this._call('summarize', { text: requiredString('text', text, 100_000) });
993
- }
994
-
995
- polish(text) {
996
- return this._call('polish', { text: requiredString('text', text, 100_000) });
997
- }
998
-
999
- _imageOptions(prompt, options, maxCount) {
1000
- const config = options || {};
1001
- for (const key of ['model', 'count', 'n', 'outputCompression', 'input_fidelity', 'moderation', 'mask', 'maskFilename', 'maskContentType']) {
1002
- if (Object.prototype.hasOwnProperty.call(config, key)) throw new TypeError(`${key} is not a supported image option.`);
1003
- }
1004
- const imageCount = positiveInteger('imageCount', config.imageCount, 1);
1005
- if (imageCount > maxCount) throw new RangeError(`imageCount must not exceed ${maxCount}.`);
1006
- const size = config.size || '1024x1024';
1007
- if (!IMAGE_AI_SIZE_SET.has(size)) throw new TypeError(`size must be one of: ${IMAGE_AI_SIZES.join(', ')}.`);
1008
- const payload = {
1009
- prompt: requiredString('prompt', prompt, 28_000), image_count: imageCount,
1010
- size, output_format: config.outputFormat || 'png',
1011
- background: config.background || 'auto'
1012
- };
1013
- if (config.quality !== undefined) {
1014
- if (!IMAGE_AI_QUALITIES.includes(config.quality)) throw new TypeError(`quality must be one of: ${IMAGE_AI_QUALITIES.join(', ')}.`);
1015
- payload.quality = config.quality;
1016
- }
1017
- // 서버가 같은 요청도 매번 새로 생성·과금하므로 idempotencyKey 는 보내지 않는다(예전 코드 호환을 위해 받기만 한다).
1018
- return payload;
1019
- }
1020
-
1021
- async generateImages(prompt, options) {
1022
- const config = options || {}, payload = this._imageOptions(prompt, config, 4);
1023
- if (config.referenceImage === undefined) return this._call('generateImages', payload);
1024
- const uploadOptions = {
1025
- filename: config.referenceFilename, contentType: config.referenceContentType,
1026
- allowedTypes:['image/png','image/jpeg','image/webp'], maxBytes:MAX_IMAGE_AI_BYTES,
1027
- typeError:'referenceImage must be PNG, JPEG, or WebP.'
1028
- };
1029
- const source = await normalizeImage(config.referenceImage, uploadOptions), form = new FormData();
1030
- Object.entries(payload).forEach(([key,value]) => form.append(key, String(value)));
1031
- form.append('reference_image', source.blob, source.filename);
1032
- return this._call('generateImages', null, form);
1033
- }
1034
-
1035
- async editImages(image, prompt, options) {
1036
- const config = options || {}, payload = this._imageOptions(prompt, config, 4);
1037
- const uploadOptions = Object.assign({}, config, { allowedTypes:['image/png','image/jpeg','image/webp'], maxBytes:MAX_IMAGE_AI_BYTES, typeError:'image must be PNG, JPEG, or WebP.' });
1038
- const source = await normalizeImage(image, uploadOptions), form = new FormData();
1039
- Object.entries(payload).forEach(([key,value]) => form.append(key, String(value)));
1040
- form.append('image', source.blob, source.filename);
1041
- return this._call('generateImages', null, form, { endpoint:'/rest/image-generation/edit' });
1042
- }
1043
-
1044
- async createImageGenerationJob(prompt, options) {
1045
- const config = options || {}, payload = this._imageOptions(prompt, config, 50);
1046
- if (config.referenceImage === undefined) return this._call('generateImages', payload, null, { endpoint:'/rest/image-generation/jobs/generate', timeoutMs:60_000 });
1047
- const uploadOptions = {
1048
- filename: config.referenceFilename, contentType: config.referenceContentType,
1049
- allowedTypes:['image/png','image/jpeg','image/webp'], maxBytes:MAX_IMAGE_AI_BYTES,
1050
- typeError:'referenceImage must be PNG, JPEG, or WebP.'
1051
- };
1052
- const source = await normalizeImage(config.referenceImage, uploadOptions), form = new FormData();
1053
- Object.entries(payload).forEach(([key,value]) => form.append(key, String(value)));
1054
- form.append('reference_image', source.blob, source.filename);
1055
- return this._call('generateImages', null, form, { endpoint:'/rest/image-generation/jobs/generate', timeoutMs:60_000 });
1056
- }
1057
-
1058
- async createImageEditJob(image, prompt, options) {
1059
- const config = options || {}, payload = this._imageOptions(prompt, config, 50);
1060
- const uploadOptions = Object.assign({}, config, { allowedTypes:['image/png','image/jpeg','image/webp'], maxBytes:MAX_IMAGE_AI_BYTES, typeError:'image must be PNG, JPEG, or WebP.' });
1061
- const source = await normalizeImage(image, uploadOptions), form = new FormData();
1062
- Object.entries(payload).forEach(([key,value]) => form.append(key, String(value)));
1063
- form.append('image', source.blob, source.filename);
1064
- return this._call('generateImages', null, form, { endpoint:'/rest/image-generation/jobs/edit', timeoutMs:60_000 });
1065
- }
1066
-
1067
- getImageJob(jobId) {
1068
- const id = normalizeTtsJobId(jobId);
1069
- return this._call('generateImages', null, null, { endpoint:'/rest/image-generation/jobs/'+id, method:'GET', timeoutMs:30_000 });
1070
- }
1071
-
1072
- downloadImageJobImage(jobId, index) {
1073
- const id=normalizeTtsJobId(jobId), value=Number(index);
1074
- if(!Number.isInteger(value)||value<0||value>49) throw new RangeError('index must be an integer from 0 through 49.');
1075
- return this._call('generateImages', null, null, { endpoint:'/rest/image-generation/jobs/'+id+'/images/'+value, method:'GET', output:'binary', filename:id+'-'+value+'.bin', timeoutMs:60_000 });
1076
- }
1077
-
1078
- downloadImageJobArchive(jobId) {
1079
- const id=normalizeTtsJobId(jobId);
1080
- return this._call('generateImages', null, null, { endpoint:'/rest/image-generation/jobs/'+id+'/result', method:'GET', output:'binary', filename:id+'.zip', timeoutMs:60_000 });
1081
- }
1082
-
1083
- // 간편인증 기반 조회 상품: 인증 요청(request*) 뒤 결과 조회(get*)로 폴링한다.
1084
- // 각 상품은 접수 시 정액, 최초 결과 반환 시 항목 단가가 과금되고 재조회는 무과금이다.
1085
- requestEmployment(input) {
1086
- const payload = dataRequestInput(input);
1087
- const insuranceYears = optionalRangeInteger('insuranceYears', (input || {}).insuranceYears, 1, 3);
1088
- if (insuranceYears !== undefined) payload.insuranceYears = insuranceYears;
1089
- return this._call('requestEmployment', payload);
1090
- }
1091
-
1092
- getEmployment(transactionId) {
1093
- return this._call('requestEmployment', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_employment' });
1094
- }
1095
-
1096
- requestPersonalIncome(input) {
1097
- const payload = dataRequestInput(input);
1098
- const incomeYears = optionalRangeInteger('incomeYears', (input || {}).incomeYears, 1, 5);
1099
- if (incomeYears !== undefined) payload.incomeYears = incomeYears;
1100
- return this._call('requestPersonalIncome', payload);
1101
- }
1102
-
1103
- getPersonalIncome(transactionId) {
1104
- return this._call('requestPersonalIncome', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_personal_income' });
1105
- }
1106
-
1107
- requestNpsJoinHistory(input) {
1108
- const payload = dataRequestInput(input);
1109
- const config = input || {};
1110
- const from = normalizeYearMonth('from', config.from);
1111
- const to = normalizeYearMonth('to', config.to);
1112
- if (from !== undefined) payload.from = from;
1113
- if (to !== undefined) payload.to = to;
1114
- return this._call('requestNpsJoinHistory', payload);
1115
- }
1116
-
1117
- getNpsJoinHistory(transactionId) {
1118
- return this._call('requestNpsJoinHistory', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_nps_join_history' });
1119
- }
1120
-
1121
- requestDrivingLicense(input) {
1122
- return this._call('requestDrivingLicense', dataRequestInput(input));
1123
- }
1124
-
1125
- getDrivingLicense(transactionId) {
1126
- return this._call('requestDrivingLicense', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_driving_license' });
1127
- }
1128
-
1129
- requestHealthCheckup(input) {
1130
- return this._call('requestHealthCheckup', dataRequestInput(input));
1131
- }
1132
-
1133
- getHealthCheckup(transactionId) {
1134
- return this._call('requestHealthCheckup', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_health_checkup' });
1135
- }
1136
-
1137
- requestCashReceiptDeduction(input) {
1138
- const payload = dataRequestInput(input);
1139
- const incomeYears = optionalRangeInteger('incomeYears', (input || {}).incomeYears, 1, 3);
1140
- if (incomeYears !== undefined) payload.incomeYears = incomeYears;
1141
- return this._call('requestCashReceiptDeduction', payload);
1142
- }
1143
-
1144
- getCashReceiptDeduction(transactionId) {
1145
- return this._call('requestCashReceiptDeduction', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_cash_receipt_deduction' });
1146
- }
1147
-
1148
- requestTaxReturnHistory(input) {
1149
- const payload = dataRequestInput(input);
1150
- const years = optionalRangeInteger('years', (input || {}).years, 1, 10);
1151
- if (years !== undefined) payload.years = years;
1152
- return this._call('requestTaxReturnHistory', payload);
1153
- }
1154
-
1155
- getTaxReturnHistory(transactionId) {
1156
- return this._call('requestTaxReturnHistory', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_tax_return_history' });
1157
- }
1158
-
1159
- // Skills: 요청·응답이 모두 JSON 이고 data·api 봉투 없이 응답 항목이 최상위에 온다.
1160
- // 오류는 { error: { code, message, details } } 형식이며 code 를 serviceCode 로 돌려준다.
1161
- async _skills(method, path, options) {
1162
- const config = options || {};
1163
- const controller = new AbortController();
1164
- const timeoutMs = this.#timeoutMs || config.timeoutMs || DEFAULT_TIMEOUT_MS;
1165
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1166
- const headers = { Accept: 'application/json', Authorization: `Bearer ${this.#apiKey}` };
1167
- const request = { method, headers, signal: controller.signal, redirect: 'error' };
1168
- if (config.idempotencyKey) headers['Idempotency-Key'] = config.idempotencyKey;
1169
- if (method !== 'GET') {
1170
- headers['Content-Type'] = 'application/json';
1171
- request.body = JSON.stringify(config.body || {});
1172
- }
1173
- let url = this.#baseUrl + path;
1174
- const query = new URLSearchParams(config.query || {}).toString();
1175
- if (query) url += '?' + query;
1176
-
1177
- let response;
1178
- try {
1179
- response = await this.#fetch(url, request);
1180
- } catch (error) {
1181
- const timedOut = controller.signal.aborted || error && error.name === 'AbortError';
1182
- throw new ApickApiError(
1183
- timedOut ? `APICK request timed out after ${timeoutMs} ms.` : redact(error && error.message || 'APICK network request failed.', this.#apiKey),
1184
- { code: timedOut ? 'APICK_TIMEOUT' : 'APICK_NETWORK_ERROR' }
1185
- );
1186
- } finally {
1187
- clearTimeout(timer);
1188
- }
1189
-
1190
- const raw = await response.text();
1191
- let body;
1192
- try {
1193
- body = raw ? JSON.parse(raw) : null;
1194
- } catch {
1195
- body = undefined;
1196
- }
1197
- const failure = body && typeof body === 'object' && body.error && typeof body.error === 'object' ? body.error : null;
1198
- if (!response.ok || failure) {
1199
- throw new ApickApiError(failure && typeof failure.message === 'string' && failure.message || publicErrorMessage(body, 'APICK request failed.'), {
1200
- status: response.status,
1201
- code: response.status === 401 ? 'APICK_AUTH_ERROR' : 'APICK_API_ERROR',
1202
- serviceCode: failure && typeof failure.code === 'string' ? failure.code : undefined,
1203
- details: failure && failure.details && typeof failure.details === 'object' ? failure.details : undefined
1204
- });
1205
- }
1206
- if (!body || typeof body !== 'object') {
1207
- throw new ApickApiError(`APICK returned an unexpected response (HTTP ${response.status}).`, {
1208
- status: response.status,
1209
- code: 'APICK_INVALID_RESPONSE'
1210
- });
1211
- }
1212
- return Object.freeze({ data: body, meta: responseMeta(null, response.headers) });
1213
- }
1214
-
1215
- searchSkills(options) {
1216
- const config = options || {};
1217
- const query = {};
1218
- if (config.query !== undefined && config.query !== null && config.query !== '') query.query = requiredString('query', config.query, 60);
1219
- if (config.category !== undefined && config.category !== null && config.category !== '') {
1220
- const category = requiredString('category', config.category);
1221
- if (!SKILL_CATEGORY_SET.has(category)) throw new RangeError('category must be one of the supported Skill categories.');
1222
- query.category = category;
1223
- }
1224
- if (config.sort !== undefined && config.sort !== null && config.sort !== '') {
1225
- const sort = requiredString('sort', config.sort);
1226
- if (!SKILL_SORT_SET.has(sort)) throw new RangeError('sort must be one of the supported Skill sort orders.');
1227
- query.sort = sort;
1228
- }
1229
- if (config.cursor !== undefined && config.cursor !== null && config.cursor !== '') query.cursor = requiredString('cursor', String(config.cursor), 20);
1230
- const limit = optionalRangeInteger('limit', config.limit, 1, 20);
1231
- if (limit !== undefined) query.limit = String(limit);
1232
- return this._skills('GET', '/rest/skills', { query });
1233
- }
1234
-
1235
- getSkillPerformance() {
1236
- return this._skills('GET', '/skills/performance/data.json');
1237
- }
1238
-
1239
- _subagent(method, route, options) { return this._skills(method, '/rest/subagent/v1' + route, options); }
1240
- subagentStatus() { return this._subagent('GET', '/status'); }
1241
- subagentUsage() { return this._subagent('GET', '/usage'); }
1242
- createSubagentFile(input) { return this._subagent('POST', '/files', {body: input}); }
1243
- getSubagentFile(fileId) { return this._subagent('GET', '/files/' + normalizeSubagentId(fileId,'saf')); }
1244
- uploadSubagentPart(fileId, partNo, data) { if(!Number.isSafeInteger(partNo)||partNo<0||typeof data!=='string')throw new TypeError('Invalid upload part.');return this._subagent('POST','/files/'+normalizeSubagentId(fileId,'saf')+'/parts',{body:{part_no:partNo,data}}); }
1245
- completeSubagentFile(fileId) { return this._subagent('POST','/files/'+normalizeSubagentId(fileId,'saf')+'/complete',{body:{}}); }
1246
- deleteSubagentFile(fileId) { return this._subagent('DELETE','/files/'+normalizeSubagentId(fileId,'saf')); }
1247
- dispatchSubagent(input, options) { const key=requiredString('idempotencyKey',options?.idempotencyKey,200);if(key.length<8)throw new TypeError('idempotencyKey must contain at least 8 characters.');return this._subagent('POST','/jobs',{body:input,idempotencyKey:key}); }
1248
- collectSubagent(jobId, options) { return this._subagent('GET','/jobs/'+normalizeSubagentId(jobId,'saj'),{query:options?.cursor?{cursor:String(options.cursor)}:{}}); }
1249
- subagentEvidence(jobId,evidenceIds) { return this._subagent('POST','/jobs/'+normalizeSubagentId(jobId,'saj')+'/evidence',{body:{evidence_ids:evidenceIds}}); }
1250
- reviewSubagent(jobId,resultHash,decision) { if(!['accepted','rejected'].includes(decision)||!/^[a-f0-9]{64}$/.test(resultHash))throw new TypeError('Invalid review.');return this._subagent('POST','/jobs/'+normalizeSubagentId(jobId,'saj')+'/review',{body:{result_hash:resultHash,decision}}); }
1251
- cancelSubagent(jobId) { return this._subagent('POST','/jobs/'+normalizeSubagentId(jobId,'saj')+'/cancel',{body:{}}); }
1252
- deleteSubagentJob(jobId) { return this._subagent('DELETE','/jobs/'+normalizeSubagentId(jobId,'saj')); }
1253
-
1254
- getSkill(skillId) {
1255
- return this._skills('GET', '/rest/skills/' + normalizeSkillId(skillId));
1256
- }
1257
-
1258
- quoteSkill(skillId, input, options) {
1259
- const body = { input: skillInput(input) };
1260
- const version = optionalSkillText('version', (options || {}).version, 16);
1261
- if (version !== undefined) body.version = version;
1262
- return this._skills('POST', '/rest/skills/' + normalizeSkillId(skillId) + '/quotes', { body });
1263
- }
1264
-
1265
- // 같은 idempotencyKey 로 다시 보내면 새로 실행하지 않고 처음 실행을 돌려준다. 응답을 못 받았을 때는 같은 키로 재시도한다.
1266
- runSkill(skillId, input, options) {
1267
- const config = options || {};
1268
- const idempotencyKey = requiredString('idempotencyKey', config.idempotencyKey);
1269
- if (!/^[A-Za-z0-9._:-]{1,128}$/.test(idempotencyKey)) {
1270
- throw new TypeError('idempotencyKey must be 1-128 characters of letters, digits, and . _ : -');
1271
- }
1272
- const body = { input: skillInput(input) };
1273
- const version = optionalSkillText('version', config.version, 16);
1274
- const quoteId = optionalSkillText('quoteId', config.quoteId, 40);
1275
- const maxCostPoints = config.maxCostPoints === undefined || config.maxCostPoints === null ? undefined : positiveInteger('maxCostPoints', config.maxCostPoints);
1276
- const waitSeconds = optionalRangeInteger('waitSeconds', config.waitSeconds, 0, 20);
1277
- if (version !== undefined) body.version = version;
1278
- if (quoteId !== undefined) body.quote_id = quoteId;
1279
- if (maxCostPoints !== undefined) body.max_cost_points = maxCostPoints;
1280
- if (waitSeconds !== undefined) body.wait_seconds = waitSeconds;
1281
- return this._skills('POST', '/rest/skills/' + normalizeSkillId(skillId) + '/runs', { body, idempotencyKey, timeoutMs: 40_000 });
1282
- }
1283
-
1284
- getSkillRun(runId) {
1285
- return this._skills('GET', '/rest/skills/runs/' + normalizeSkillRunId(runId));
1286
- }
1287
-
1288
- getSkillRunResult(runId) {
1289
- return this._skills('GET', '/rest/skills/runs/' + normalizeSkillRunId(runId) + '/result');
1290
- }
1291
-
1292
- /** Download a file from a completed run owned by this API key. */
1293
- async getSkillArtifact(runId, fileId) {
1294
- const run = normalizeSkillRunId(runId);
1295
- if (typeof fileId !== 'string' || !/^art_[a-f0-9]{24}$/.test(fileId)) throw new TypeError('fileId must be an artifact ID from a Skill result.');
1296
- const controller = new AbortController();
1297
- const timeoutMs = this.#timeoutMs || DEFAULT_TIMEOUT_MS;
1298
- const timer = setTimeout(() => controller.abort(), timeoutMs);
1299
- try {
1300
- const response = await this.#fetch(this.#baseUrl + '/rest/skills/runs/' + run + '/files/' + fileId + '?download=1', {
1301
- method: 'GET', headers: { Authorization: `Bearer ${this.#apiKey}` }, signal: controller.signal, redirect: 'error'
1302
- });
1303
- if (!response.ok) {
1304
- let body; try { body = await response.json(); } catch { body = null; }
1305
- throw new ApickApiError('APICK artifact download failed.', { status: response.status, code: response.status === 401 ? 'APICK_AUTH_ERROR' : 'APICK_API_ERROR', serviceCode: body && body.error && body.error.code });
1306
- }
1307
- const limit = 200 * 1024 * 1024;
1308
- if (Number(response.headers.get('content-length')) > limit) { controller.abort(); throw new ApickApiError('APICK artifact exceeds the size limit.', { code: 'APICK_INVALID_RESPONSE' }); }
1309
- const reader = response.body.getReader(), chunks = []; let size = 0;
1310
- try {
1311
- while (true) {
1312
- const item = await reader.read(); if (item.done) break;
1313
- size += item.value.byteLength;
1314
- if (size > limit) { await reader.cancel(); throw new ApickApiError('APICK artifact exceeds the size limit.', { code: 'APICK_INVALID_RESPONSE' }); }
1315
- chunks.push(item.value);
1316
- }
1317
- } finally { reader.releaseLock(); }
1318
- const bytes = new Uint8Array(size); let offset = 0;
1319
- for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
1320
- return new ApickBinaryResult(bytes, { contentType: (response.headers.get('content-type') || '').split(';')[0], filename: parseFilename(response.headers.get('content-disposition'), 'result.bin'), meta: responseMeta(null, response.headers) });
1321
- } catch (error) {
1322
- if (error instanceof ApickApiError) throw error;
1323
- throw new ApickApiError(controller.signal.aborted ? 'APICK artifact download timed out.' : redact(error && error.message || 'APICK download failed.', this.#apiKey), { code: controller.signal.aborted ? 'APICK_TIMEOUT' : 'APICK_NETWORK_ERROR' });
1324
- } finally { clearTimeout(timer); }
1325
- }
1326
-
1327
- cancelSkillRun(runId) {
1328
- return this._skills('POST', '/rest/skills/runs/' + normalizeSkillRunId(runId) + '/cancel', { body: {} });
1329
- }
1330
-
1331
- skillUsage(options) {
1332
- const config = options || {};
1333
- const query = {};
1334
- if (config.cursor !== undefined && config.cursor !== null && config.cursor !== '') query.cursor = requiredString('cursor', String(config.cursor), 20);
1335
- const limit = optionalRangeInteger('limit', config.limit, 1, 50);
1336
- if (limit !== undefined) query.limit = String(limit);
1337
- return this._skills('GET', '/rest/skills/usage', { query });
1338
- }
1339
- }
1340
-
1341
- module.exports = {
1342
- ApickClient,
1343
- ApickApiError,
1344
- ApickBinaryResult,
1345
- SERVICES,
1346
- TTS_VOICE_IDS,
1347
- AUTH_PROVIDERS,
1348
- SKILL_CATEGORIES,
1349
- SKILL_SORTS,
1350
- DEFAULT_BASE_URL
1351
- };
1
+ 'use strict';
2
+
3
+ const DEFAULT_BASE_URL = 'https://apick.app';
4
+ const DEFAULT_TIMEOUT_MS = 30_000;
5
+ const MAX_OCR_BYTES = 50 * 1024 * 1024;
6
+ const MAX_IMAGE_AI_BYTES = 50 * 1024 * 1024;
7
+ const IMAGE_AI_SIZES = Object.freeze(['1024x1024', '1536x1024', '1024x1536', '1152x864', '864x1152']);
8
+ const IMAGE_AI_SIZE_SET = new Set(IMAGE_AI_SIZES);
9
+ // 품질별 장당 고정가: basic 40P(기본) · advanced 350P · premium 1,400P
10
+ const IMAGE_AI_QUALITIES = Object.freeze(['basic', 'advanced', 'premium']);
11
+ const TTS_VOICE_IDS = Object.freeze([
12
+ 'v2_ann_m_30s_01', 'v2_ann_m_30s_02', 'v2_ann_m_30s_04', 'v2_ann_m_30s_05', 'v2_ann_f_30s_02', 'v2_ann_f_30s_03', 'v2_ann_f_30s_04', 'v2_ann_f_30s_05', 'v2_m_teen_01', 'v2_m_young_01', 'v2_m_mid_01', 'v2_m_senior_01', 'v2_f_young_01', 'v2_f_senior_01'
13
+ ]);
14
+ const TTS_VOICE_ID_SET = new Set(TTS_VOICE_IDS);
15
+ const AUTH_PROVIDERS = Object.freeze([
16
+ 'kakao', 'naver', 'toss', 'pass', 'samsung', 'kb', 'shinhan', 'hana', 'woori', 'ibk', 'nh', 'kakaobank', 'banksalad'
17
+ ]);
18
+ const AUTH_PROVIDER_SET = new Set(AUTH_PROVIDERS);
19
+
20
+ const SERVICE_DEFINITIONS = Object.freeze({
21
+ businessDetails: { endpoint: '/rest/biz_detail', timeoutMs: 50_000, output: 'json' },
22
+ ventureBusiness: { endpoint: '/rest/venture_biz_info', timeoutMs: 50_000, output: 'json' },
23
+ trackParcel: { endpoint: '/rest/parcel_tracking', timeoutMs: 30_000, output: 'json' },
24
+ trackParcelAuto: { endpoint: '/rest/parcel_tracking_auto', timeoutMs: 30_000, output: 'json' },
25
+ validateEmail: { endpoint: '/rest/check_email_valid', timeoutMs: 20_000, output: 'json' },
26
+ validatePhone: { endpoint: '/rest/check_phone_valid', timeoutMs: 20_000, output: 'json' },
27
+ holidays: { endpoint: '/rest/holiday_info', timeoutMs: 35_000, output: 'json' },
28
+ searchAddress: { endpoint: '/rest/search_juso', timeoutMs: 20_000, output: 'json' },
29
+ ocr: { endpoint: '/rest/ocr', timeoutMs: 35_000, output: 'json' },
30
+ maskResidentNumber: { endpoint: '/rest/hide_rrn', timeoutMs: 35_000, output: 'binary', filename: 'masked.png' },
31
+ maskResidenceCard: { endpoint: '/rest/identity_document_residence_card', timeoutMs: 35_000, output: 'json' },
32
+ maskPassport: { endpoint: '/rest/identity_document_passport', timeoutMs: 35_000, output: 'json' },
33
+ maskIdCard: { endpoint: '/rest/identity_document_id_card', timeoutMs: 35_000, output: 'json' },
34
+ maskDriverLicense: { endpoint: '/rest/identity_document_driver_license', timeoutMs: 35_000, output: 'json' },
35
+ dnsLookup: { endpoint: '/rest/nslookup', timeoutMs: 16_000, output: 'json' },
36
+ geolocate: { endpoint: '/rest/location', timeoutMs: 35_000, output: 'json' },
37
+ whois: { endpoint: '/rest/whois', timeoutMs: 35_000, output: 'json' },
38
+ googleSearch: { endpoint: '/rest/google_search', timeoutMs: 50_000, output: 'json' },
39
+ googleImageSearch: { endpoint: '/rest/google_image_search', timeoutMs: 50_000, output: 'json' },
40
+ googleNewsSearch: { endpoint: '/rest/google_news_search', timeoutMs: 50_000, output: 'json' },
41
+ googleShoppingSearch: { endpoint: '/rest/google_shopping_search', timeoutMs: 50_000, output: 'json' },
42
+ googleMapsSearch: { endpoint: '/rest/google_maps_search', timeoutMs: 100_000, output: 'json' },
43
+ googleRankCheck: { endpoint: '/rest/google_rank_check', timeoutMs: 50_000, output: 'json' },
44
+ instagramProfile: { endpoint: '/rest/instagram_profile', timeoutMs: 110_000, output: 'json' },
45
+ instagramPost: { endpoint: '/rest/instagram_post', timeoutMs: 110_000, output: 'json' },
46
+ tiktokProfile: { endpoint: '/rest/tiktok_profile', timeoutMs: 110_000, output: 'json' },
47
+ amazonProduct: { endpoint: '/rest/amazon_product', timeoutMs: 110_000, output: 'json' },
48
+ xProfile: { endpoint: '/rest/x_profile', timeoutMs: 110_000, output: 'json' },
49
+ xPost: { endpoint: '/rest/x_post', timeoutMs: 110_000, output: 'json' },
50
+ coupangProduct: { endpoint: '/rest/coupang_product', timeoutMs: 110_000, output: 'json' },
51
+ naverShoppingProduct: { endpoint: '/rest/naver_shopping_product', timeoutMs: 110_000, output: 'json' },
52
+ linkedinProfile: { endpoint: '/rest/linkedin_profile', timeoutMs: 110_000, output: 'json' },
53
+ linkedinCompany: { endpoint: '/rest/linkedin_company', timeoutMs: 110_000, output: 'json' },
54
+ linkedinJob: { endpoint: '/rest/linkedin_job', timeoutMs: 110_000, output: 'json' },
55
+ getScrapeJob: { endpoint: '/rest/scrape_jobs', timeoutMs: 35_000, output: 'json' },
56
+ screenshot: { endpoint: '/rest/url_screenshot', timeoutMs: 75_000, output: 'binary', filename: 'screenshot.jpeg' },
57
+ youtubeMetadata: { endpoint: '/rest/youtube_metadata', timeoutMs: 60_000, output: 'json' },
58
+ youtubeThumbnail: { endpoint: '/rest/youtube_thumbnail', timeoutMs: 60_000, output: 'binary', filename: 'thumbnail.jpg' },
59
+ youtubeSubtitleList: { endpoint: '/rest/youtube_subtitle_list', timeoutMs: 60_000, output: 'json' },
60
+ youtubeSubtitle: { endpoint: '/rest/youtube_subtitle', timeoutMs: 60_000, output: 'binary', filename: 'subtitle.vtt' },
61
+ youtubeSearch: { endpoint: '/rest/youtube_search', timeoutMs: 60_000, output: 'json' },
62
+ youtubeChannel: { endpoint: '/rest/youtube_channel', timeoutMs: 60_000, output: 'json' },
63
+ youtubePlaylist: { endpoint: '/rest/youtube_playlist', timeoutMs: 60_000, output: 'json' },
64
+ youtubeHashtag: { endpoint: '/rest/youtube_hashtag', timeoutMs: 60_000, output: 'json' },
65
+ youtubeComments: { endpoint: '/rest/youtube_comments', timeoutMs: 120_000, output: 'json' },
66
+ createTtsJob: { endpoint: '/rest/tts/jobs', timeoutMs: 35_000, output: 'json' },
67
+ createVideoJob: { endpoint: '/rest/seedance/jobs', timeoutMs: 60_000, output: 'json' },
68
+ htmlToPdf: { endpoint: '/rest/html_to_pdf', timeoutMs: 25_000, output: 'binary', filename: 'document.pdf' },
69
+ jsonToExcel: { endpoint: '/rest/json_to_excel', timeoutMs: 45_000, output: 'binary', filename: 'data.xlsx' },
70
+ summarize: { endpoint: '/rest/llm/text_summary', timeoutMs: 75_000, output: 'json' },
71
+ polish: { endpoint: '/rest/llm/text_polish', timeoutMs: 105_000, output: 'json' },
72
+ generateImages: { endpoint: '/rest/image-generation/generate', timeoutMs: 190_000, output: 'json' },
73
+ requestEmployment: { endpoint: '/rest/req_employment', timeoutMs: 35_000, output: 'json' },
74
+ requestPersonalIncome: { endpoint: '/rest/req_personal_income', timeoutMs: 35_000, output: 'json' },
75
+ requestNpsJoinHistory: { endpoint: '/rest/req_nps_join_history', timeoutMs: 35_000, output: 'json' },
76
+ requestDrivingLicense: { endpoint: '/rest/req_driving_license', timeoutMs: 35_000, output: 'json' },
77
+ requestHealthCheckup: { endpoint: '/rest/req_health_checkup', timeoutMs: 35_000, output: 'json' },
78
+ requestCashReceiptDeduction: { endpoint: '/rest/req_cash_receipt_deduction', timeoutMs: 35_000, output: 'json' },
79
+ requestTaxReturnHistory: { endpoint: '/rest/req_tax_return_history', timeoutMs: 35_000, output: 'json' }
80
+ });
81
+
82
+ const SERVICES = Object.freeze(Object.fromEntries(
83
+ Object.entries(SERVICE_DEFINITIONS).map(([name, definition]) => [
84
+ name,
85
+ Object.freeze({ endpoint: definition.endpoint, output: definition.output })
86
+ ])
87
+ ));
88
+
89
+ function normalizeSubagentId(value,prefix){if(typeof value!=='string'||!new RegExp('^'+prefix+'_[a-f0-9]{32}$').test(value))throw new TypeError('Invalid subagent identifier.');return value;}
90
+ function redact(value, apiKey) {
91
+ let text = String(value || '');
92
+ if (apiKey) text = text.split(apiKey).join('***');
93
+ return text.replace(/(Authorization\s*:\s*Bearer\s*)\S+/gi, '$1***').replace(/(CL_AUTH_KEY\s*[:=]\s*)\S+/gi, '$1***');
94
+ }
95
+
96
+ // SNS 프로필 입력: 주소면 url, 아니면 username 으로 보낸다.
97
+ function profileInput(value) {
98
+ const text = requiredString('usernameOrUrl', value, 2048);
99
+ return /^https?:\/\//i.test(text) ? { url: text } : { username: text.replace(/^@/, '') };
100
+ }
101
+
102
+ // 구글 지도 장소 입력: 주소면 url, 아니면 place_id(googleMapsSearch 결과의 place_id)로 보낸다.
103
+ function placeInput(value) {
104
+ const text = requiredString('placeIdOrUrl', value, 2048);
105
+ return /^https?:\/\//i.test(text) ? { url: text } : { place_id: text };
106
+ }
107
+
108
+ // 아마존 상품 입력: 주소면 url, 10자리 ASIN 이면 asin 으로 보낸다.
109
+ function amazonInput(value) {
110
+ const text = requiredString('urlOrAsin', value, 2048);
111
+ return /^https?:\/\//i.test(text) ? { url: text } : { asin: text.toUpperCase() };
112
+ }
113
+
114
+ // 쿠팡 상품 입력: 주소면 url, 상품 번호면 product_id 로 보낸다.
115
+ function coupangInput(value) {
116
+ const text = requiredString('urlOrProductId', value, 2048);
117
+ return /^https?:\/\//i.test(text) ? { url: text } : { product_id: text };
118
+ }
119
+
120
+ // 네이버 브랜드스토어 상품 입력: 상품 주소만 받는다.
121
+ function naverInput(value) {
122
+ return { url: requiredString('url', value, 2048) };
123
+ }
124
+
125
+ // LinkedIn 입력: 프로필(/in/), 회사(/company/), 채용공고(/jobs/view/) 주소.
126
+ function linkedinInput(value) {
127
+ return { url: requiredString('url', value, 2048) };
128
+ }
129
+
130
+ const SCRAPE_JOB_PRODUCTS = Object.freeze({ instagram_posts: 100, instagram_comments: 15, tiktok_search: 50, tiktok_video: 1, tiktok_comments: 100, amazon_reviews: 100, google_maps_place: 1 });
131
+
132
+ // 수집 작업 공통 옵션: maxResults(상품별 상한), idempotencyKey(응답을 못 받아 다시 보낼 때 같은 접수로 처리).
133
+ function scrapeJobOptions(product, payload, options) {
134
+ const config = options || {};
135
+ if (config.maxResults !== undefined && SCRAPE_JOB_PRODUCTS[product] > 1) {
136
+ const max = Number(config.maxResults);
137
+ if (!Number.isInteger(max) || max < 1 || max > SCRAPE_JOB_PRODUCTS[product]) throw new RangeError(`maxResults must be an integer from 1 to ${SCRAPE_JOB_PRODUCTS[product]}.`);
138
+ payload.max_results = max;
139
+ }
140
+ if (config.idempotencyKey !== undefined) {
141
+ if (typeof config.idempotencyKey !== 'string' || !/^[A-Za-z0-9_.:-]{8,128}$/.test(config.idempotencyKey)) throw new TypeError('idempotencyKey must be 8-128 characters of letters, digits, and . _ : -');
142
+ payload.idempotency_key = config.idempotencyKey;
143
+ }
144
+ return payload;
145
+ }
146
+
147
+ function requiredString(name, value, maxLength) {
148
+ if (typeof value !== 'string' || !value.trim()) {
149
+ throw new TypeError(`${name} must be a non-empty string.`);
150
+ }
151
+ const normalized = value.trim();
152
+ if (maxLength && normalized.length > maxLength) {
153
+ throw new RangeError(`${name} must not exceed ${maxLength} characters.`);
154
+ }
155
+ return normalized;
156
+ }
157
+
158
+ function positiveInteger(name, value, defaultValue) {
159
+ if (value === undefined || value === null || value === '') return defaultValue;
160
+ const number = Number(value);
161
+ if (!Number.isInteger(number) || number < 1) {
162
+ throw new RangeError(`${name} must be a positive integer.`);
163
+ }
164
+ return number;
165
+ }
166
+
167
+ function normalizeBusinessNumber(value) {
168
+ const normalized = requiredString('businessNumber', value).replace(/-/g, '');
169
+ if (!/^\d{10}$/.test(normalized)) {
170
+ throw new TypeError('businessNumber must contain exactly 10 digits.');
171
+ }
172
+ return normalized;
173
+ }
174
+
175
+ function normalizeUrl(value) {
176
+ const input = requiredString('url', value);
177
+ let parsed;
178
+ try {
179
+ parsed = new URL(input);
180
+ } catch {
181
+ throw new TypeError('url must be a valid HTTP or HTTPS URL.');
182
+ }
183
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
184
+ throw new TypeError('url must use HTTP or HTTPS.');
185
+ }
186
+ return parsed.toString();
187
+ }
188
+
189
+ function normalizeTtsJobId(value) {
190
+ const jobId = requiredString('jobId', value);
191
+ if (!/^[a-f0-9]{32}$/.test(jobId)) throw new TypeError('jobId must be a 32-character lowercase hexadecimal string.');
192
+ return jobId;
193
+ }
194
+
195
+ function normalizeTtsVoice(value) {
196
+ const voiceId = requiredString('voiceId', value, 160);
197
+ if (TTS_VOICE_ID_SET.has(voiceId)) throw new RangeError('This voiceId has retired. Select a current Gemini voice with listGeminiTtsVoices().');
198
+ return voiceId;
199
+ }
200
+
201
+ function normalizeAuthProvider(value) {
202
+ const provider = requiredString('authProvider', value).toLowerCase();
203
+ if (!AUTH_PROVIDER_SET.has(provider)) throw new RangeError('authProvider must be one of the supported simple-auth providers.');
204
+ return provider;
205
+ }
206
+
207
+ function normalizeBirthDate(value) {
208
+ const birthDate = requiredString('birthDate', value);
209
+ if (!/^(18|19|20)\d{6}$/.test(birthDate)) throw new TypeError('birthDate must be 8 digits (YYYYMMDD).');
210
+ return birthDate;
211
+ }
212
+
213
+ function normalizeKoreanMobile(value) {
214
+ const phone = requiredString('phone', value).replace(/[^0-9]/g, '');
215
+ if (!/^01[0-9]{8,9}$/.test(phone)) throw new TypeError('phone must be a Korean mobile number (digits only).');
216
+ return phone;
217
+ }
218
+
219
+ function normalizeTransactionId(value) {
220
+ const transactionId = requiredString('transactionId', value);
221
+ if (!/^[a-f0-9]{32}$/.test(transactionId)) throw new TypeError('transactionId must be a 32-character lowercase hexadecimal string.');
222
+ return transactionId;
223
+ }
224
+
225
+ function normalizeYearMonth(name, value) {
226
+ if (value === undefined || value === null || value === '') return undefined;
227
+ const text = requiredString(name, value);
228
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(text)) throw new TypeError(`${name} must be in YYYY-MM format.`);
229
+ return text;
230
+ }
231
+
232
+ // 정해진 값 중 하나. 비어 있으면 서버 기본값을 쓰도록 보내지 않는다.
233
+ function optionalChoice(name, value, choices) {
234
+ if (value === undefined || value === null || value === '') return undefined;
235
+ const text = String(value);
236
+ if (!choices.includes(text)) throw new RangeError(`${name} must be one of ${choices.join(', ')}.`);
237
+ return text;
238
+ }
239
+
240
+ function compact(payload) {
241
+ return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined));
242
+ }
243
+
244
+ function optionalRangeInteger(name, value, min, max) {
245
+ if (value === undefined || value === null || value === '') return undefined;
246
+ const number = Number(value);
247
+ if (!Number.isInteger(number) || number < min || number > max) {
248
+ throw new RangeError(`${name} must be an integer between ${min} and ${max}.`);
249
+ }
250
+ return number;
251
+ }
252
+
253
+ // 재직·소득·연금·면허·건강검진 등 간편인증 기반 조회 상품의 공통 입력.
254
+ // 서버는 최상위 평면 필드명을 그대로 받으므로 여기서 만든 객체가 폼 필드 이름이 된다.
255
+ function dataRequestInput(input) {
256
+ const config = input || {};
257
+ return {
258
+ name: requiredString('name', config.name),
259
+ birthDate: normalizeBirthDate(config.birthDate),
260
+ phone: normalizeKoreanMobile(config.phone),
261
+ authProvider: normalizeAuthProvider(config.authProvider)
262
+ };
263
+ }
264
+
265
+ const SKILL_CATEGORIES = Object.freeze(['data', 'ai', 'dev', 'document', 'marketing', 'finance', 'productivity', 'video', 'etc']);
266
+ const SKILL_CATEGORY_SET = new Set(SKILL_CATEGORIES);
267
+ const SKILL_SORTS = Object.freeze(['recommended', 'popular', 'used', 'likes', 'rating', 'new', 'mine', 'liked']);
268
+ const SKILL_SORT_SET = new Set(SKILL_SORTS);
269
+
270
+ function normalizeSkillId(value) {
271
+ return encodeURIComponent(requiredString('skillId', value, 80));
272
+ }
273
+
274
+ function normalizeSkillRunId(value) {
275
+ return encodeURIComponent(requiredString('runId', value, 64));
276
+ }
277
+
278
+ function skillInput(value) {
279
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('input must be a plain object matching the Skill input_schema.');
280
+ return value;
281
+ }
282
+
283
+ function optionalSkillText(name, value, maxLength) {
284
+ if (value === undefined || value === null || value === '') return undefined;
285
+ return requiredString(name, String(value), maxLength);
286
+ }
287
+
288
+ function numberOrNull(value) {
289
+ if (value === undefined || value === null || value === '') return null;
290
+ const number = Number(value);
291
+ return Number.isFinite(number) ? number : null;
292
+ }
293
+
294
+ function responseMeta(api, headers) {
295
+ const metadata = api && typeof api === 'object' ? api : {};
296
+ const readHeader = (name) => headers && typeof headers.get === 'function' ? headers.get(name) : null;
297
+ return Object.freeze({
298
+ cost: numberOrNull(metadata.cost ?? readHeader('cost')),
299
+ durationMs: numberOrNull(metadata.ms ?? readHeader('ms'))
300
+ });
301
+ }
302
+
303
+ function publicErrorMessage(body, fallback) {
304
+ if (!body || typeof body !== 'object') return fallback;
305
+ const candidates = [
306
+ body.data && body.data.error,
307
+ body.result && body.result.error,
308
+ body.error,
309
+ body.message,
310
+ body.msg
311
+ ];
312
+ for (const candidate of candidates) {
313
+ if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
314
+ }
315
+ return fallback;
316
+ }
317
+
318
+ function parseFilename(headerValue, fallback) {
319
+ if (typeof headerValue !== 'string') return fallback;
320
+ const encoded = /filename\*=UTF-8''([^;]+)/i.exec(headerValue);
321
+ if (encoded) {
322
+ try { return decodeURIComponent(encoded[1]).replace(/[\\/]/g, '_'); } catch { /* use fallback parser */ }
323
+ }
324
+ const plain = /filename="?([^";]+)"?/i.exec(headerValue);
325
+ return plain ? plain[1].trim().replace(/[\\/]/g, '_') : fallback;
326
+ }
327
+
328
+ function inferImageType(filename) {
329
+ const lower = String(filename || '').toLowerCase();
330
+ if (lower.endsWith('.png')) return 'image/png';
331
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
332
+ if (lower.endsWith('.webp')) return 'image/webp';
333
+ return '';
334
+ }
335
+
336
+ async function normalizeImage(image, options) {
337
+ const config = options || {};
338
+ let blob;
339
+ let filename = config.filename || '';
340
+
341
+ if (typeof image === 'string') {
342
+ const [{ readFile }, path] = await Promise.all([
343
+ import('node:fs/promises'),
344
+ import('node:path')
345
+ ]);
346
+ const bytes = await readFile(image);
347
+ filename = filename || path.basename(image);
348
+ blob = new Blob([bytes], { type: config.contentType || inferImageType(filename) });
349
+ } else if (typeof Blob !== 'undefined' && image instanceof Blob) {
350
+ blob = image;
351
+ filename = filename || (typeof image.name === 'string' ? image.name : 'image.png');
352
+ } else if (image && typeof image.arrayBuffer === 'function') {
353
+ const bytes = await image.arrayBuffer();
354
+ filename = filename || (typeof image.name === 'string' ? image.name : 'image.png');
355
+ blob = new Blob([bytes], { type: config.contentType || image.type || inferImageType(filename) });
356
+ } else if (image instanceof ArrayBuffer || ArrayBuffer.isView(image)) {
357
+ filename = filename || 'image.png';
358
+ blob = new Blob([image], { type: config.contentType || inferImageType(filename) });
359
+ } else {
360
+ throw new TypeError('image must be a file path, Blob, ArrayBuffer, or typed array.');
361
+ }
362
+
363
+ const contentType = config.contentType || blob.type || inferImageType(filename);
364
+ const allowedTypes = config.allowedTypes || ['image/png', 'image/jpeg'];
365
+ if (!allowedTypes.includes(contentType)) {
366
+ throw new TypeError(config.typeError || 'OCR supports PNG and JPEG images only.');
367
+ }
368
+ if (blob.size > (config.maxBytes || MAX_OCR_BYTES)) {
369
+ throw new RangeError('image must not exceed 50 MB.');
370
+ }
371
+ return { blob, filename, contentType };
372
+ }
373
+
374
+ class ApickApiError extends Error {
375
+ constructor(message, options) {
376
+ super(message);
377
+ this.name = 'ApickApiError';
378
+ this.status = options && options.status || 0;
379
+ this.code = options && options.code || 'APICK_API_ERROR';
380
+ this.serviceCode = options && options.serviceCode || undefined;
381
+ if (options && options.details !== undefined) this.details = options.details;
382
+ }
383
+
384
+ toJSON() {
385
+ return {
386
+ name: this.name,
387
+ message: this.message,
388
+ status: this.status,
389
+ code: this.code,
390
+ serviceCode: this.serviceCode
391
+ };
392
+ }
393
+ }
394
+
395
+ class ApickBinaryResult {
396
+ constructor(bytes, options) {
397
+ this.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
398
+ this.contentType = options.contentType || 'application/octet-stream';
399
+ this.filename = options.filename || 'result.bin';
400
+ this.meta = options.meta;
401
+ Object.freeze(this.meta);
402
+ }
403
+
404
+ get size() {
405
+ return this.bytes.byteLength;
406
+ }
407
+
408
+ toArrayBuffer() {
409
+ return this.bytes.buffer.slice(this.bytes.byteOffset, this.bytes.byteOffset + this.bytes.byteLength);
410
+ }
411
+
412
+ toBlob() {
413
+ return new Blob([this.bytes], { type: this.contentType });
414
+ }
415
+
416
+ async save(filePath) {
417
+ const target = requiredString('filePath', filePath);
418
+ const { writeFile } = await import('node:fs/promises');
419
+ await writeFile(target, this.bytes);
420
+ return target;
421
+ }
422
+ }
423
+
424
+ class ApickClient {
425
+ #apiKey;
426
+ #fetch;
427
+ #baseUrl;
428
+ #timeoutMs;
429
+
430
+ constructor(apiKeyOrOptions) {
431
+ const options = typeof apiKeyOrOptions === 'string'
432
+ ? { apiKey: apiKeyOrOptions }
433
+ : (apiKeyOrOptions || {});
434
+
435
+ this.#apiKey = requiredString('apiKey', options.apiKey);
436
+ this.#fetch = options.fetch || globalThis.fetch;
437
+ if (typeof this.#fetch !== 'function') {
438
+ throw new TypeError('A Fetch API implementation is required. Use Node.js 18+ or pass options.fetch.');
439
+ }
440
+
441
+ let baseUrl;
442
+ try {
443
+ baseUrl = new URL(options.baseUrl || DEFAULT_BASE_URL);
444
+ } catch {
445
+ throw new TypeError('baseUrl must be a valid HTTPS URL.');
446
+ }
447
+ if (baseUrl.protocol !== 'https:' || baseUrl.username || baseUrl.password) {
448
+ throw new TypeError('baseUrl must be an HTTPS URL without embedded credentials.');
449
+ }
450
+ this.#baseUrl = baseUrl.toString().replace(/\/+$/, '');
451
+ this.#timeoutMs = options.timeoutMs === undefined
452
+ ? null
453
+ : positiveInteger('timeoutMs', options.timeoutMs);
454
+ }
455
+
456
+ async _call(serviceName, payload, formData, requestOptions) {
457
+ const baseDefinition = SERVICE_DEFINITIONS[serviceName];
458
+ if (!baseDefinition) throw new TypeError(`Unknown APICK service: ${serviceName}`);
459
+ const definition = Object.assign({}, baseDefinition, requestOptions || {});
460
+
461
+ const controller = new AbortController();
462
+ const timeoutMs = this.#timeoutMs || definition.timeoutMs || DEFAULT_TIMEOUT_MS;
463
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
464
+ const headers = {
465
+ Accept: definition.output === 'binary' ? '*/*' : 'application/json',
466
+ Authorization: `Bearer ${this.#apiKey}`
467
+ };
468
+ const method = definition.method || 'POST';
469
+ if (definition.idempotencyKey) headers['X-Idempotency-Key'] = requiredString('idempotencyKey', definition.idempotencyKey, 128);
470
+ const request = {
471
+ method,
472
+ headers,
473
+ signal: controller.signal,
474
+ redirect: 'error'
475
+ };
476
+ if (method !== 'GET') {
477
+ if (definition.json) { headers['Content-Type']='application/json'; request.body=JSON.stringify(payload||{}); }
478
+ else request.body = formData || require('./form.cjs').createForm(payload || {}, definition.endpoint);
479
+ }
480
+
481
+ let response;
482
+ try {
483
+ response = await this.#fetch(this.#baseUrl + definition.endpoint, request);
484
+ } catch (error) {
485
+ const timedOut = controller.signal.aborted || error && error.name === 'AbortError';
486
+ throw new ApickApiError(
487
+ timedOut ? `APICK request timed out after ${timeoutMs} ms.` : redact(error && error.message || 'APICK network request failed.', this.#apiKey),
488
+ { code: timedOut ? 'APICK_TIMEOUT' : 'APICK_NETWORK_ERROR' }
489
+ );
490
+ } finally {
491
+ clearTimeout(timer);
492
+ }
493
+
494
+ const contentType = String(response.headers.get('content-type') || '').toLowerCase();
495
+ const isJson = contentType.includes('application/json') || contentType.includes('+json');
496
+
497
+ if (definition.output === 'binary' && !isJson && response.ok) {
498
+ const bytes = new Uint8Array(await response.arrayBuffer());
499
+ return new ApickBinaryResult(bytes, {
500
+ contentType: contentType.split(';')[0] || 'application/octet-stream',
501
+ filename: parseFilename(response.headers.get('content-disposition'), definition.filename),
502
+ meta: responseMeta(null, response.headers)
503
+ });
504
+ }
505
+
506
+ const raw = await response.text();
507
+ let body;
508
+ try {
509
+ body = raw ? JSON.parse(raw) : null;
510
+ } catch {
511
+ throw new ApickApiError(`APICK returned an unexpected response (HTTP ${response.status}).`, {
512
+ status: response.status,
513
+ code: 'APICK_INVALID_RESPONSE'
514
+ });
515
+ }
516
+
517
+ const failureMessage = publicErrorMessage(body, 'APICK request failed.');
518
+ const bodyFailed = Boolean(
519
+ body && body.data && body.data.error
520
+ || body && body.result && body.result.error
521
+ || body && body.api && body.api.success === false
522
+ );
523
+ if (!response.ok || bodyFailed || definition.output === 'binary') {
524
+ throw new ApickApiError(failureMessage, {
525
+ status: response.status,
526
+ code: response.status === 401 ? 'APICK_AUTH_ERROR' : 'APICK_API_ERROR',
527
+ serviceCode: body && body.data && typeof (body.data.code || body.data.error_code) === 'string' ? (body.data.code || body.data.error_code) : undefined
528
+ });
529
+ }
530
+
531
+ return Object.freeze({
532
+ data: body && Object.prototype.hasOwnProperty.call(body, 'data') ? body.data : body,
533
+ meta: responseMeta(body && body.api, response.headers)
534
+ });
535
+ }
536
+
537
+ businessDetails(businessNumber) {
538
+ return this._call('businessDetails', { biz_no: normalizeBusinessNumber(businessNumber) });
539
+ }
540
+
541
+ ventureBusiness(businessNumber) {
542
+ return this._call('ventureBusiness', { biz_no: normalizeBusinessNumber(businessNumber) });
543
+ }
544
+
545
+ trackParcel(carrier, trackingNumber) {
546
+ return this._call('trackParcel', {
547
+ carrier: requiredString('carrier', carrier),
548
+ trackingNumber: requiredString('trackingNumber', trackingNumber)
549
+ });
550
+ }
551
+
552
+ trackParcelAuto(trackingNumber) {
553
+ return this._call('trackParcelAuto', { trackingNumber: requiredString('trackingNumber', trackingNumber) });
554
+ }
555
+
556
+ validateEmail(email) {
557
+ return this._call('validateEmail', { email: requiredString('email', email) });
558
+ }
559
+
560
+ validatePhone(number) {
561
+ return this._call('validatePhone', { number: requiredString('number', number) });
562
+ }
563
+
564
+ holidays(year, month) {
565
+ const normalizedYear = Number(year);
566
+ const normalizedMonth = Number(month);
567
+ if (!Number.isInteger(normalizedYear) || normalizedYear < 1900 || normalizedYear > 2200) {
568
+ throw new RangeError('year must be an integer from 1900 through 2200.');
569
+ }
570
+ if (!Number.isInteger(normalizedMonth) || normalizedMonth < 1 || normalizedMonth > 12) {
571
+ throw new RangeError('month must be an integer from 1 through 12.');
572
+ }
573
+ return this._call('holidays', {
574
+ year: String(normalizedYear),
575
+ month: String(normalizedMonth).padStart(2, '0')
576
+ });
577
+ }
578
+
579
+ searchAddress(query, options) {
580
+ const config = options || {};
581
+ return this._call('searchAddress', {
582
+ juso: requiredString('query', query),
583
+ page: String(positiveInteger('page', config.page, 1))
584
+ });
585
+ }
586
+
587
+ async ocr(image, options) {
588
+ const upload = await normalizeImage(image, options);
589
+ const form = new FormData();
590
+ form.append('image', upload.blob, upload.filename);
591
+ return this._call('ocr', null, form);
592
+ }
593
+
594
+ async _maskImage(serviceName, image, options, type) {
595
+ const upload = await normalizeImage(image, options);
596
+ const form = new FormData();
597
+ form.append('image', upload.blob, upload.filename);
598
+ if (type !== undefined) form.append('type', String(type));
599
+ return this._call(serviceName, null, form);
600
+ }
601
+
602
+ maskResidentNumber(image, options) {
603
+ const config = options || {};
604
+ const type = Number(config.type);
605
+ if (![1, 2, 3, 4].includes(type)) throw new RangeError('type must be one of: 1, 2, 3, 4.');
606
+ return this._maskImage('maskResidentNumber', image, config, type);
607
+ }
608
+
609
+ maskResidenceCard(image, options) { return this._maskImage('maskResidenceCard', image, options); }
610
+ maskPassport(image, options) { return this._maskImage('maskPassport', image, options); }
611
+ maskIdCard(image, options) { return this._maskImage('maskIdCard', image, options); }
612
+ maskDriverLicense(image, options) { return this._maskImage('maskDriverLicense', image, options); }
613
+
614
+ dnsLookup(domain) {
615
+ return this._call('dnsLookup', { domain: requiredString('domain', domain) });
616
+ }
617
+
618
+ geolocate(address) {
619
+ return this._call('geolocate', { address: requiredString('address', address) });
620
+ }
621
+
622
+ whois(address) {
623
+ return this._call('whois', { address: requiredString('address', address) });
624
+ }
625
+
626
+ googleSearch(keyword, options) {
627
+ const config = options || {};
628
+ return this._call('googleSearch', {
629
+ keyword: requiredString('keyword', keyword),
630
+ page: String(positiveInteger('page', config.page, 1))
631
+ });
632
+ }
633
+
634
+ googleImageSearch(keyword, options) {
635
+ const config = options || {};
636
+ return this._call('googleImageSearch', {
637
+ keyword: requiredString('keyword', keyword),
638
+ page: String(positiveInteger('page', config.page, 1))
639
+ });
640
+ }
641
+
642
+ googleNewsSearch(keyword, options) {
643
+ const config = options || {};
644
+ return this._call('googleNewsSearch', {
645
+ keyword: requiredString('keyword', keyword, 200),
646
+ page: String(positiveInteger('page', config.page, 1))
647
+ });
648
+ }
649
+
650
+ googleShoppingSearch(keyword, options) {
651
+ const config = options || {};
652
+ return this._call('googleShoppingSearch', {
653
+ keyword: requiredString('keyword', keyword, 200),
654
+ page: String(positiveInteger('page', config.page, 1))
655
+ });
656
+ }
657
+
658
+ // 지도 장소 검색은 처리에 보통 30~60초가 걸린다.
659
+ googleMapsSearch(keyword) {
660
+ return this._call('googleMapsSearch', { keyword: requiredString('keyword', keyword, 200) });
661
+ }
662
+
663
+ // keyword 로 구글을 검색했을 때 domain 이 1~100위 중 몇 위인지 확인한다.
664
+ googleRankCheck(keyword, domain) {
665
+ return this._call('googleRankCheck', {
666
+ keyword: requiredString('keyword', keyword, 200),
667
+ domain: requiredString('domain', domain, 253)
668
+ });
669
+ }
670
+
671
+ // 사용자명 또는 프로필 주소. 처리에 보통 40~60초가 걸린다.
672
+ instagramProfile(usernameOrUrl) {
673
+ return this._call('instagramProfile', profileInput(usernameOrUrl));
674
+ }
675
+
676
+ // 게시물(/p/) 또는 릴스(/reel/) 주소.
677
+ instagramPost(url) {
678
+ return this._call('instagramPost', { url: requiredString('url', url, 2048) });
679
+ }
680
+
681
+ tiktokProfile(usernameOrUrl) {
682
+ return this._call('tiktokProfile', profileInput(usernameOrUrl));
683
+ }
684
+
685
+ // 상품 주소(/dp/ASIN) 또는 10자리 ASIN(미국 아마존).
686
+ amazonProduct(urlOrAsin) {
687
+ return this._call('amazonProduct', amazonInput(urlOrAsin));
688
+ }
689
+
690
+ xProfile(usernameOrUrl) {
691
+ return this._call('xProfile', profileInput(usernameOrUrl));
692
+ }
693
+
694
+ xPost(url) {
695
+ return this._call('xPost', { url: requiredString('url', url, 2048) });
696
+ }
697
+
698
+ // 상품 주소(/vp/products/{번호}) 또는 상품 번호. 처리에 보통 25~40초가 걸린다.
699
+ coupangProduct(urlOrProductId) {
700
+ return this._call('coupangProduct', coupangInput(urlOrProductId));
701
+ }
702
+
703
+ // 네이버 브랜드스토어 상품 주소(/{스토어}/products/{번호}). 스마트스토어 직링크는 받지 않는다.
704
+ naverShoppingProduct(url) {
705
+ return this._call('naverShoppingProduct', naverInput(url));
706
+ }
707
+
708
+ // LinkedIn 프로필 주소(/in/{아이디}). 처리에 보통 20초 안팎이 걸린다.
709
+ linkedinProfile(url) {
710
+ return this._call('linkedinProfile', linkedinInput(url));
711
+ }
712
+
713
+ // LinkedIn 회사 주소(/company/{아이디}).
714
+ linkedinCompany(url) {
715
+ return this._call('linkedinCompany', linkedinInput(url));
716
+ }
717
+
718
+ // LinkedIn 채용공고 주소(/jobs/view/{번호}).
719
+ linkedinJob(url) {
720
+ return this._call('linkedinJob', linkedinInput(url));
721
+ }
722
+
723
+ // 수집 작업: 접수 즉시 job_id 를 돌려준다. maxResults × 단가를 예약하고 결과를 받을 때 실제 건수만 차감한다.
724
+ _createScrapeJob(product, payload, options) {
725
+ return this._call('getScrapeJob', scrapeJobOptions(product, payload, options), null, { endpoint: '/rest/' + product + '/jobs', json: true });
726
+ }
727
+
728
+ createInstagramPostsJob(usernameOrUrl, options) {
729
+ return this._createScrapeJob('instagram_posts', profileInput(usernameOrUrl), options);
730
+ }
731
+
732
+ createInstagramCommentsJob(url, options) {
733
+ return this._createScrapeJob('instagram_comments', { url: requiredString('url', url, 2048) }, options);
734
+ }
735
+
736
+ createTiktokSearchJob(keyword, options) {
737
+ return this._createScrapeJob('tiktok_search', { keyword: requiredString('keyword', keyword, 100) }, options);
738
+ }
739
+
740
+ createTiktokVideoJob(url, options) {
741
+ return this._createScrapeJob('tiktok_video', { url: requiredString('url', url, 2048) }, options);
742
+ }
743
+
744
+ createTiktokCommentsJob(url, options) {
745
+ return this._createScrapeJob('tiktok_comments', { url: requiredString('url', url, 2048) }, options);
746
+ }
747
+
748
+ createAmazonReviewsJob(urlOrAsin, options) {
749
+ return this._createScrapeJob('amazon_reviews', amazonInput(urlOrAsin), options);
750
+ }
751
+
752
+ // 구글 지도 장소 상세·대표 리뷰(건당 고정가). 텍스트는 영어로 온다.
753
+ createGoogleMapsPlaceJob(placeIdOrUrl, options) {
754
+ return this._createScrapeJob('google_maps_place', placeInput(placeIdOrUrl), options);
755
+ }
756
+
757
+ // 상태·결과 조회(무료). 끝난 작업은 실제 결과 건수로 정산돼 있다.
758
+ getScrapeJob(jobId) {
759
+ if (typeof jobId !== 'string' || !/^[a-f0-9]{32}$/.test(jobId)) throw new TypeError('jobId must be a 32-character job ID.');
760
+ return this._call('getScrapeJob', null, null, { endpoint: '/rest/scrape_jobs/' + jobId, method: 'GET', timeoutMs: 70_000 });
761
+ }
762
+
763
+ // completed 또는 failed 가 될 때까지 조회한다. 기본 10초 간격, 최대 10분.
764
+ async waitForScrapeJob(jobId, options) {
765
+ const config = options || {};
766
+ const intervalMs = Math.max(3_000, positiveInteger('intervalMs', config.intervalMs, 10_000));
767
+ const deadline = Date.now() + positiveInteger('timeoutMs', config.timeoutMs, 600_000);
768
+ for (;;) {
769
+ const result = await this.getScrapeJob(jobId);
770
+ const status = result && result.data && result.data.status;
771
+ if (status === 'completed' || status === 'failed') return result;
772
+ if (Date.now() + intervalMs > deadline) throw new ApickApiError('Scrape job did not finish in time.', { code: 'APICK_TIMEOUT' });
773
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
774
+ }
775
+ }
776
+
777
+ screenshot(url) {
778
+ return this._call('screenshot', { url: normalizeUrl(url) });
779
+ }
780
+
781
+ // 유튜브 공개 영상. url 은 영상 주소(watch·youtu.be·shorts) 또는 11자리 영상 ID를 받는다.
782
+ youtubeMetadata(url) {
783
+ return this._call('youtubeMetadata', { url: requiredString('url', url, 2048) });
784
+ }
785
+
786
+ youtubeThumbnail(url) {
787
+ return this._call('youtubeThumbnail', { url: requiredString('url', url, 2048) });
788
+ }
789
+
790
+ youtubeSubtitleList(url) {
791
+ return this._call('youtubeSubtitleList', { url: requiredString('url', url, 2048) });
792
+ }
793
+
794
+ youtubeSubtitle(url, lang, options) {
795
+ const config = options || {};
796
+ const payload = { url: requiredString('url', url, 2048), lang: requiredString('lang', lang, 32) };
797
+ if (config.format !== undefined) {
798
+ if (!['vtt', 'srt', 'txt'].includes(config.format)) throw new RangeError('format must be vtt, srt or txt.');
799
+ payload.format = config.format;
800
+ }
801
+ if (config.type !== undefined) {
802
+ if (!['any', 'manual', 'auto'].includes(config.type)) throw new RangeError('type must be any, manual or auto.');
803
+ payload.type = config.type;
804
+ }
805
+ return this._call('youtubeSubtitle', payload, null, { filename: 'subtitle.' + (payload.format || 'vtt') });
806
+ }
807
+
808
+ // 키워드 검색. 영상·쇼츠·채널·재생목록을 돌려준다.
809
+ youtubeSearch(query, options) {
810
+ const config = options || {};
811
+ return this._call('youtubeSearch', compact({
812
+ query: requiredString('query', query, 200),
813
+ count: optionalRangeInteger('count', config.count, 1, 50),
814
+ sort: optionalChoice('sort', config.sort, ['relevance', 'date', 'views', 'rating']),
815
+ type: optionalChoice('type', config.type, ['any', 'video', 'channel', 'playlist', 'movie']),
816
+ upload_date: optionalChoice('uploadDate', config.uploadDate, ['any', 'hour', 'today', 'week', 'month', 'year']),
817
+ duration: optionalChoice('duration', config.duration, ['any', 'short', 'medium', 'long'])
818
+ }));
819
+ }
820
+
821
+ // channel 은 채널 주소, 핸들(@이름) 또는 채널 ID(UC…).
822
+ youtubeChannel(channel, options) {
823
+ const config = options || {};
824
+ return this._call('youtubeChannel', compact({
825
+ channel: requiredString('channel', channel, 2048),
826
+ tab: optionalChoice('tab', config.tab, ['videos', 'shorts', 'streams', 'playlists']),
827
+ count: optionalRangeInteger('count', config.count, 1, 100)
828
+ }));
829
+ }
830
+
831
+ youtubePlaylist(url, options) {
832
+ const config = options || {};
833
+ return this._call('youtubePlaylist', compact({ url: requiredString('url', url, 2048), count: optionalRangeInteger('count', config.count, 1, 200) }));
834
+ }
835
+
836
+ youtubeHashtag(hashtag, options) {
837
+ const config = options || {};
838
+ return this._call('youtubeHashtag', compact({ hashtag: requiredString('hashtag', hashtag, 101), count: optionalRangeInteger('count', config.count, 1, 100) }));
839
+ }
840
+
841
+ youtubeComments(url, options) {
842
+ const config = options || {};
843
+ if (config.replies !== undefined && typeof config.replies !== 'boolean') throw new TypeError('replies must be a boolean.');
844
+ return this._call('youtubeComments', compact({
845
+ url: requiredString('url', url, 2048),
846
+ count: optionalRangeInteger('count', config.count, 1, 200),
847
+ sort: optionalChoice('sort', config.sort, ['top', 'new']),
848
+ replies: config.replies === undefined ? undefined : String(config.replies)
849
+ }));
850
+ }
851
+
852
+ async createVideoJob(model, prompt, options) {
853
+ if (!['seedance', 'veo', 'kling'].includes(model)) throw new RangeError('model must be seedance, veo or kling.');
854
+ const config = options || {};
855
+ const payload = { prompt: config.mode === 'reference' && prompt === '' ? '' : requiredString('prompt', prompt, 2000) };
856
+ for (const [key, field] of Object.entries({ version:'version', tier:'tier', mode:'mode', duration:'duration', aspectRatio:'aspect_ratio', resolution:'resolution', audio:'audio', negativePrompt:'negative_prompt', seed:'seed', cfgScale:'cfg_scale', idempotencyKey:'idempotency_key' })) {
857
+ if (config[key] !== undefined) payload[field] = config[key];
858
+ }
859
+ const files = [['image', config.image], ['last_image', config.lastImage]];
860
+ for (const input of config.referenceImages || []) files.push(['reference_image', input]);
861
+ for (const input of config.referenceVideos || []) files.push(['reference_video', input]);
862
+ for (const input of config.referenceAudios || []) files.push(['reference_audio', input]);
863
+ const present = files.filter(([, input]) => input !== undefined);
864
+ const request = { endpoint: '/rest/' + model + '/jobs' };
865
+ if (!present.length) return this._call('createVideoJob', payload, null, request);
866
+ const form = new FormData();
867
+ for (const [key, value] of Object.entries(payload)) form.append(key, String(value));
868
+ for (const [field, input] of present) {
869
+ if (field !== 'reference_video' && field !== 'reference_audio') {
870
+ const upload = await normalizeImage(input);
871
+ form.append(field, upload.blob, upload.filename);
872
+ } else {
873
+ let bytes, filename, contentType;
874
+ if (typeof input === 'string') {
875
+ bytes = await require('node:fs/promises').readFile(input);
876
+ filename = require('node:path').basename(input);
877
+ contentType = field === 'reference_audio' ? (/\.wav$/i.test(filename) ? 'audio/wav' : 'audio/mpeg') : /\.webm$/i.test(filename) ? 'video/webm' : /\.mov$/i.test(filename) ? 'video/quicktime' : 'video/mp4';
878
+ } else { bytes = await input.arrayBuffer(); filename = input.name || (field === 'reference_audio' ? 'reference.mp3' : 'reference.mp4'); contentType = input.type || (field === 'reference_audio' ? 'audio/mpeg' : 'video/mp4'); }
879
+ const allowed = field === 'reference_audio' ? ['audio/mpeg', 'audio/wav', 'audio/x-wav'] : ['video/mp4', 'video/quicktime', 'video/webm'];
880
+ const maxBytes = field === 'reference_audio' ? 15 * 1024 * 1024 : 100 * 1024 * 1024;
881
+ if (!allowed.includes(contentType) || bytes.byteLength < 1 || bytes.byteLength > maxBytes) throw new RangeError(field === 'reference_audio' ? 'Invalid reference audio.' : 'Invalid reference video.');
882
+ form.append(field, new Blob([bytes], { type: contentType }), filename);
883
+ }
884
+ }
885
+ return this._call('createVideoJob', null, form, request);
886
+ }
887
+
888
+ getVideoJob(model, jobId) {
889
+ if (!['seedance', 'veo', 'kling'].includes(model) || !/^[a-f0-9]{32}$/.test(jobId)) throw new RangeError('Invalid video model or job ID.');
890
+ return this._call('createVideoJob', null, null, { endpoint:'/rest/'+model+'/jobs/'+jobId, method:'GET', timeoutMs:30_000 });
891
+ }
892
+
893
+ downloadVideoResult(model, jobId) {
894
+ if (!['seedance', 'veo', 'kling'].includes(model) || !/^[a-f0-9]{32}$/.test(jobId)) throw new RangeError('Invalid video model or job ID.');
895
+ return this._call('createVideoJob', null, null, { endpoint:'/rest/'+model+'/jobs/'+jobId+'/result', method:'GET', output:'binary', filename:jobId+'.mp4', timeoutMs:60_000 });
896
+ }
897
+
898
+ createTtsJob(text, options) {
899
+ const config = options || {};
900
+ const payload = typeof text==='object' && text!==null ? {...text} : {
901
+ voice_id: normalizeTtsVoice(config.voiceId || 'Kore'),
902
+ text: requiredString('text', text, config.normalizeText === false ? 8000 : 2000)
903
+ };
904
+ if(config.normalizeText!==undefined)payload.normalize_text=config.normalizeText;
905
+ if(config.fallbackPolicy!==undefined||config.fallbackOptions!==undefined||payload.fallback_policy!==undefined||payload.fallback_options!==undefined)throw new TypeError('TTS fallback has retired. Use createGeminiTtsJob or createOpenAiTtsJob.');
906
+ if(payload.normalize_text!==undefined&&typeof payload.normalize_text!=='boolean')throw new TypeError('normalizeText must be boolean.');
907
+ if(payload.voice_id!==undefined)normalizeTtsVoice(payload.voice_id);
908
+ return this._call('createTtsJob',payload,null,{json:true,idempotencyKey:config.idempotencyKey});
909
+ }
910
+
911
+ createGeminiTtsJob(input, options) {
912
+ if(!input||typeof input!=='object'||Array.isArray(input))throw new TypeError('Gemini TTS input is required.');
913
+ return this._call('createTtsJob', input, null, {endpoint:'/rest/tts/gemini/jobs',json:true,idempotencyKey:options?.idempotencyKey});
914
+ }
915
+ listGeminiTtsVoices() { return this._call('createTtsJob',null,null,{endpoint:'/rest/tts/gemini/voices',method:'GET'}); }
916
+ createOpenAiTtsJob(input, options) {
917
+ if(!input||typeof input!=='object'||Array.isArray(input))throw new TypeError('OpenAI TTS input is required.');
918
+ return this._call('createTtsJob',input,null,{endpoint:'/rest/tts/openai/jobs',json:true,idempotencyKey:options?.idempotencyKey});
919
+ }
920
+ listOpenAiTtsVoices() { return this._call('createTtsJob',null,null,{endpoint:'/rest/tts/openai/voices',method:'GET'}); }
921
+ getTtsOptions() { return this._call('createTtsJob',null,null,{endpoint:'/rest/tts/options',method:'GET'}); }
922
+ quoteTts(input) { return this._call('createTtsJob',input,null,{endpoint:'/rest/tts/quote',json:true}); }
923
+
924
+ getTtsJob(jobId) {
925
+ const id = normalizeTtsJobId(jobId);
926
+ return this._call('createTtsJob', null, null, { endpoint: '/rest/tts/jobs/' + id, method: 'GET' });
927
+ }
928
+
929
+ cancelTtsJob(jobId) {
930
+ const id = normalizeTtsJobId(jobId);
931
+ return this._call('createTtsJob', null, null, { endpoint: '/rest/tts/jobs/' + id + '/cancel' });
932
+ }
933
+
934
+ getTtsQuality(jobId) {
935
+ const id = normalizeTtsJobId(jobId);
936
+ return this._call('createTtsJob', null, null, { endpoint: '/rest/tts/jobs/' + id + '/quality', method: 'GET' });
937
+ }
938
+
939
+ retryTtsJob(jobId, utteranceIds, idempotencyKey) {
940
+ const id = normalizeTtsJobId(jobId);
941
+ if (!Array.isArray(utteranceIds) || utteranceIds.length > 100 || utteranceIds.some(value => typeof value !== 'string' || !/^u\d{3}$/.test(value))) {
942
+ throw new TypeError('utteranceIds must contain up to 100 uNNN identifiers.');
943
+ }
944
+ if (typeof idempotencyKey !== 'string' || !/^[A-Za-z0-9_-]{8,128}$/.test(idempotencyKey)) {
945
+ throw new TypeError('idempotencyKey must contain 8 to 128 letters, digits, underscores or hyphens.');
946
+ }
947
+ return this._call('createTtsJob', { utterance_ids: [...new Set(utteranceIds)].sort(), idempotency_key: idempotencyKey }, null,
948
+ { endpoint: '/rest/tts/jobs/' + id + '/retry' });
949
+ }
950
+
951
+ downloadTtsCandidate(jobId, candidateId) {
952
+ const id = normalizeTtsJobId(jobId);
953
+ if (typeof candidateId !== 'string' || !/^[a-f0-9]{32}$/.test(candidateId)) throw new TypeError('candidateId must be a 32-character hexadecimal identifier.');
954
+ return this._call('createTtsJob', null, null, { endpoint: '/rest/tts/jobs/' + id + '/candidates/' + candidateId + '/audio',
955
+ method: 'GET', output: 'binary', filename: candidateId + '.wav' });
956
+ }
957
+
958
+ downloadTtsResult(jobId) {
959
+ const id = normalizeTtsJobId(jobId);
960
+ return this._call('createTtsJob', null, null, {
961
+ endpoint: '/rest/tts/jobs/' + id + '/result',
962
+ method: 'GET',
963
+ output: 'binary',
964
+ filename: id + '.mp3'
965
+ });
966
+ }
967
+
968
+ downloadTtsSubtitles(jobId) {
969
+ const id = normalizeTtsJobId(jobId);
970
+ return this._call('createTtsJob', null, null, {
971
+ endpoint: '/rest/tts/jobs/' + id + '/subtitles',
972
+ method: 'GET',
973
+ output: 'binary',
974
+ filename: id + '.ass'
975
+ });
976
+ }
977
+
978
+ htmlToPdf(html, options) {
979
+ const config = options || {};
980
+ return this._call('htmlToPdf', {
981
+ html: requiredString('html', html),
982
+ pagination: config.pagination ? 1 : 0
983
+ });
984
+ }
985
+
986
+ jsonToExcel(data, options) {
987
+ if (!Array.isArray(data)) throw new TypeError('data must be an array.');
988
+ const config = options || {};
989
+ const payload = { data_list: data };
990
+ if (config.sheetName !== undefined) payload.sheet_name = requiredString('sheetName', config.sheetName);
991
+ return this._call('jsonToExcel', payload);
992
+ }
993
+
994
+ summarize(text) {
995
+ return this._call('summarize', { text: requiredString('text', text, 100_000) });
996
+ }
997
+
998
+ polish(text) {
999
+ return this._call('polish', { text: requiredString('text', text, 100_000) });
1000
+ }
1001
+
1002
+ _imageOptions(prompt, options, maxCount) {
1003
+ const config = options || {};
1004
+ for (const key of ['model', 'count', 'n', 'outputCompression', 'input_fidelity', 'moderation', 'mask', 'maskFilename', 'maskContentType']) {
1005
+ if (Object.prototype.hasOwnProperty.call(config, key)) throw new TypeError(`${key} is not a supported image option.`);
1006
+ }
1007
+ const imageCount = positiveInteger('imageCount', config.imageCount, 1);
1008
+ if (imageCount > maxCount) throw new RangeError(`imageCount must not exceed ${maxCount}.`);
1009
+ const size = config.size || '1024x1024';
1010
+ if (!IMAGE_AI_SIZE_SET.has(size)) throw new TypeError(`size must be one of: ${IMAGE_AI_SIZES.join(', ')}.`);
1011
+ const payload = {
1012
+ prompt: requiredString('prompt', prompt, 28_000), image_count: imageCount,
1013
+ size, output_format: config.outputFormat || 'png',
1014
+ background: config.background || 'auto'
1015
+ };
1016
+ if (config.quality !== undefined) {
1017
+ if (!IMAGE_AI_QUALITIES.includes(config.quality)) throw new TypeError(`quality must be one of: ${IMAGE_AI_QUALITIES.join(', ')}.`);
1018
+ payload.quality = config.quality;
1019
+ }
1020
+ // 서버가 같은 요청도 매번 새로 생성·과금하므로 idempotencyKey 는 보내지 않는다(예전 코드 호환을 위해 받기만 한다).
1021
+ return payload;
1022
+ }
1023
+
1024
+ async generateImages(prompt, options) {
1025
+ const config = options || {}, payload = this._imageOptions(prompt, config, 4);
1026
+ if (config.referenceImage === undefined) return this._call('generateImages', payload);
1027
+ const uploadOptions = {
1028
+ filename: config.referenceFilename, contentType: config.referenceContentType,
1029
+ allowedTypes:['image/png','image/jpeg','image/webp'], maxBytes:MAX_IMAGE_AI_BYTES,
1030
+ typeError:'referenceImage must be PNG, JPEG, or WebP.'
1031
+ };
1032
+ const source = await normalizeImage(config.referenceImage, uploadOptions), form = new FormData();
1033
+ Object.entries(payload).forEach(([key,value]) => form.append(key, String(value)));
1034
+ form.append('reference_image', source.blob, source.filename);
1035
+ return this._call('generateImages', null, form);
1036
+ }
1037
+
1038
+ async editImages(image, prompt, options) {
1039
+ const config = options || {}, payload = this._imageOptions(prompt, config, 4);
1040
+ const uploadOptions = Object.assign({}, config, { allowedTypes:['image/png','image/jpeg','image/webp'], maxBytes:MAX_IMAGE_AI_BYTES, typeError:'image must be PNG, JPEG, or WebP.' });
1041
+ const source = await normalizeImage(image, uploadOptions), form = new FormData();
1042
+ Object.entries(payload).forEach(([key,value]) => form.append(key, String(value)));
1043
+ form.append('image', source.blob, source.filename);
1044
+ return this._call('generateImages', null, form, { endpoint:'/rest/image-generation/edit' });
1045
+ }
1046
+
1047
+ async createImageGenerationJob(prompt, options) {
1048
+ const config = options || {}, payload = this._imageOptions(prompt, config, 50);
1049
+ if (config.referenceImage === undefined) return this._call('generateImages', payload, null, { endpoint:'/rest/image-generation/jobs/generate', timeoutMs:60_000 });
1050
+ const uploadOptions = {
1051
+ filename: config.referenceFilename, contentType: config.referenceContentType,
1052
+ allowedTypes:['image/png','image/jpeg','image/webp'], maxBytes:MAX_IMAGE_AI_BYTES,
1053
+ typeError:'referenceImage must be PNG, JPEG, or WebP.'
1054
+ };
1055
+ const source = await normalizeImage(config.referenceImage, uploadOptions), form = new FormData();
1056
+ Object.entries(payload).forEach(([key,value]) => form.append(key, String(value)));
1057
+ form.append('reference_image', source.blob, source.filename);
1058
+ return this._call('generateImages', null, form, { endpoint:'/rest/image-generation/jobs/generate', timeoutMs:60_000 });
1059
+ }
1060
+
1061
+ async createImageEditJob(image, prompt, options) {
1062
+ const config = options || {}, payload = this._imageOptions(prompt, config, 50);
1063
+ const uploadOptions = Object.assign({}, config, { allowedTypes:['image/png','image/jpeg','image/webp'], maxBytes:MAX_IMAGE_AI_BYTES, typeError:'image must be PNG, JPEG, or WebP.' });
1064
+ const source = await normalizeImage(image, uploadOptions), form = new FormData();
1065
+ Object.entries(payload).forEach(([key,value]) => form.append(key, String(value)));
1066
+ form.append('image', source.blob, source.filename);
1067
+ return this._call('generateImages', null, form, { endpoint:'/rest/image-generation/jobs/edit', timeoutMs:60_000 });
1068
+ }
1069
+
1070
+ getImageJob(jobId) {
1071
+ const id = normalizeTtsJobId(jobId);
1072
+ return this._call('generateImages', null, null, { endpoint:'/rest/image-generation/jobs/'+id, method:'GET', timeoutMs:30_000 });
1073
+ }
1074
+
1075
+ downloadImageJobImage(jobId, index) {
1076
+ const id=normalizeTtsJobId(jobId), value=Number(index);
1077
+ if(!Number.isInteger(value)||value<0||value>49) throw new RangeError('index must be an integer from 0 through 49.');
1078
+ return this._call('generateImages', null, null, { endpoint:'/rest/image-generation/jobs/'+id+'/images/'+value, method:'GET', output:'binary', filename:id+'-'+value+'.bin', timeoutMs:60_000 });
1079
+ }
1080
+
1081
+ downloadImageJobArchive(jobId) {
1082
+ const id=normalizeTtsJobId(jobId);
1083
+ return this._call('generateImages', null, null, { endpoint:'/rest/image-generation/jobs/'+id+'/result', method:'GET', output:'binary', filename:id+'.zip', timeoutMs:60_000 });
1084
+ }
1085
+
1086
+ // 간편인증 기반 조회 상품: 인증 요청(request*) 뒤 결과 조회(get*)로 폴링한다.
1087
+ // 각 상품은 접수 시 정액, 최초 결과 반환 시 항목 단가가 과금되고 재조회는 무과금이다.
1088
+ requestEmployment(input) {
1089
+ const payload = dataRequestInput(input);
1090
+ const insuranceYears = optionalRangeInteger('insuranceYears', (input || {}).insuranceYears, 1, 3);
1091
+ if (insuranceYears !== undefined) payload.insuranceYears = insuranceYears;
1092
+ return this._call('requestEmployment', payload);
1093
+ }
1094
+
1095
+ getEmployment(transactionId) {
1096
+ return this._call('requestEmployment', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_employment' });
1097
+ }
1098
+
1099
+ requestPersonalIncome(input) {
1100
+ const payload = dataRequestInput(input);
1101
+ const incomeYears = optionalRangeInteger('incomeYears', (input || {}).incomeYears, 1, 5);
1102
+ if (incomeYears !== undefined) payload.incomeYears = incomeYears;
1103
+ return this._call('requestPersonalIncome', payload);
1104
+ }
1105
+
1106
+ getPersonalIncome(transactionId) {
1107
+ return this._call('requestPersonalIncome', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_personal_income' });
1108
+ }
1109
+
1110
+ requestNpsJoinHistory(input) {
1111
+ const payload = dataRequestInput(input);
1112
+ const config = input || {};
1113
+ const from = normalizeYearMonth('from', config.from);
1114
+ const to = normalizeYearMonth('to', config.to);
1115
+ if (from !== undefined) payload.from = from;
1116
+ if (to !== undefined) payload.to = to;
1117
+ return this._call('requestNpsJoinHistory', payload);
1118
+ }
1119
+
1120
+ getNpsJoinHistory(transactionId) {
1121
+ return this._call('requestNpsJoinHistory', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_nps_join_history' });
1122
+ }
1123
+
1124
+ requestDrivingLicense(input) {
1125
+ return this._call('requestDrivingLicense', dataRequestInput(input));
1126
+ }
1127
+
1128
+ getDrivingLicense(transactionId) {
1129
+ return this._call('requestDrivingLicense', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_driving_license' });
1130
+ }
1131
+
1132
+ requestHealthCheckup(input) {
1133
+ return this._call('requestHealthCheckup', dataRequestInput(input));
1134
+ }
1135
+
1136
+ getHealthCheckup(transactionId) {
1137
+ return this._call('requestHealthCheckup', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_health_checkup' });
1138
+ }
1139
+
1140
+ requestCashReceiptDeduction(input) {
1141
+ const payload = dataRequestInput(input);
1142
+ const incomeYears = optionalRangeInteger('incomeYears', (input || {}).incomeYears, 1, 3);
1143
+ if (incomeYears !== undefined) payload.incomeYears = incomeYears;
1144
+ return this._call('requestCashReceiptDeduction', payload);
1145
+ }
1146
+
1147
+ getCashReceiptDeduction(transactionId) {
1148
+ return this._call('requestCashReceiptDeduction', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_cash_receipt_deduction' });
1149
+ }
1150
+
1151
+ requestTaxReturnHistory(input) {
1152
+ const payload = dataRequestInput(input);
1153
+ const years = optionalRangeInteger('years', (input || {}).years, 1, 10);
1154
+ if (years !== undefined) payload.years = years;
1155
+ return this._call('requestTaxReturnHistory', payload);
1156
+ }
1157
+
1158
+ getTaxReturnHistory(transactionId) {
1159
+ return this._call('requestTaxReturnHistory', { transactionId: normalizeTransactionId(transactionId) }, null, { endpoint: '/rest/get_tax_return_history' });
1160
+ }
1161
+
1162
+ // Skills: 요청·응답이 모두 JSON 이고 data·api 봉투 없이 응답 항목이 최상위에 온다.
1163
+ // 오류는 { error: { code, message, details } } 형식이며 code 를 serviceCode 로 돌려준다.
1164
+ async _skills(method, path, options) {
1165
+ const config = options || {};
1166
+ const controller = new AbortController();
1167
+ const timeoutMs = this.#timeoutMs || config.timeoutMs || DEFAULT_TIMEOUT_MS;
1168
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1169
+ const headers = { Accept: 'application/json', Authorization: `Bearer ${this.#apiKey}` };
1170
+ const request = { method, headers, signal: controller.signal, redirect: 'error' };
1171
+ if (config.idempotencyKey) headers['Idempotency-Key'] = config.idempotencyKey;
1172
+ if (method !== 'GET') {
1173
+ headers['Content-Type'] = 'application/json';
1174
+ request.body = JSON.stringify(config.body || {});
1175
+ }
1176
+ let url = this.#baseUrl + path;
1177
+ const query = new URLSearchParams(config.query || {}).toString();
1178
+ if (query) url += '?' + query;
1179
+
1180
+ let response;
1181
+ try {
1182
+ response = await this.#fetch(url, request);
1183
+ } catch (error) {
1184
+ const timedOut = controller.signal.aborted || error && error.name === 'AbortError';
1185
+ throw new ApickApiError(
1186
+ timedOut ? `APICK request timed out after ${timeoutMs} ms.` : redact(error && error.message || 'APICK network request failed.', this.#apiKey),
1187
+ { code: timedOut ? 'APICK_TIMEOUT' : 'APICK_NETWORK_ERROR' }
1188
+ );
1189
+ } finally {
1190
+ clearTimeout(timer);
1191
+ }
1192
+
1193
+ const raw = await response.text();
1194
+ let body;
1195
+ try {
1196
+ body = raw ? JSON.parse(raw) : null;
1197
+ } catch {
1198
+ body = undefined;
1199
+ }
1200
+ const failure = body && typeof body === 'object' && body.error && typeof body.error === 'object' ? body.error : null;
1201
+ if (!response.ok || failure) {
1202
+ throw new ApickApiError(failure && typeof failure.message === 'string' && failure.message || publicErrorMessage(body, 'APICK request failed.'), {
1203
+ status: response.status,
1204
+ code: response.status === 401 ? 'APICK_AUTH_ERROR' : 'APICK_API_ERROR',
1205
+ serviceCode: failure && typeof failure.code === 'string' ? failure.code : undefined,
1206
+ details: failure && failure.details && typeof failure.details === 'object' ? failure.details : undefined
1207
+ });
1208
+ }
1209
+ if (!body || typeof body !== 'object') {
1210
+ throw new ApickApiError(`APICK returned an unexpected response (HTTP ${response.status}).`, {
1211
+ status: response.status,
1212
+ code: 'APICK_INVALID_RESPONSE'
1213
+ });
1214
+ }
1215
+ return Object.freeze({ data: body, meta: responseMeta(null, response.headers) });
1216
+ }
1217
+
1218
+ searchSkills(options) {
1219
+ const config = options || {};
1220
+ const query = {};
1221
+ if (config.query !== undefined && config.query !== null && config.query !== '') query.query = requiredString('query', config.query, 60);
1222
+ if (config.category !== undefined && config.category !== null && config.category !== '') {
1223
+ const category = requiredString('category', config.category);
1224
+ if (!SKILL_CATEGORY_SET.has(category)) throw new RangeError('category must be one of the supported Skill categories.');
1225
+ query.category = category;
1226
+ }
1227
+ if (config.sort !== undefined && config.sort !== null && config.sort !== '') {
1228
+ const sort = requiredString('sort', config.sort);
1229
+ if (!SKILL_SORT_SET.has(sort)) throw new RangeError('sort must be one of the supported Skill sort orders.');
1230
+ query.sort = sort;
1231
+ }
1232
+ if (config.cursor !== undefined && config.cursor !== null && config.cursor !== '') query.cursor = requiredString('cursor', String(config.cursor), 20);
1233
+ const limit = optionalRangeInteger('limit', config.limit, 1, 20);
1234
+ if (limit !== undefined) query.limit = String(limit);
1235
+ return this._skills('GET', '/rest/skills', { query });
1236
+ }
1237
+
1238
+ getSkillPerformance() {
1239
+ return this._skills('GET', '/skills/performance/data.json');
1240
+ }
1241
+
1242
+ _subagent(method, route, options) { return this._skills(method, '/rest/subagent/v1' + route, options); }
1243
+ subagentStatus() { return this._subagent('GET', '/status'); }
1244
+ subagentUsage() { return this._subagent('GET', '/usage'); }
1245
+ createSubagentFile(input) { return this._subagent('POST', '/files', {body: input}); }
1246
+ getSubagentFile(fileId) { return this._subagent('GET', '/files/' + normalizeSubagentId(fileId,'saf')); }
1247
+ uploadSubagentPart(fileId, partNo, data) { if(!Number.isSafeInteger(partNo)||partNo<0||typeof data!=='string')throw new TypeError('Invalid upload part.');return this._subagent('POST','/files/'+normalizeSubagentId(fileId,'saf')+'/parts',{body:{part_no:partNo,data}}); }
1248
+ completeSubagentFile(fileId) { return this._subagent('POST','/files/'+normalizeSubagentId(fileId,'saf')+'/complete',{body:{}}); }
1249
+ deleteSubagentFile(fileId) { return this._subagent('DELETE','/files/'+normalizeSubagentId(fileId,'saf')); }
1250
+ dispatchSubagent(input, options) { const key=requiredString('idempotencyKey',options?.idempotencyKey,200);if(key.length<8)throw new TypeError('idempotencyKey must contain at least 8 characters.');return this._subagent('POST','/jobs',{body:input,idempotencyKey:key}); }
1251
+ collectSubagent(jobId, options) { return this._subagent('GET','/jobs/'+normalizeSubagentId(jobId,'saj'),{query:options?.cursor?{cursor:String(options.cursor)}:{}}); }
1252
+ subagentEvidence(jobId,evidenceIds) { return this._subagent('POST','/jobs/'+normalizeSubagentId(jobId,'saj')+'/evidence',{body:{evidence_ids:evidenceIds}}); }
1253
+ reviewSubagent(jobId,resultHash,decision) { if(!['accepted','rejected'].includes(decision)||!/^[a-f0-9]{64}$/.test(resultHash))throw new TypeError('Invalid review.');return this._subagent('POST','/jobs/'+normalizeSubagentId(jobId,'saj')+'/review',{body:{result_hash:resultHash,decision}}); }
1254
+ cancelSubagent(jobId) { return this._subagent('POST','/jobs/'+normalizeSubagentId(jobId,'saj')+'/cancel',{body:{}}); }
1255
+ deleteSubagentJob(jobId) { return this._subagent('DELETE','/jobs/'+normalizeSubagentId(jobId,'saj')); }
1256
+
1257
+ getSkill(skillId) {
1258
+ return this._skills('GET', '/rest/skills/' + normalizeSkillId(skillId));
1259
+ }
1260
+
1261
+ quoteSkill(skillId, input, options) {
1262
+ const body = { input: skillInput(input) };
1263
+ const version = optionalSkillText('version', (options || {}).version, 16);
1264
+ if (version !== undefined) body.version = version;
1265
+ return this._skills('POST', '/rest/skills/' + normalizeSkillId(skillId) + '/quotes', { body });
1266
+ }
1267
+
1268
+ // 같은 idempotencyKey 로 다시 보내면 새로 실행하지 않고 처음 실행을 돌려준다. 응답을 못 받았을 때는 같은 키로 재시도한다.
1269
+ runSkill(skillId, input, options) {
1270
+ const config = options || {};
1271
+ const idempotencyKey = requiredString('idempotencyKey', config.idempotencyKey);
1272
+ if (!/^[A-Za-z0-9._:-]{1,128}$/.test(idempotencyKey)) {
1273
+ throw new TypeError('idempotencyKey must be 1-128 characters of letters, digits, and . _ : -');
1274
+ }
1275
+ const body = { input: skillInput(input) };
1276
+ const version = optionalSkillText('version', config.version, 16);
1277
+ const quoteId = optionalSkillText('quoteId', config.quoteId, 40);
1278
+ const maxCostPoints = config.maxCostPoints === undefined || config.maxCostPoints === null ? undefined : positiveInteger('maxCostPoints', config.maxCostPoints);
1279
+ const waitSeconds = optionalRangeInteger('waitSeconds', config.waitSeconds, 0, 20);
1280
+ if (version !== undefined) body.version = version;
1281
+ if (quoteId !== undefined) body.quote_id = quoteId;
1282
+ if (maxCostPoints !== undefined) body.max_cost_points = maxCostPoints;
1283
+ if (waitSeconds !== undefined) body.wait_seconds = waitSeconds;
1284
+ return this._skills('POST', '/rest/skills/' + normalizeSkillId(skillId) + '/runs', { body, idempotencyKey, timeoutMs: 40_000 });
1285
+ }
1286
+
1287
+ getSkillRun(runId) {
1288
+ return this._skills('GET', '/rest/skills/runs/' + normalizeSkillRunId(runId));
1289
+ }
1290
+
1291
+ getSkillRunResult(runId) {
1292
+ return this._skills('GET', '/rest/skills/runs/' + normalizeSkillRunId(runId) + '/result');
1293
+ }
1294
+
1295
+ /** Download a file from a completed run owned by this API key. */
1296
+ async getSkillArtifact(runId, fileId) {
1297
+ const run = normalizeSkillRunId(runId);
1298
+ if (typeof fileId !== 'string' || !/^art_[a-f0-9]{24}$/.test(fileId)) throw new TypeError('fileId must be an artifact ID from a Skill result.');
1299
+ const controller = new AbortController();
1300
+ const timeoutMs = this.#timeoutMs || DEFAULT_TIMEOUT_MS;
1301
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1302
+ try {
1303
+ const response = await this.#fetch(this.#baseUrl + '/rest/skills/runs/' + run + '/files/' + fileId + '?download=1', {
1304
+ method: 'GET', headers: { Authorization: `Bearer ${this.#apiKey}` }, signal: controller.signal, redirect: 'error'
1305
+ });
1306
+ if (!response.ok) {
1307
+ let body; try { body = await response.json(); } catch { body = null; }
1308
+ throw new ApickApiError('APICK artifact download failed.', { status: response.status, code: response.status === 401 ? 'APICK_AUTH_ERROR' : 'APICK_API_ERROR', serviceCode: body && body.error && body.error.code });
1309
+ }
1310
+ const limit = 200 * 1024 * 1024;
1311
+ if (Number(response.headers.get('content-length')) > limit) { controller.abort(); throw new ApickApiError('APICK artifact exceeds the size limit.', { code: 'APICK_INVALID_RESPONSE' }); }
1312
+ const reader = response.body.getReader(), chunks = []; let size = 0;
1313
+ try {
1314
+ while (true) {
1315
+ const item = await reader.read(); if (item.done) break;
1316
+ size += item.value.byteLength;
1317
+ if (size > limit) { await reader.cancel(); throw new ApickApiError('APICK artifact exceeds the size limit.', { code: 'APICK_INVALID_RESPONSE' }); }
1318
+ chunks.push(item.value);
1319
+ }
1320
+ } finally { reader.releaseLock(); }
1321
+ const bytes = new Uint8Array(size); let offset = 0;
1322
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
1323
+ return new ApickBinaryResult(bytes, { contentType: (response.headers.get('content-type') || '').split(';')[0], filename: parseFilename(response.headers.get('content-disposition'), 'result.bin'), meta: responseMeta(null, response.headers) });
1324
+ } catch (error) {
1325
+ if (error instanceof ApickApiError) throw error;
1326
+ throw new ApickApiError(controller.signal.aborted ? 'APICK artifact download timed out.' : redact(error && error.message || 'APICK download failed.', this.#apiKey), { code: controller.signal.aborted ? 'APICK_TIMEOUT' : 'APICK_NETWORK_ERROR' });
1327
+ } finally { clearTimeout(timer); }
1328
+ }
1329
+
1330
+ cancelSkillRun(runId) {
1331
+ return this._skills('POST', '/rest/skills/runs/' + normalizeSkillRunId(runId) + '/cancel', { body: {} });
1332
+ }
1333
+
1334
+ skillUsage(options) {
1335
+ const config = options || {};
1336
+ const query = {};
1337
+ if (config.cursor !== undefined && config.cursor !== null && config.cursor !== '') query.cursor = requiredString('cursor', String(config.cursor), 20);
1338
+ const limit = optionalRangeInteger('limit', config.limit, 1, 50);
1339
+ if (limit !== undefined) query.limit = String(limit);
1340
+ return this._skills('GET', '/rest/skills/usage', { query });
1341
+ }
1342
+ }
1343
+
1344
+ module.exports = {
1345
+ ApickClient,
1346
+ ApickApiError,
1347
+ ApickBinaryResult,
1348
+ SERVICES,
1349
+ TTS_VOICE_IDS,
1350
+ AUTH_PROVIDERS,
1351
+ SKILL_CATEGORIES,
1352
+ SKILL_SORTS,
1353
+ DEFAULT_BASE_URL
1354
+ };