342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
|
|
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(/</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(/<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,
|
|||
|
|
};
|
|||
|
|
}
|