2026-08-05 11:40:29 +08:00
|
|
|
|
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) {
|
2026-08-07 11:06:20 +08:00
|
|
|
|
if (!(await isAdminRequest(request))) return adminForbidden();
|
2026-08-05 11:40:29 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|