import path from "node:path"; import { strFromU8, unzipSync } from "fflate"; export const PARTNER_BATCH_HEADERS = [ "序号(不能改)", "标题", "笔记内容(正文+话题)", "图片", "发布链接", "笔记截图", "数据分析截图(单篇笔记数据分析截图)", "_系统笔记ID", "_原笔记截图", "_原数据分析截图", ] as const; export const PARTNER_BATCH_VISIBLE_COLUMN_COUNT = 7; export const PARTNER_BATCH_MAX_BYTES = 80_000_000; export type PartnerBatchWorkbookColumns = { headers: string[]; columnWidths: number[]; sourceImageStartColumn: number; sourceImageCount: number; sourceVideoStartColumn: number; sourceVideoCount: number; publishUrlColumn: number; publishScreenshotColumn: number; creatorScreenshotColumn: number; systemColumn: number; }; function nonNegativeInteger(value: number) { return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } export function buildPartnerBatchWorkbookColumns(input: { contentFormat: "image_text" | "video"; maxSourceImages: number; maxSourceVideos: number; }): PartnerBatchWorkbookColumns { const sourceImageCount = input.contentFormat === "video" ? 0 : Math.max(1, nonNegativeInteger(input.maxSourceImages)); const sourceVideoCount = input.contentFormat === "video" ? Math.max(1, nonNegativeInteger(input.maxSourceVideos)) : 0; const sourceImageStartColumn = 3; const sourceVideoStartColumn = sourceImageStartColumn + sourceImageCount; const publishUrlColumn = sourceVideoStartColumn + sourceVideoCount; const publishScreenshotColumn = publishUrlColumn + 1; const creatorScreenshotColumn = publishScreenshotColumn + 1; const systemColumn = creatorScreenshotColumn + 1; return { headers: [ "序号(不能改)", "标题", "笔记内容(正文+话题)", ...Array.from( { length: sourceImageCount }, (_, index) => `图片${index + 1}`, ), ...Array.from( { length: sourceVideoCount }, (_, index) => `视频${index + 1}`, ), "发布链接", "笔记截图", "数据分析截图(单篇笔记数据分析截图)", "_系统笔记ID", "_原笔记截图", "_原数据分析截图", ], columnWidths: [ 14, 30, 62, ...Array.from({ length: sourceImageCount }, () => 24), ...Array.from({ length: sourceVideoCount }, () => 20), 45, 28, 32, 22, 22, 22, ], sourceImageStartColumn, sourceImageCount, sourceVideoStartColumn, sourceVideoCount, publishUrlColumn, publishScreenshotColumn, creatorScreenshotColumn, systemColumn, }; } function firstForwardedValue(value: string | null) { return value?.split(",")[0]?.trim() ?? ""; } function httpOrigin(value: string) { try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:" ? url.origin : ""; } catch { return ""; } } export function resolvePartnerWorkbookOrigin( request: Request, configuredOrigin = "", ) { const requestUrl = new URL(request.url); const host = firstForwardedValue(request.headers.get("x-forwarded-host")) || firstForwardedValue(request.headers.get("host")); const forwardedProtocol = firstForwardedValue( request.headers.get("x-forwarded-proto"), ).toLowerCase(); const protocol = ["http", "https"].includes(forwardedProtocol) ? forwardedProtocol : requestUrl.protocol.replace(":", ""); const proxyOrigin = host ? httpOrigin(`${protocol}://${host}`) : ""; return ( httpOrigin(configuredOrigin) || proxyOrigin || httpOrigin(requestUrl.origin) || requestUrl.origin ); } export type PartnerBatchImage = { bytes: Uint8Array; contentType: string; fileName: string; }; export type PartnerBatchImportRow = { spreadsheetRow: number; sequence: string; title: string; publishUrl: string; distributionId: string; originalPublishScreenshotKey: string; originalCreatorScreenshotKey: string; publishScreenshot: PartnerBatchImage | null; creatorScreenshot: PartnerBatchImage | null; }; function decodeXml(value: string) { return value .replace(/<[^>]+>/g, "") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, "&") .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16)), ); } function xmlAttribute(value: string) { return decodeXml(value); } function textNodes(xml: string) { return [...xml.matchAll(/]*>([\s\S]*?)<\/t>/g)] .map((match) => decodeXml(match[1])) .join(""); } function columnIndex(reference: string) { const letters = reference.match(/^[A-Z]+/i)?.[0]?.toUpperCase() ?? ""; let result = 0; for (const letter of letters) result = result * 26 + letter.charCodeAt(0) - 64; return Math.max(0, result - 1); } function parseWorksheet(xml: string, sharedStrings: string[]) { const rows: string[][] = []; for (const rowMatch of xml.matchAll(/]*)>([\s\S]*?)<\/row>/g)) { const rowNumber = Number( rowMatch[1].match(/\br="(\d+)"/)?.[1] ?? rows.length + 1, ); const values: string[] = []; for (const cellMatch of rowMatch[2].matchAll( /]*)>([\s\S]*?)<\/c>/g, )) { const attributes = cellMatch[1]; const body = cellMatch[2]; const reference = attributes.match(/\br="([A-Z]+\d+)"/i)?.[1] ?? "A1"; const type = attributes.match(/\bt="([^"]+)"/)?.[1] ?? ""; const rawValue = body.match(/([\s\S]*?)<\/v>/)?.[1] ?? ""; let value = ""; if (type === "s") value = sharedStrings[Number(rawValue)] ?? ""; else if (type === "inlineStr") value = textNodes(body); else if (type === "b") value = rawValue === "1" ? "TRUE" : "FALSE"; else value = decodeXml(rawValue); values[columnIndex(reference)] = value.trim(); } while (rows.length < rowNumber - 1) rows.push([]); rows[rowNumber - 1] = values; } return rows; } function normalizeHeader(value: string) { return value.replace(/[\s_\-()()]/g, "").toLocaleLowerCase("zh-CN"); } function isSourceImageHeader(value: string) { return /^(?:图片|发布配图)\d*$/.test(normalizeHeader(value)); } function headerAliases(header: (typeof PARTNER_BATCH_HEADERS)[number]) { const aliases: Record = { "序号(不能改)": ["序号(不能改)", "序号"], 标题: ["标题"], "笔记内容(正文+话题)": ["笔记内容(正文+话题)", "笔记内容"], 图片: ["图片", "发布配图"], 发布链接: ["发布链接"], 笔记截图: ["笔记截图", "发布截图"], "数据分析截图(单篇笔记数据分析截图)": [ "数据分析截图(单篇笔记数据分析截图)", "数据分析截图", "创作者中心截图", ], _系统笔记ID: ["_系统笔记ID", "系统笔记ID"], _原笔记截图: ["_原笔记截图"], _原数据分析截图: ["_原数据分析截图"], }; return aliases[header] ?? [header]; } function findHeader(rows: string[][]) { for (let rowIndex = 0; rowIndex < Math.min(rows.length, 8); rowIndex += 1) { const mapping = new Map<(typeof PARTNER_BATCH_HEADERS)[number], number>(); rows[rowIndex].forEach((value, column) => { for (const header of PARTNER_BATCH_HEADERS) { if (header === "图片" && isSourceImageHeader(value)) { if (!mapping.has(header)) mapping.set(header, column); break; } if ( headerAliases(header).some( (alias) => normalizeHeader(alias) === normalizeHeader(value), ) ) { mapping.set(header, column); break; } } }); if ( mapping.has("序号(不能改)") && mapping.has("标题") && mapping.has("发布链接") && mapping.has("笔记截图") && mapping.has("数据分析截图(单篇笔记数据分析截图)") && mapping.has("_系统笔记ID") ) { return { rowIndex, mapping }; } } return null; } function relationshipMap(xml: string) { const relationships = new Map(); for (const match of xml.matchAll(/]*)\/?\s*>/g)) { const attributes = match[1]; const id = attributes.match(/\bId="([^"]+)"/)?.[1] ?? ""; const target = attributes.match(/\bTarget="([^"]+)"/)?.[1] ?? ""; if (id && target) relationships.set(id, xmlAttribute(target)); } return relationships; } function contentType(bytes: Uint8Array, fileName: string) { if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png"; if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg"; if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) { return "image/gif"; } if (String.fromCharCode(...bytes.slice(8, 12)) === "WEBP") return "image/webp"; const extension = path.extname(fileName).toLowerCase(); return extension === ".png" ? "image/png" : extension === ".gif" ? "image/gif" : extension === ".webp" ? "image/webp" : "image/jpeg"; } function resolveZipPath(base: string, target: string) { return path.posix.normalize(path.posix.join(path.posix.dirname(base), target)); } function parseDrawingImages(entries: Record) { const images = new Map(); const sheetRelationshipsXml = entries["xl/worksheets/_rels/sheet1.xml.rels"] ? strFromU8(entries["xl/worksheets/_rels/sheet1.xml.rels"]) : ""; const sheetRelationships = relationshipMap(sheetRelationshipsXml); const sheetXml = entries["xl/worksheets/sheet1.xml"] ? strFromU8(entries["xl/worksheets/sheet1.xml"]) : ""; const drawingId = sheetXml.match(/]*r:id="([^"]+)"/)?.[1] ?? ""; const drawingTarget = sheetRelationships.get(drawingId); if (!drawingTarget) return images; const drawingPath = resolveZipPath("xl/worksheets/sheet1.xml", drawingTarget); const drawingXml = entries[drawingPath] ? strFromU8(entries[drawingPath]) : ""; const drawingRelationshipsPath = path.posix.join( path.posix.dirname(drawingPath), "_rels", `${path.posix.basename(drawingPath)}.rels`, ); const drawingRelationships = relationshipMap( entries[drawingRelationshipsPath] ? strFromU8(entries[drawingRelationshipsPath]) : "", ); for (const anchor of drawingXml.matchAll( /]*>[\s\S]*?([\s\S]*?)<\/xdr:from>[\s\S]*?]*r:embed="([^"]+)"[\s\S]*?<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/g, )) { const column = Number(anchor[1].match(/(\d+)<\/xdr:col>/)?.[1]); const row = Number(anchor[1].match(/(\d+)<\/xdr:row>/)?.[1]); const mediaTarget = drawingRelationships.get(anchor[2]); if (!Number.isInteger(column) || !Number.isInteger(row) || !mediaTarget) continue; const mediaPath = resolveZipPath(drawingPath, mediaTarget); const bytes = entries[mediaPath]; if (!bytes) continue; images.set(`${row + 1}:${column}`, { bytes, contentType: contentType(bytes, mediaPath), fileName: path.posix.basename(mediaPath), }); } return images; } function parseRichValueImages(entries: Record) { const images = new Map(); const worksheetXml = entries["xl/worksheets/sheet1.xml"] ? strFromU8(entries["xl/worksheets/sheet1.xml"]) : ""; const metadataXml = entries["xl/metadata.xml"] ? strFromU8(entries["xl/metadata.xml"]) : ""; const richValueXml = entries["xl/richData/rdrichvalue.xml"] ? strFromU8(entries["xl/richData/rdrichvalue.xml"]) : ""; const richValueRelXml = entries["xl/richData/richValueRel.xml"] ? strFromU8(entries["xl/richData/richValueRel.xml"]) : ""; const richValueRelRelationships = relationshipMap( entries["xl/richData/_rels/richValueRel.xml.rels"] ? strFromU8(entries["xl/richData/_rels/richValueRel.xml.rels"]) : "", ); if ( !worksheetXml || !metadataXml || !richValueXml || !richValueRelXml || !richValueRelRelationships.size ) { return images; } const valueMetadataXml = metadataXml.match(/]*>([\s\S]*?)<\/valueMetadata>/)?.[1] ?? ""; const metadataToRichValue = [ ...valueMetadataXml.matchAll(/]*>[\s\S]*?]*\bv="(\d+)"[^>]*\/>[\s\S]*?<\/bk>/g), ].map((match) => Number(match[1])); const richValueToRelationship = [ ...richValueXml.matchAll(/]*>([\s\S]*?)<\/rv>/g), ].map((match) => Number(match[1].match(/(\d+)<\/v>/)?.[1] ?? -1)); const relationshipIds = [ ...richValueRelXml.matchAll(/]*\br:id="([^"]+)"[^>]*\/>/g), ].map((match) => match[1]); for (const cell of worksheetXml.matchAll( /]*)>[\s\S]*?<\/c>/g, )) { const reference = cell[1].match(/\br="([A-Z]+\d+)"/i)?.[1] ?? ""; const metadataIndex = Number(cell[1].match(/\bvm="(\d+)"/)?.[1] ?? 0); const row = Number(reference.match(/\d+$/)?.[0] ?? 0); const column = columnIndex(reference); if (!reference || !metadataIndex || !row) continue; const richValueIndex = metadataToRichValue[metadataIndex - 1]; const relationshipIndex = richValueToRelationship[richValueIndex]; const relationshipId = relationshipIds[relationshipIndex]; const mediaTarget = richValueRelRelationships.get(relationshipId); if (!mediaTarget) continue; const mediaPath = resolveZipPath( "xl/richData/richValueRel.xml", mediaTarget, ); const bytes = entries[mediaPath]; if (!bytes) continue; images.set(`${row}:${column}`, { bytes, contentType: contentType(bytes, mediaPath), fileName: path.posix.basename(mediaPath), }); } return images; } function parseWpsCellImages(entries: Record) { const images = new Map(); const worksheetXml = entries["xl/worksheets/sheet1.xml"] ? strFromU8(entries["xl/worksheets/sheet1.xml"]) : ""; const cellImagesXml = entries["xl/cellimages.xml"] ? strFromU8(entries["xl/cellimages.xml"]) : ""; const relationships = relationshipMap( entries["xl/_rels/cellimages.xml.rels"] ? strFromU8(entries["xl/_rels/cellimages.xml.rels"]) : "", ); if (!worksheetXml || !cellImagesXml || !relationships.size) return images; const imageIdToRelationship = new Map(); for (const match of cellImagesXml.matchAll( /<(?:etc:)?cellImage\b[^>]*>([\s\S]*?)<\/(?:etc:)?cellImage>/g, )) { const imageId = match[1].match( /<(?:xdr:)?cNvPr\b[^>]*\bname="([^"]+)"/, )?.[1]; const relationshipId = match[1].match( /<(?:a:)?blip\b[^>]*\br:embed="([^"]+)"/, )?.[1]; if (imageId && relationshipId) { imageIdToRelationship.set(imageId, relationshipId); } } for (const cell of worksheetXml.matchAll(/]*)>([\s\S]*?)<\/c>/g)) { const reference = cell[1].match(/\br="([A-Z]+\d+)"/i)?.[1] ?? ""; const imageId = decodeXml(cell[2]).match(/DISPIMG\("([^"]+)"/i)?.[1]; if (!reference || !imageId) continue; const relationshipId = imageIdToRelationship.get(imageId); const mediaTarget = relationshipId ? relationships.get(relationshipId) : undefined; if (!mediaTarget) continue; const mediaPath = resolveZipPath("xl/cellimages.xml", mediaTarget); const bytes = entries[mediaPath]; if (!bytes) continue; const row = Number(reference.match(/\d+$/)?.[0] ?? 0); if (!row) continue; images.set(`${row}:${columnIndex(reference)}`, { bytes, contentType: contentType(bytes, mediaPath), fileName: path.posix.basename(mediaPath), }); } return images; } function parseImages(entries: Record) { const images = parseDrawingImages(entries); for (const [cell, image] of parseRichValueImages(entries)) { images.set(cell, image); } for (const [cell, image] of parseWpsCellImages(entries)) { images.set(cell, image); } return images; } function valueAt( row: string[], mapping: Map<(typeof PARTNER_BATCH_HEADERS)[number], number>, header: (typeof PARTNER_BATCH_HEADERS)[number], ) { const column = mapping.get(header); return column === undefined ? "" : String(row[column] ?? "").trim(); } export function parsePartnerBatchWorkbook(input: ArrayBuffer | Uint8Array) { const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); if (bytes.byteLength > PARTNER_BATCH_MAX_BYTES) { throw new Error("批量回填表不能超过80MB"); } const entries = unzipSync(bytes); const worksheetBytes = entries["xl/worksheets/sheet1.xml"]; if (!worksheetBytes) throw new Error("Excel 中没有可读取的批量回填工作表"); const sharedXml = entries["xl/sharedStrings.xml"] ? strFromU8(entries["xl/sharedStrings.xml"]) : ""; const sharedStrings = [ ...sharedXml.matchAll(/]*>([\s\S]*?)<\/si>/g), ].map((match) => textNodes(match[1])); const rows = parseWorksheet(strFromU8(worksheetBytes), sharedStrings); const header = findHeader(rows); if (!header) { throw new Error("表格结构不正确,请使用本领取页面导出的批量回填表"); } const images = parseImages(entries); const publishScreenshotColumn = header.mapping.get("笔记截图")!; const creatorScreenshotColumn = header.mapping.get( "数据分析截图(单篇笔记数据分析截图)", )!; const result: PartnerBatchImportRow[] = []; for (let index = header.rowIndex + 1; index < rows.length; index += 1) { const row = rows[index]; const distributionId = valueAt(row, header.mapping, "_系统笔记ID"); if (!distributionId && !row.some((value) => String(value ?? "").trim())) continue; result.push({ spreadsheetRow: index + 1, sequence: valueAt(row, header.mapping, "序号(不能改)"), title: valueAt(row, header.mapping, "标题"), publishUrl: valueAt(row, header.mapping, "发布链接"), distributionId, originalPublishScreenshotKey: valueAt( row, header.mapping, "_原笔记截图", ), originalCreatorScreenshotKey: valueAt( row, header.mapping, "_原数据分析截图", ), publishScreenshot: images.get(`${index + 1}:${publishScreenshotColumn}`) ?? null, creatorScreenshot: images.get(`${index + 1}:${creatorScreenshotColumn}`) ?? null, }); } if (result.length === 0) throw new Error("表格中没有可回填的笔记"); return result; }