Files
koc-loop/koc-portal/app/batch-workbook-upload.ts
2026-08-15 03:53:09 +08:00

342 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number>;
};
function decodeXml(value: string) {
return value
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/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(/<t\b[^>]*>([\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(/<c\b([^>]*)>([\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(/<v>([\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<number>();
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<string, string>();
for (const match of xml.matchAll(/<Relationship\b([^>]*)\/?\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<string, Uint8Array>,
cells: WorkbookCell[],
screenshotColumns: ScreenshotColumns,
) {
const result = new Set<string>();
let expected = 0;
const screenshotIds = new Set<string>();
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<string, Uint8Array>,
screenshotColumns: ScreenshotColumns,
) {
const result = new Set<string>();
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(/<drawing\b[^>]*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(
/<xdr:(?:oneCellAnchor|twoCellAnchor)\b[^>]*>[\s\S]*?<xdr:from>([\s\S]*?)<\/xdr:from>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"[\s\S]*?<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/g,
)) {
const column = Number(anchor[1].match(/<xdr:col>(\d+)<\/xdr:col>/)?.[1]);
const zeroBasedRow = Number(anchor[1].match(/<xdr:row>(\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<string, Uint8Array>,
cells: WorkbookCell[],
screenshotColumns: ScreenshotColumns,
) {
const result = new Set<string>();
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(/<valueMetadata\b[^>]*>([\s\S]*?)<\/valueMetadata>/)?.[1] ??
"";
const metadataToRichValue = [
...valueMetadataXml.matchAll(
/<bk\b[^>]*>[\s\S]*?<rc\b[^>]*\bv="(\d+)"[^>]*\/>[\s\S]*?<\/bk>/g,
),
].map((match) => Number(match[1]));
const richValueToRelationship = [
...richValueXml.matchAll(/<rv\b[^>]*>([\s\S]*?)<\/rv>/g),
].map((match) => Number(match[1].match(/<v>(\d+)<\/v>/)?.[1] ?? -1));
const relationshipIds = [
...richValueRelXml.matchAll(/<rel\b[^>]*\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(/<si\b[^>]*>([\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<string>();
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,
};
}