import { strFromU8, unzipSync, zipSync } from "fflate"; export const PARTNER_BATCH_UPLOAD_MAX_BYTES = 80_000_000; type WorkbookCell = { reference: string; row: number; column: number; attributes: string; body: string; value: string; }; type ScreenshotColumns = { headerRow: number; columns: Set; }; 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 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 normalizeHeader(value: string) { return value.replace(/[\s_\-()()]/g, "").toLocaleLowerCase("zh-CN"); } function parseCells(worksheetXml: string, sharedStrings: string[]) { const cells: WorkbookCell[] = []; for (const match of worksheetXml.matchAll(/]*)>([\s\S]*?)<\/c>/g)) { const attributes = match[1]; const body = match[2]; const reference = attributes.match(/\br="([A-Z]+\d+)"/i)?.[1] ?? ""; if (!reference) continue; const type = attributes.match(/\bt="([^"]+)"/)?.[1] ?? ""; const rawValue = body.match(/([\s\S]*?)<\/v>/)?.[1] ?? ""; const value = type === "s" ? sharedStrings[Number(rawValue)] ?? "" : type === "inlineStr" ? textNodes(body) : decodeXml(rawValue); cells.push({ reference, row: Number(reference.match(/\d+$/)?.[0] ?? 0), column: columnIndex(reference), attributes, body, value: value.trim(), }); } return cells; } function findScreenshotColumns(cells: WorkbookCell[]): ScreenshotColumns { for (let row = 1; row <= 8; row += 1) { const columns = new Set(); for (const cell of cells) { if (cell.row !== row) continue; const header = normalizeHeader(cell.value); if ( header === normalizeHeader("笔记截图") || header === normalizeHeader("发布截图") || header === normalizeHeader("数据分析截图") || header === normalizeHeader("数据分析截图(单篇笔记数据分析截图)") || header === normalizeHeader("创作者中心截图") ) { columns.add(cell.column); } } if (columns.size >= 2) return { headerRow: row, columns }; } throw new Error("表格结构不正确,请使用本领取页面导出的批量回填表"); } function relationshipMap(xml: string) { const relationships = new Map(); for (const match of xml.matchAll(/]*)\/?\s*>/g)) { const id = match[1].match(/\bId="([^"]+)"/)?.[1] ?? ""; const target = match[1].match(/\bTarget="([^"]+)"/)?.[1] ?? ""; if (id && target) relationships.set(id, decodeXml(target)); } return relationships; } function normalizeZipPath(value: string) { const result: string[] = []; for (const part of value.split("/")) { if (!part || part === ".") continue; if (part === "..") result.pop(); else result.push(part); } return result.join("/"); } function resolveZipPath(base: string, target: string) { const slash = base.lastIndexOf("/"); const directory = slash >= 0 ? base.slice(0, slash + 1) : ""; return normalizeZipPath(`${directory}${target}`); } function wpsScreenshotMedia( entries: Record, cells: WorkbookCell[], screenshotColumns: ScreenshotColumns, ) { const result = new Set(); let expected = 0; const screenshotIds = new Set(); for (const cell of cells) { if ( cell.row <= screenshotColumns.headerRow || !screenshotColumns.columns.has(cell.column) ) { continue; } const id = decodeXml(cell.body).match(/DISPIMG\("([^"]+)"/i)?.[1]; if (id) { expected += 1; screenshotIds.add(id); } } if (screenshotIds.size === 0) return { result, expected, resolved: 0 }; 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"]) : "", ); let resolved = 0; for (const match of cellImagesXml.matchAll( /<(?:etc:)?cellImage\b[^>]*>([\s\S]*?)<\/(?:etc:)?cellImage>/g, )) { const id = match[1].match(/<(?:xdr:)?cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]; const relationshipId = match[1].match( /<(?:a:)?blip\b[^>]*\br:embed="([^"]+)"/, )?.[1]; if (!id || !relationshipId || !screenshotIds.has(id)) continue; const target = relationships.get(relationshipId); if (!target) continue; result.add(resolveZipPath("xl/cellimages.xml", target)); resolved += 1; } return { result, expected, resolved }; } function drawingScreenshotMedia( entries: Record, screenshotColumns: ScreenshotColumns, ) { const result = new Set(); let expected = 0; let resolved = 0; const worksheetXml = entries["xl/worksheets/sheet1.xml"] ? strFromU8(entries["xl/worksheets/sheet1.xml"]) : ""; const sheetRelationships = relationshipMap( entries["xl/worksheets/_rels/sheet1.xml.rels"] ? strFromU8(entries["xl/worksheets/_rels/sheet1.xml.rels"]) : "", ); const drawingId = worksheetXml.match(/]*r:id="([^"]+)"/)?.[1]; const drawingTarget = drawingId ? sheetRelationships.get(drawingId) : undefined; if (!drawingTarget) return { result, expected, resolved }; const drawingPath = resolveZipPath("xl/worksheets/sheet1.xml", drawingTarget); const drawingXml = entries[drawingPath] ? strFromU8(entries[drawingPath]) : ""; const relationshipPath = `${drawingPath.slice(0, drawingPath.lastIndexOf("/") + 1)}_rels/${drawingPath.slice(drawingPath.lastIndexOf("/") + 1)}.rels`; const drawingRelationships = relationshipMap( entries[relationshipPath] ? strFromU8(entries[relationshipPath]) : "", ); 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 zeroBasedRow = Number(anchor[1].match(/(\d+)<\/xdr:row>/)?.[1]); if ( !Number.isInteger(column) || !Number.isInteger(zeroBasedRow) || zeroBasedRow + 1 <= screenshotColumns.headerRow || !screenshotColumns.columns.has(column) ) { continue; } expected += 1; const target = drawingRelationships.get(anchor[2]); if (!target) continue; result.add(resolveZipPath(drawingPath, target)); resolved += 1; } return { result, expected, resolved }; } function richValueScreenshotMedia( entries: Record, cells: WorkbookCell[], screenshotColumns: ScreenshotColumns, ) { const result = new Set(); let expected = 0; let resolved = 0; 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 relationships = relationshipMap( entries["xl/richData/_rels/richValueRel.xml.rels"] ? strFromU8(entries["xl/richData/_rels/richValueRel.xml.rels"]) : "", ); if (!metadataXml || !richValueXml || !richValueRelXml || !relationships.size) { return { result, expected, resolved }; } 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 cells) { if ( cell.row <= screenshotColumns.headerRow || !screenshotColumns.columns.has(cell.column) ) { continue; } const metadataIndex = Number(cell.attributes.match(/\bvm="(\d+)"/)?.[1] ?? 0); if (!metadataIndex) continue; expected += 1; const richValueIndex = metadataToRichValue[metadataIndex - 1]; const relationshipIndex = richValueToRelationship[richValueIndex]; const relationshipId = relationshipIds[relationshipIndex]; const target = relationships.get(relationshipId); if (!target) continue; result.add(resolveZipPath("xl/richData/richValueRel.xml", target)); resolved += 1; } return { result, expected, resolved }; } export type CompactedPartnerBatchWorkbook = { bytes: Uint8Array; removedMediaCount: number; preservedScreenshotCount: number; }; /** * Oversized exports are usually caused by full-resolution source images. The * upload only needs the two screenshot columns, so retain those image entries * and omit source media from the temporary upload copy. */ export function compactPartnerBatchWorkbookForUpload( input: Uint8Array, ): CompactedPartnerBatchWorkbook { const isMediaFile = (name: string) => name.startsWith("xl/media/") && !name.endsWith("/"); const structure = unzipSync(input, { filter: (file) => !isMediaFile(file.name), }); const worksheetXml = structure["xl/worksheets/sheet1.xml"] ? strFromU8(structure["xl/worksheets/sheet1.xml"]) : ""; if (!worksheetXml) throw new Error("Excel 中没有可读取的批量回填工作表"); const sharedXml = structure["xl/sharedStrings.xml"] ? strFromU8(structure["xl/sharedStrings.xml"]) : ""; const sharedStrings = [ ...sharedXml.matchAll(/]*>([\s\S]*?)<\/si>/g), ].map((match) => textNodes(match[1])); const cells = parseCells(worksheetXml, sharedStrings); const screenshotColumns = findScreenshotColumns(cells); const formats = [ wpsScreenshotMedia(structure, cells, screenshotColumns), drawingScreenshotMedia(structure, screenshotColumns), richValueScreenshotMedia(structure, cells, screenshotColumns), ]; const screenshotMedia = new Set(); let expectedScreenshotCount = 0; let resolvedScreenshotCount = 0; for (const format of formats) { expectedScreenshotCount += format.expected; resolvedScreenshotCount += format.resolved; for (const name of format.result) screenshotMedia.add(name); } if (resolvedScreenshotCount < expectedScreenshotCount) { throw new Error("表格中的回填截图无法完整识别,请重新导出最新版回填表"); } let mediaCount = 0; const entries = unzipSync(input, { filter: (file) => { if (!isMediaFile(file.name)) return true; mediaCount += 1; return screenshotMedia.has(file.name); }, }); const bytes = zipSync(entries, { level: 6 }); return { bytes, removedMediaCount: Math.max(0, mediaCount - screenshotMedia.size), preservedScreenshotCount: screenshotMedia.size, }; }