316 lines
8.8 KiB
JavaScript
316 lines
8.8 KiB
JavaScript
|
|
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,
|
||
|
|
}),
|
||
|
|
);
|