1
2
3import { Actor, log } from 'apify';
4import { createHash } from 'node:crypto';
5
6await Actor.init();
7const input = (await Actor.getInput()) ?? {};
8const clean = (v) => (typeof v === 'string' ? v.trim() : v);
9
10const fail = async (msg) => {
11 log.error(msg);
12 await Actor.fail(msg);
13};
14
15
16const resource = input.payload?.resource || {};
17const datasetId = clean(input.datasetId) || resource.defaultDatasetId;
18const kvStoreId = clean(input.keyValueStoreId) || resource.defaultKeyValueStoreId;
19const runId = resource.id || '';
20const folderId = String(clean(input.folderId) || '').replace(/^.*\/folder\//, '').replace(/\D.*$/, '');
21
22
23
24const hasBoxAuth = clean(input.boxDeveloperToken) || (clean(input.boxClientId) && clean(input.boxClientSecret));
25if (!folderId && !hasBoxAuth && !datasetId && !kvStoreId) {
26 const steps = [
27 '1) Create a Box Platform app (Server Authentication, Client Credentials Grant) and copy its Client ID and Client Secret from the App Details panel.',
28 '2) Fill in "Box folder ID" plus the Box connection fields (or a developer token for a quick test).',
29 '3) Open any Actor or task > Integrations > Add integration > Save to Box, so every run lands in Box automatically.',
30 ];
31 log.info(`Save to Box is not configured yet, so nothing was uploaded. To set it up:\n${steps.join('\n')}`);
32 await Actor.pushData({ status: 'not-configured', message: 'Save to Box is installed but not configured yet. See the README for the 5-minute setup.', steps });
33 await Actor.exit({ statusMessage: 'Not configured yet: add a Box folder ID and Box connection (see README). Nothing was uploaded.' });
34}
35
36if (!folderId) await fail('Set "Box folder ID" (the number at the end of the folder URL in Box).');
37if (!datasetId && !(input.saveFiles && kvStoreId)) {
38 await fail('Nothing to save: no dataset given. Attach this Actor as an integration to another Actor, or pick a dataset under "Source".');
39}
40if (resource.status && resource.status !== 'SUCCEEDED') log.warning(`Source run finished with status ${resource.status}; saving whatever it produced.`);
41
42
43let actorName = clean(input.actorName) || '';
44if (!actorName && resource.actId) {
45 try {
46 const act = await Actor.apifyClient.actor(resource.actId).get();
47 actorName = act?.title || act?.name || '';
48 } catch { }
49}
50if (!actorName && datasetId) {
51 try {
52 const ds = await Actor.apifyClient.dataset(datasetId).get();
53 actorName = ds?.name || '';
54 } catch { }
55}
56actorName ||= resource.actId ? `actor-${resource.actId}` : 'apify';
57
58const started = resource.startedAt ? new Date(resource.startedAt) : new Date();
59const vars = {
60 actor: actorName.replace(/[\\/:*?"<>|]+/g, '-').trim(),
61 date: started.toISOString().slice(0, 10),
62 time: started.toISOString().slice(11, 16).replace(':', ''),
63 runId: runId || 'manual',
64};
65const fill = (tpl) => String(tpl).replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m).replace(/[\\:*?"<>|]+/g, '-').trim();
66
67
68const BOX = process.env.BOX_API_BASE || 'https://api.box.com/2.0';
69const UPLOAD = process.env.BOX_UPLOAD_BASE || 'https://upload.box.com/api/2.0';
70async function getToken() {
71
72 const dev = clean(input.boxDeveloperToken);
73 const id = clean(input.boxClientId);
74 const secret = clean(input.boxClientSecret);
75 const subjectType = input.boxSubjectType || 'enterprise';
76 const subjectId = clean(input.boxSubjectId);
77 if (id && secret && subjectId) {
78 const res = await fetch(`${process.env.BOX_OAUTH_BASE || 'https://api.box.com'}/oauth2/token`, {
79 method: 'POST',
80 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
81 body: new URLSearchParams({
82 grant_type: 'client_credentials', client_id: id, client_secret: secret,
83 box_subject_type: subjectType, box_subject_id: subjectId,
84 }),
85 });
86 const json = await res.json().catch(() => ({}));
87 if (res.ok) return json.access_token;
88 const hint = {
89 invalid_client: 'Client ID or secret is wrong (copy them again from the Box Developer Console).',
90 unauthorized_client: 'The app is not authorized in this enterprise yet: a Box admin must approve it (Admin Console > Integrations > Platform Apps Manager), and for "A specific Box user" the app needs "Generate user access tokens".',
91 invalid_grant: `The ${subjectType} ID looks wrong or does not belong to this app's enterprise.`,
92 }[json.error] || '';
93 throw new Error(`Box sign-in failed (${res.status} ${json.error || ''}). ${hint}`.trim());
94 }
95 if (dev) return dev;
96 throw new Error('No Box connection: fill in client ID, client secret and enterprise/user ID (or a developer token for a quick test).');
97}
98
99let auth;
100try {
101 auth = { Authorization: `Bearer ${await getToken()}` };
102} catch (e) {
103 await fail(e.message);
104}
105
106async function box(path, opts = {}) {
107
108
109 for (let attempt = 1; ; attempt++) {
110 let res;
111 try {
112 res = await fetch(path.startsWith('http') ? path : `${BOX}${path}`, {
113 ...opts,
114 headers: { ...auth, ...(opts.json ? { 'Content-Type': 'application/json' } : {}), ...opts.headers },
115 body: opts.json ? JSON.stringify(opts.json) : opts.body,
116 });
117 } catch (e) {
118 if (attempt >= 5) throw e;
119 await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
120 continue;
121 }
122 const retryAfter = Number(res.headers.get('retry-after')) || 0;
123 const transient = res.status === 429 || res.status >= 500 || (res.status === 202 && retryAfter);
124 if (transient && attempt < 6) {
125 const wait = Math.min(60, retryAfter || 2 ** attempt) * 1000;
126 log.warning(`Box ${res.status} on ${opts.method || 'GET'} ${path.replace(/\?.*/, '')}; retrying in ${wait / 1000}s (attempt ${attempt}/5)`);
127 await res.arrayBuffer().catch(() => {});
128 await new Promise((r) => setTimeout(r, wait));
129 continue;
130 }
131 const text = await res.text();
132 let json;
133 try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
134 if (!res.ok) {
135 const err = new Error(`Box ${opts.method || 'GET'} ${path.replace(/\?.*/, '')} -> ${res.status} ${json.code || ''}: ${json.message || text.slice(0, 200)}`);
136 err.status = res.status;
137 err.json = json;
138 throw err;
139 }
140 return json;
141 }
142}
143
144
145let me;
146try {
147 me = await box('/users/me?fields=login,name');
148} catch (e) {
149 await fail(`Box rejected the token: ${e.message}`);
150}
151let target;
152try {
153 target = await box(`/folders/${folderId}?fields=name,path_collection,permissions`);
154} catch (e) {
155 if (e.status === 404 || e.status === 403) {
156 await fail(`Box folder ${folderId} is not visible to the account "${me.name}" (${me.login}). In Box, open the folder > Share > Invite people, add ${me.login} as Editor, then run again.`);
157 }
158 await fail(e.message);
159}
160if (target.permissions && target.permissions.can_upload === false) {
161 await fail(`"${me.login}" can see folder "${target.name}" but cannot upload. Give it Editor (or Uploader) access in Box.`);
162}
163log.info(`Connected to Box as ${me.name} (${me.login}); target folder "${target.name}" (${folderId}).`);
164
165
166async function ensureFolder(parentId, name) {
167 try {
168 return (await box('/folders', { method: 'POST', json: { name, parent: { id: parentId } } })).id;
169 } catch (e) {
170 const conflict = e.json?.context_info?.conflicts;
171 const id = Array.isArray(conflict) ? conflict[0]?.id : conflict?.id;
172 if (e.status === 409 && id) return id;
173 throw e;
174 }
175}
176
177const sha1 = (buf) => createHash('sha1').update(buf).digest('base64');
178
179async function simpleUpload(parentId, name, buf, existingId) {
180 const form = new FormData();
181 form.append('attributes', JSON.stringify(existingId ? { name } : { name, parent: { id: parentId } }));
182 form.append('file', new Blob([buf]), name);
183 const url = existingId ? `${UPLOAD}/files/${existingId}/content` : `${UPLOAD}/files/content`;
184 return (await box(url, { method: 'POST', body: form, headers: { 'Content-MD5': createHash('sha1').update(buf).digest('hex') } })).entries[0];
185}
186
187async function chunkedUpload(parentId, name, buf, existingId) {
188 const session = existingId
189 ? await box(`${UPLOAD}/files/${existingId}/upload_sessions`, { method: 'POST', json: { file_size: buf.length, file_name: name } })
190 : await box(`${UPLOAD}/files/upload_sessions`, { method: 'POST', json: { folder_id: parentId, file_size: buf.length, file_name: name } });
191 const parts = [];
192 for (let offset = 0; offset < buf.length; offset += session.part_size) {
193 const chunk = buf.subarray(offset, Math.min(offset + session.part_size, buf.length));
194 const r = await box(session.session_endpoints.upload_part, {
195 method: 'PUT', body: chunk,
196 headers: {
197 'Content-Type': 'application/octet-stream', Digest: `sha=${sha1(chunk)}`,
198 'Content-Range': `bytes ${offset}-${offset + chunk.length - 1}/${buf.length}`,
199 },
200 });
201 parts.push(r.part);
202 log.info(` uploaded ${Math.round(((offset + chunk.length) / buf.length) * 100)}% of ${name}`);
203 }
204 const done = await box(session.session_endpoints.commit, { method: 'POST', json: { parts }, headers: { Digest: `sha=${sha1(buf)}` } });
205 return done.entries[0];
206}
207
208const CHUNK_THRESHOLD = 40 * 1024 * 1024;
209async function uploadFile(parentId, name, buf) {
210 const up = (existingId) => (buf.length > CHUNK_THRESHOLD ? chunkedUpload : simpleUpload)(parentId, name, buf, existingId);
211 try {
212 return { file: await up(), versioned: false };
213 } catch (e) {
214
215 const existingId = e.json?.context_info?.conflicts?.id;
216 if (e.status === 409 && existingId) return { file: await up(existingId), versioned: true };
217 throw e;
218 }
219}
220
221async function finishFile(file) {
222 const description = `Saved by Apify "Save to Box" from ${actorName}${runId ? ` (run ${runId})` : ''} on ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC`;
223 const patch = { description: description.slice(0, 255) };
224 if (input.sharedLink && input.sharedLink !== 'none') patch.shared_link = { access: input.sharedLink };
225 try {
226 const f = await box(`/files/${file.id}?fields=shared_link`, { method: 'PUT', json: patch });
227 return f.shared_link?.url || null;
228 } catch (e) {
229 log.warning(`Could not set description/shared link on ${file.name}: ${e.message}`);
230 return null;
231 }
232}
233
234
235let destId = folderId;
236if (clean(input.subfolder)) {
237 for (const part of fill(input.subfolder).split('/').map((s) => s.trim()).filter(Boolean)) destId = await ensureFolder(destId, part);
238}
239const baseName = (fill(input.fileName || '{actor} {date} {time}').replace(/\//g, '-').replace(/[. ]+$/, '').slice(0, 200)) || `${vars.actor} ${vars.date}`;
240const results = [];
241
242const record = async (kind, name, buf, { file, versioned }) => {
243 const link = await finishFile(file);
244 const row = {
245 kind, name, sizeBytes: buf.length, boxFileId: file.id, newVersion: versioned,
246 url: `https://app.box.com/file/${file.id}`, sharedLink: link,
247 };
248 results.push(row);
249 log.info(`${versioned ? 'Updated' : 'Saved'} ${name} (${(buf.length / 1024).toFixed(1)} KB) -> ${row.url}`);
250};
251
252
253const formats = Array.isArray(input.formats) ? input.formats : ['xlsx'];
254if (datasetId && formats.length) {
255 const ds = Actor.apifyClient.dataset(datasetId);
256 let info;
257 try {
258 info = await ds.get();
259 } catch (e) {
260 await fail(`Cannot read dataset ${datasetId}: ${e.message}. If you entered it by hand, pick it with the dataset picker so this Actor is allowed to read it.`);
261 }
262 const count = info?.itemCount ?? 0;
263 if (!count && !input.uploadEmpty) {
264 log.info('Dataset is empty; nothing to upload (enable "Upload when the dataset is empty" to change this).');
265 } else {
266 await Actor.setStatusMessage(`Exporting ${count} items as ${formats.join(', ')}`);
267 const opts = { clean: true, ...(input.fields?.length ? { fields: input.fields } : {}) };
268 for (const fmt of formats) {
269 const buf = Buffer.from(await ds.downloadItems(fmt, opts));
270 const name = `${baseName}.${fmt}`;
271 await Actor.setStatusMessage(`Uploading ${name} to Box`);
272 await record(`dataset-${fmt}`, name, buf, await uploadFile(destId, name, buf));
273 }
274 }
275}
276
277
278const EXT = {
279 'application/pdf': 'pdf', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
280 'text/html': 'html', 'text/plain': 'txt', 'text/csv': 'csv', 'application/json': 'json', 'text/markdown': 'md',
281 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
282 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', 'application/zip': 'zip',
283};
284const SKIP = /^(INPUT|OUTPUT_SCHEMA|SDK_|CRAWLEE_|apify-|STATE|.*_STATE$|.*-state$)/i;
285if (input.saveFiles && kvStoreId) {
286 const store = Actor.apifyClient.keyValueStore(kvStoreId);
287 const keys = [];
288 let exclusiveStartKey;
289 try {
290 do {
291 const page = await store.listKeys({ limit: 1000, exclusiveStartKey, ...(clean(input.fileKeyPrefix) ? { prefix: clean(input.fileKeyPrefix) } : {}) });
292 keys.push(...page.items.filter((i) => !SKIP.test(i.key)));
293 exclusiveStartKey = page.isTruncated ? page.nextExclusiveStartKey : undefined;
294 } while (exclusiveStartKey && keys.length < (input.maxFiles ?? 200));
295 } catch (e) {
296 await fail(`Cannot read key-value store ${kvStoreId}: ${e.message}`);
297 }
298 const take = keys.slice(0, input.maxFiles ?? 200);
299 if (keys.length > take.length) log.warning(`Only uploading the first ${take.length} of ${keys.length}+ files (raise "Max files per run").`);
300 const filesFolder = formats.length && datasetId ? await ensureFolder(destId, `${baseName} files`) : destId;
301 let n = 0;
302 for (const { key } of take) {
303 const rec = await store.getRecord(key, { buffer: true });
304 if (!rec?.value) continue;
305 const buf = Buffer.isBuffer(rec.value) ? rec.value : Buffer.from(typeof rec.value === 'string' ? rec.value : JSON.stringify(rec.value, null, 2));
306 const type = String(rec.contentType || '').split(';')[0].trim();
307 const ext = EXT[type] || (type.startsWith('text/') ? 'txt' : 'bin');
308 const name = /\.[a-z0-9]{2,5}$/i.test(key) ? key : `${key}.${ext}`;
309 await Actor.setStatusMessage(`Uploading file ${++n}/${take.length}: ${name}`);
310 try {
311 await record('file', name.replace(/[\\/:*?"<>|]+/g, '-'), buf, await uploadFile(filesFolder, name.replace(/[\\/:*?"<>|]+/g, '-'), buf));
312 } catch (e) { log.warning(`Skipped ${key}: ${e.message}`); }
313 }
314}
315
316
317await Actor.pushData(results);
318const folderUrl = `https://app.box.com/folder/${destId}`;
319await Actor.setValue('OUTPUT', { folderUrl, files: results });
320const summary = results.length
321 ? `Saved ${results.length} file${results.length > 1 ? 's' : ''} to Box folder "${target.name}"${destId !== folderId ? ' (subfolder)' : ''}: ${folderUrl}`
322 : 'Nothing to upload.';
323await Actor.exit({ statusMessage: summary });