import { execFile } from "node:child_process"; import { mkdir, readFile, readdir, stat, writeFile, } from "node:fs/promises"; import { extname, resolve } from "node:path"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const projectRoot = resolve(import.meta.dirname, ".."); const snapshotPath = resolve(projectRoot, "lib/feishu-source-snapshot.json"); const downloadDir = "/private/tmp/koc-feishu-images-954953"; const uploadOriginArg = process.argv.find((item) => item.startsWith("--upload-origin="), ); const uploadOrigin = uploadOriginArg ? uploadOriginArg.slice("--upload-origin=".length).replace(/\/$/, "") : ""; const internalToken = process.env.KOC_ADMIN_INTERNAL_TOKEN ?? process.env.ADMIN_INTERNAL_TOKEN ?? ""; const reuseDownloads = process.argv.includes("--reuse-downloads"); const fromRowArg = process.argv.find((item) => item.startsWith("--from-row=")); const fromRow = fromRowArg ? Math.max(1, Number(fromRowArg.slice("--from-row=".length)) || 1) : 1; if (uploadOrigin && !internalToken) { throw new Error( "KOC_ADMIN_INTERNAL_TOKEN is required when --upload-origin is provided", ); } const snapshot = JSON.parse(await readFile(snapshotPath, "utf8")); const sourceUrl = "https://eodzc79n5l.feishu.cn/wiki/BSzxwRbGJi5dWoksauicUjtonks?from=from_copylink&sheet=954953"; const quietEnv = { ...process.env, LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1", LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1", }; await mkdir(downloadDir, { recursive: true }); const { stdout: cellsStdout } = await execFileAsync( "lark-cli", [ "sheets", "+cells-get", "--url", sourceUrl, "--sheet-id", snapshot.sheetId, "--range", "F2:H62", "--include", "value", "--max-chars", "500000", "--format", "json", ], { cwd: projectRoot, env: quietEnv, maxBuffer: 2_000_000, }, ); const cellsPayload = JSON.parse(cellsStdout); if (!cellsPayload.ok || cellsPayload.data?.has_more) { throw new Error("Feishu image cells were not returned completely"); } const range = cellsPayload.data?.ranges?.[0]; if (!range || range.truncated || range.actual_range !== "F2:H62") { throw new Error( `Unexpected Feishu image range: ${range?.actual_range ?? "missing"}`, ); } const sourceRows = new Map( snapshot.rows.map((row) => [Number(row.sourceRow), row]), ); const assets = []; for (let rowIndex = 0; rowIndex < range.cells.length; rowIndex += 1) { const sheetRow = Number(range.row_indices[rowIndex]); const sourceRow = sheetRow - 1; if (!sourceRows.has(sourceRow)) continue; const cells = range.cells[rowIndex] ?? []; for (let columnIndex = 0; columnIndex < cells.length; columnIndex += 1) { const column = range.col_indices[columnIndex]; const imageIndex = ["F", "G", "H"].indexOf(column) + 1; if (imageIndex < 1) continue; const richText = cells[columnIndex]?.rich_text ?? []; const image = richText.find((item) => item.type === "embed-image"); if (!image?.image_token) continue; assets.push({ sourceRow, imageIndex, token: image.image_token, width: Number(image.image_width) || null, height: Number(image.image_height) || null, key: `content-assets/${snapshot.sheetId}/${sourceRow}/${imageIndex}`, }); } } async function downloadAsset(asset) { const baseName = `row-${asset.sourceRow}-image-${asset.imageIndex}`; if (reuseDownloads) { const existingName = (await readdir(downloadDir)).find((name) => name.startsWith(`${baseName}.`), ); if (existingName) { const localPath = resolve(downloadDir, existingName); const extension = extname(existingName).toLowerCase(); const fileInfo = await stat(localPath); return { ...asset, localPath, contentType: extension === ".png" ? "image/png" : extension === ".webp" ? "image/webp" : "image/jpeg", sizeBytes: fileInfo.size, }; } } const { stdout } = await execFileAsync( "lark-cli", [ "docs", "+media-download", "--token", asset.token, "--output", `./${baseName}`, "--overwrite", ], { cwd: downloadDir, env: quietEnv, maxBuffer: 200_000, }, ); const result = JSON.parse(stdout.slice(stdout.indexOf("{"))); if (!result.ok || !result.data?.saved_path) { throw new Error( `Failed to download row ${asset.sourceRow} image ${asset.imageIndex}`, ); } return { ...asset, localPath: result.data.saved_path, contentType: result.data.content_type || "application/octet-stream", sizeBytes: Number(result.data.size_bytes) || 0, }; } const downloaded = []; const queue = [...assets]; const workers = Array.from({ length: 4 }, async () => { while (queue.length > 0) { const asset = queue.shift(); if (!asset) return; downloaded.push(await downloadAsset(asset)); } }); await Promise.all(workers); downloaded.sort( (left, right) => left.sourceRow - right.sourceRow || left.imageIndex - right.imageIndex, ); for (const row of snapshot.rows) { row.images = downloaded .filter((asset) => asset.sourceRow === Number(row.sourceRow)) .map((asset) => ({ index: asset.imageIndex, key: asset.key, width: asset.width, height: asset.height, })); } await writeFile(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); if (uploadOrigin) { const sharp = (await import("sharp")).default; const uploadConcurrency = new URL(uploadOrigin).hostname === "localhost" ? 4 : 1; async function prepareUpload(asset) { const original = await readFile(asset.localPath); if (original.byteLength < 800_000) { return { bytes: original, contentType: asset.contentType, extension: extname(asset.localPath) || ".bin", }; } let quality = 86; let compressed = await sharp(original) .rotate() .flatten({ background: "#ffffff" }) .resize({ width: 1800, height: 2200, fit: "inside", withoutEnlargement: true, }) .jpeg({ quality, mozjpeg: true }) .toBuffer(); while (compressed.byteLength > 800_000 && quality > 58) { quality -= 7; compressed = await sharp(original) .rotate() .flatten({ background: "#ffffff" }) .resize({ width: 1600, height: 2000, fit: "inside", withoutEnlargement: true, }) .jpeg({ quality, mozjpeg: true }) .toBuffer(); } return { bytes: compressed, contentType: "image/jpeg", extension: ".jpg", }; } const uploadQueue = downloaded.filter((asset) => asset.sourceRow >= fromRow); const uploadWorkers = Array.from({ length: uploadConcurrency }, async () => { while (uploadQueue.length > 0) { const asset = uploadQueue.shift(); if (!asset) return; const prepared = await prepareUpload(asset); let uploaded = false; let lastError = "unknown error"; for (let attempt = 1; attempt <= 3; attempt += 1) { const form = new FormData(); form.append("sheetId", snapshot.sheetId); form.append("sourceRow", String(asset.sourceRow)); form.append("imageIndex", String(asset.imageIndex)); form.append( "file", new File( [prepared.bytes], `image-${asset.imageIndex}${prepared.extension}`, { type: prepared.contentType, }, ), ); const response = await fetch( `${uploadOrigin}/api/content-image-upload`, { method: "POST", headers: { "X-KOC-Admin-Token": internalToken, }, body: form, }, ); const responseText = await response.text(); let result = {}; try { result = JSON.parse(responseText); } catch { result = { error: responseText || `HTTP ${response.status}` }; } if (response.ok) { uploaded = true; break; } lastError = result.error ?? `HTTP ${response.status}`; if (response.status < 500 && response.status !== 404) break; await new Promise((resolveDelay) => setTimeout(resolveDelay, attempt * 750), ); } if (!uploaded) { throw new Error( `Upload failed for row ${asset.sourceRow} image ${asset.imageIndex}: ${lastError}`, ); } } }); await Promise.all(uploadWorkers); } const totalBytes = downloaded.reduce( (sum, asset) => sum + asset.sizeBytes, 0, ); console.log( JSON.stringify({ rows: snapshot.rows.length, images: downloaded.length, totalBytes, uploaded: Boolean(uploadOrigin), uploadedImages: uploadOrigin ? downloaded.filter((asset) => asset.sourceRow >= fromRow).length : 0, downloadDir, }), );