431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
import { env } from "cloudflare:workers";
|
||
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
|
||
import { parseStoredDate } from "../../../lib/date-utils";
|
||
import {
|
||
downloadFeishuMedia,
|
||
type FeishuBindings,
|
||
} from "../../../lib/feishu-client";
|
||
import {
|
||
ensureSchema,
|
||
getRawDb,
|
||
getUploadBucket,
|
||
} from "../../../lib/mvp-db";
|
||
import {
|
||
buildRecoveryWorkbook,
|
||
type RecoveryWorkbookImage,
|
||
type RecoveryWorkbookRow,
|
||
} from "../../../lib/recovery-workbook";
|
||
|
||
export const runtime = "edge";
|
||
|
||
type StoredAsset = {
|
||
index: number;
|
||
key: string;
|
||
fileToken?: string;
|
||
width?: number | null;
|
||
height?: number | null;
|
||
};
|
||
|
||
type ExportRow = {
|
||
content_id: string;
|
||
source_row: number | null;
|
||
title: string;
|
||
body: string;
|
||
image_assets: string;
|
||
distribution_id: string | null;
|
||
publish_url: string | null;
|
||
publish_time: string | null;
|
||
publish_screenshot_key: string | null;
|
||
screenshot_key: string | null;
|
||
exposure: number | null;
|
||
views: number | null;
|
||
d2_likes: number | null;
|
||
d2_comments: number | null;
|
||
d2_collects: number | null;
|
||
d5_likes: number | null;
|
||
d5_comments: number | null;
|
||
d5_collects: number | null;
|
||
d7_likes: number | null;
|
||
d7_comments: number | null;
|
||
d7_collects: number | null;
|
||
latest_likes: number | null;
|
||
latest_comments: number | null;
|
||
latest_collects: number | null;
|
||
collection_status: string | null;
|
||
collection_status_description: string | null;
|
||
collection_updated_at: string | null;
|
||
updated_at: string | null;
|
||
account_nickname: string | null;
|
||
partner_name: string | null;
|
||
claimant_name: string | null;
|
||
};
|
||
|
||
type ImageReference = StoredAsset & {
|
||
description: string;
|
||
};
|
||
|
||
function parseAssets(value: string) {
|
||
try {
|
||
const parsed = JSON.parse(value || "[]") as StoredAsset[];
|
||
if (!Array.isArray(parsed)) return [];
|
||
return parsed
|
||
.filter(
|
||
(asset) =>
|
||
Number.isInteger(Number(asset.index)) &&
|
||
Number(asset.index) > 0 &&
|
||
typeof asset.key === "string" &&
|
||
asset.key.startsWith("content-assets/"),
|
||
)
|
||
.map((asset) => ({ ...asset, index: Number(asset.index) }))
|
||
.sort((left, right) => left.index - right.index);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function latestMetrics(row: ExportRow) {
|
||
const legacyDay =
|
||
row.d7_likes !== null
|
||
? 7
|
||
: row.d5_likes !== null
|
||
? 5
|
||
: row.d2_likes !== null
|
||
? 2
|
||
: null;
|
||
const legacyLikes =
|
||
legacyDay === 7
|
||
? row.d7_likes
|
||
: legacyDay === 5
|
||
? row.d5_likes
|
||
: legacyDay === 2
|
||
? row.d2_likes
|
||
: null;
|
||
const legacyComments =
|
||
legacyDay === 7
|
||
? row.d7_comments
|
||
: legacyDay === 5
|
||
? row.d5_comments
|
||
: legacyDay === 2
|
||
? row.d2_comments
|
||
: null;
|
||
const legacyCollects =
|
||
legacyDay === 7
|
||
? row.d7_collects
|
||
: legacyDay === 5
|
||
? row.d5_collects
|
||
: legacyDay === 2
|
||
? row.d2_collects
|
||
: null;
|
||
const likes = row.latest_likes ?? legacyLikes;
|
||
const comments = row.latest_comments ?? legacyComments;
|
||
const collects = row.latest_collects ?? legacyCollects;
|
||
return {
|
||
likes,
|
||
comments,
|
||
collects,
|
||
total:
|
||
likes === null ? null : likes + (comments ?? 0) + (collects ?? 0),
|
||
};
|
||
}
|
||
|
||
function formatExportDate(value: string | null) {
|
||
if (!value) return "";
|
||
const date = parseStoredDate(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
timeZone: "Asia/Shanghai",
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
}).format(date);
|
||
}
|
||
|
||
function collectionLabel(row: ExportRow) {
|
||
const labels: Record<string, string> = {
|
||
pending: "待设置",
|
||
scheduled: "已计划",
|
||
collecting: "采集中",
|
||
success: "采集成功",
|
||
failed: "采集失败",
|
||
};
|
||
const status = row.collection_status || (row.latest_likes !== null ? "success" : "pending");
|
||
const label = labels[status] || status;
|
||
return row.collection_status_description
|
||
? `${label}:${row.collection_status_description}`
|
||
: label;
|
||
}
|
||
|
||
function contentTypeFromObject(object: R2ObjectBody) {
|
||
const headers = new Headers();
|
||
object.writeHttpMetadata(headers);
|
||
return headers.get("Content-Type") || "application/octet-stream";
|
||
}
|
||
|
||
async function loadImage(reference: ImageReference) {
|
||
const bucket = getUploadBucket();
|
||
let object = await bucket.get(reference.key);
|
||
if (!object && reference.fileToken) {
|
||
const media = await downloadFeishuMedia(
|
||
reference.fileToken,
|
||
env as unknown as FeishuBindings,
|
||
);
|
||
await bucket.put(reference.key, media.bytes, {
|
||
httpMetadata: { contentType: media.contentType },
|
||
customMetadata: { source: "feishu-api" },
|
||
});
|
||
object = await bucket.get(reference.key);
|
||
}
|
||
if (!object) return null;
|
||
return {
|
||
bytes: new Uint8Array(await object.arrayBuffer()),
|
||
contentType: contentTypeFromObject(object),
|
||
width: reference.width,
|
||
height: reference.height,
|
||
description: reference.description,
|
||
} satisfies RecoveryWorkbookImage;
|
||
}
|
||
|
||
async function loadImages(references: ImageReference[]) {
|
||
const unique = new Map(references.map((reference) => [reference.key, reference]));
|
||
const queue = [...unique.values()];
|
||
const loaded = new Map<string, RecoveryWorkbookImage | null>();
|
||
const workers = Array.from({ length: Math.min(6, queue.length) }, async () => {
|
||
while (queue.length > 0) {
|
||
const reference = queue.shift();
|
||
if (!reference) return;
|
||
try {
|
||
loaded.set(reference.key, await loadImage(reference));
|
||
} catch {
|
||
loaded.set(reference.key, null);
|
||
}
|
||
}
|
||
});
|
||
await Promise.all(workers);
|
||
return loaded;
|
||
}
|
||
|
||
function safeFileName(value: string) {
|
||
return value.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 60) || "KOC数据回收";
|
||
}
|
||
|
||
function exactArrayBuffer(bytes: Uint8Array) {
|
||
const copy = new Uint8Array(bytes.byteLength);
|
||
copy.set(bytes);
|
||
return copy.buffer;
|
||
}
|
||
|
||
export async function GET(request: Request) {
|
||
if (!(await isAdminRequest(request))) return adminForbidden();
|
||
try {
|
||
await ensureSchema();
|
||
const taskId = new URL(request.url).searchParams.get("task")?.trim() || "";
|
||
if (!taskId) {
|
||
return Response.json({ error: "缺少导出任务" }, { status: 400 });
|
||
}
|
||
const db = getRawDb();
|
||
const task = await db
|
||
.prepare("SELECT id, name, brand FROM tasks WHERE id = ?")
|
||
.bind(taskId)
|
||
.first<{ id: string; name: string; brand: string }>();
|
||
if (!task) {
|
||
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
|
||
}
|
||
const result = await db
|
||
.prepare(
|
||
`SELECT
|
||
c.id AS content_id,
|
||
c.source_row,
|
||
c.title,
|
||
c.body,
|
||
c.image_assets,
|
||
d.id AS distribution_id,
|
||
d.publish_url,
|
||
d.publish_time,
|
||
d.publish_screenshot_key,
|
||
d.screenshot_key,
|
||
d.exposure,
|
||
d.views,
|
||
d.d2_likes,
|
||
d.d2_comments,
|
||
d.d2_collects,
|
||
d.d5_likes,
|
||
d.d5_comments,
|
||
d.d5_collects,
|
||
d.d7_likes,
|
||
d.d7_comments,
|
||
d.d7_collects,
|
||
d.latest_likes,
|
||
d.latest_comments,
|
||
d.latest_collects,
|
||
d.collection_status,
|
||
d.collection_status_description,
|
||
d.collection_updated_at,
|
||
d.updated_at,
|
||
a.nickname AS account_nickname,
|
||
p.name AS partner_name,
|
||
cl.claimant_name
|
||
FROM contents c
|
||
LEFT JOIN distributions d ON d.content_id = c.id
|
||
LEFT JOIN accounts a ON a.id = d.account_id
|
||
LEFT JOIN partners p ON p.id = d.partner_id
|
||
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||
WHERE c.task_id = ?
|
||
ORDER BY COALESCE(c.source_row, 999999), c.created_at, d.claimed_at`,
|
||
)
|
||
.bind(taskId)
|
||
.all<ExportRow>();
|
||
const rows = result.results;
|
||
const assetsByContent = new Map(
|
||
rows.map((row) => [row.content_id, parseAssets(row.image_assets)]),
|
||
);
|
||
const maxContentImages = Math.max(
|
||
0,
|
||
...[...assetsByContent.values()].flat().map((asset) => asset.index),
|
||
);
|
||
const references: ImageReference[] = [];
|
||
rows.forEach((row) => {
|
||
for (const asset of assetsByContent.get(row.content_id) ?? []) {
|
||
references.push({
|
||
...asset,
|
||
description: `${row.title} 原图${asset.index}`,
|
||
});
|
||
}
|
||
if (row.screenshot_key) {
|
||
references.push({
|
||
index: 0,
|
||
key: row.screenshot_key,
|
||
description: `${row.title} 创作者中心截图`,
|
||
});
|
||
}
|
||
if (row.publish_screenshot_key) {
|
||
references.push({
|
||
index: 0,
|
||
key: row.publish_screenshot_key,
|
||
description: `${row.title} 发布截图`,
|
||
});
|
||
}
|
||
});
|
||
const loadedImages = await loadImages(references);
|
||
const headers = [
|
||
"序号(不能改)",
|
||
"标题",
|
||
"笔记内容(正文+话题)",
|
||
...Array.from({ length: maxContentImages }, (_, index) => `图片${index + 1}`),
|
||
"小红书昵称",
|
||
"发布链接",
|
||
"发布时间",
|
||
"点赞",
|
||
"收藏",
|
||
"评论",
|
||
"总互动",
|
||
"曝光量-实际(第7天)",
|
||
"阅读量-实际(第7天)",
|
||
"数据分析截图(单篇笔记数据分析截图)",
|
||
"笔记截图",
|
||
"数据更新时间",
|
||
"采集状态",
|
||
"合作方",
|
||
"领取人微信号/手机号",
|
||
];
|
||
const originalImageStart = 3;
|
||
const accountColumn = originalImageStart + maxContentImages;
|
||
const creatorScreenshotColumn = accountColumn + 9;
|
||
const publishScreenshotColumn = creatorScreenshotColumn + 1;
|
||
const workbookRows: RecoveryWorkbookRow[] = rows.map((row, rowIndex) => {
|
||
const metrics = latestMetrics(row);
|
||
const contentAssets = assetsByContent.get(row.content_id) ?? [];
|
||
const originalImageCells = Array.from(
|
||
{ length: maxContentImages },
|
||
(_, index) => {
|
||
const asset = contentAssets.find((item) => item.index === index + 1);
|
||
return asset && loadedImages.get(asset.key) ? "见图" : asset ? "图片读取失败" : "";
|
||
},
|
||
);
|
||
const creatorImage = row.screenshot_key
|
||
? loadedImages.get(row.screenshot_key) ?? null
|
||
: null;
|
||
const publishImage = row.publish_screenshot_key
|
||
? loadedImages.get(row.publish_screenshot_key) ?? null
|
||
: null;
|
||
const cells: Array<string | number | null> = [
|
||
row.source_row ?? rowIndex + 1,
|
||
row.title,
|
||
row.body,
|
||
...originalImageCells,
|
||
row.account_nickname || "",
|
||
row.publish_url || "",
|
||
formatExportDate(row.publish_time),
|
||
metrics.likes,
|
||
metrics.collects,
|
||
metrics.comments,
|
||
metrics.total,
|
||
row.exposure,
|
||
row.views,
|
||
creatorImage ? "见图" : row.screenshot_key ? "截图读取失败" : "",
|
||
publishImage ? "见图" : row.publish_screenshot_key ? "截图读取失败" : "",
|
||
formatExportDate(row.collection_updated_at || row.updated_at),
|
||
row.distribution_id ? collectionLabel(row) : "未领取",
|
||
row.partner_name || "",
|
||
row.claimant_name || "",
|
||
];
|
||
const images: RecoveryWorkbookRow["images"] = [];
|
||
for (const asset of contentAssets) {
|
||
const image = loadedImages.get(asset.key);
|
||
if (image) images.push({ column: originalImageStart + asset.index - 1, image });
|
||
}
|
||
if (creatorImage) {
|
||
images.push({ column: creatorScreenshotColumn, image: creatorImage });
|
||
}
|
||
if (publishImage) {
|
||
images.push({ column: publishScreenshotColumn, image: publishImage });
|
||
}
|
||
return { cells, images };
|
||
});
|
||
const columnWidths = [
|
||
12,
|
||
28,
|
||
56,
|
||
...Array.from({ length: maxContentImages }, () => 24),
|
||
18,
|
||
40,
|
||
20,
|
||
11,
|
||
11,
|
||
11,
|
||
11,
|
||
18,
|
||
18,
|
||
26,
|
||
24,
|
||
20,
|
||
24,
|
||
18,
|
||
22,
|
||
];
|
||
const workbook = buildRecoveryWorkbook({
|
||
sheetName: `${task.name}-数据回收`,
|
||
headers,
|
||
columnWidths,
|
||
rows: workbookRows,
|
||
});
|
||
const fileName = `${safeFileName(task.name)}-数据回收.xlsx`;
|
||
return new Response(exactArrayBuffer(workbook), {
|
||
headers: {
|
||
"Cache-Control": "private, no-store",
|
||
"Content-Type":
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
"Content-Disposition": `attachment; filename="koc-recovery.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||
"X-Content-Type-Options": "nosniff",
|
||
},
|
||
});
|
||
} catch (error) {
|
||
return Response.json(
|
||
{ error: error instanceof Error ? error.message : "导出失败" },
|
||
{ status: 500 },
|
||
);
|
||
}
|
||
}
|