Add recovery Excel export and automatic account detection

This commit is contained in:
巫凤萍
2026-08-05 11:40:29 +08:00
parent b081946752
commit 85110bbfce
15 changed files with 852 additions and 36 deletions

View File

@@ -36,7 +36,6 @@ type PartnerBody = {
claimantIdentifier?: string;
claimantName?: string;
quantity?: number;
accountNickname?: string;
publishUrl?: string;
exposure?: number | string;
views?: number | string;
@@ -762,11 +761,10 @@ async function handlePost(request: Request) {
if (body.action === "submit") {
const claimToken = textValue(body.claimToken, 80);
const distributionId = textValue(body.distributionId, 80);
const accountNickname = textValue(body.accountNickname, 80);
const publishInput = textValue(body.publishUrl, 5000);
if (!accountNickname || !publishInput) {
if (!publishInput) {
return Response.json(
{ error: "请填写发布账号昵称和发布链接" },
{ error: "请填写发布链接" },
{ status: 400 },
);
}
@@ -777,7 +775,7 @@ async function handlePost(request: Request) {
{ status: 400 },
);
}
const account = accountFromPublishLink(publishUrl, accountNickname);
const account = accountFromPublishLink(publishUrl);
if (!account) {
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
}
@@ -881,7 +879,7 @@ async function handlePost(request: Request) {
db,
assignment.id,
publishUrl,
accountNickname,
account.nickname,
resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings,
),

View File

@@ -0,0 +1,430 @@
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 (!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 },
);
}
}