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"; import { consumeMcpExportToken } from "../../../lib/mcp-export-token"; 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 = { 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(); 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) { try { const url = new URL(request.url); const token = url.searchParams.get("token")?.trim() || ""; const tokenPayload = token ? await consumeMcpExportToken(token, "recovery") : null; if (!tokenPayload && !(await isAdminRequest(request))) return adminForbidden(); await ensureSchema(); const taskId = tokenPayload ? String(tokenPayload.taskId ?? "").trim() : 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(); 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 = [ 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 }, ); } }