Files
koc-loop/lib/resource-import.ts
2026-08-15 03:53:09 +08:00

391 lines
13 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 } from "fflate";
export const RESOURCE_IMPORT_MAX_ROWS = 10_000;
export const RESOURCE_IMPORT_MAX_BYTES = 20 * 1024 * 1024;
export type ResourceImportRow = {
rowNumber: number;
platform: string;
nickname: string;
publicAccountId: string;
profileUrl: string;
ipLocation: string;
followers: number;
followersResolved: boolean;
gender: "" | "男" | "女";
bio: string;
tags: string[];
cooperationSource: string;
errors: string[];
};
const HEADER_ALIASES = {
profileUrl: ["账号链接", "账号主页", "账号主页链接", "主页链接"],
nickname: ["账号昵称", "账号名称", "昵称"],
publicAccountId: ["账号ID", "账号号", "小红书号", "抖音号"],
ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"],
followers: ["粉丝数", "粉丝", "粉丝量"],
gender: ["性别"],
bio: ["简介", "账号简介", "个人简介"],
tags: ["标签", "账号标签"],
cooperationSource: ["合作来源", "资源来源", "历史合作来源"],
} as const;
type CanonicalHeader = keyof typeof HEADER_ALIASES;
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 parseWorksheet(xml: string, sharedStrings: string[]) {
const rows: string[][] = [];
for (const rowMatch of xml.matchAll(/<row\b([^>]*)>([\s\S]*?)<\/row>/g)) {
const rowAttributes = rowMatch[1];
const rowNumber = Number(rowAttributes.match(/\br="(\d+)"/)?.[1] ?? rows.length + 1);
const values: string[] = [];
for (const cellMatch of rowMatch[2].matchAll(
/<c\b([^>]*?)(?:\/>|>([\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(/<v>([\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 parseXlsx(bytes: Uint8Array) {
const entries = unzipSync(bytes);
const sharedXml = entries["xl/sharedStrings.xml"]
? strFromU8(entries["xl/sharedStrings.xml"])
: "";
const sharedStrings = [...sharedXml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map(
(match) => textNodes(match[1]),
);
const sheets = Object.keys(entries)
.filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/.test(name))
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
if (sheets.length === 0) throw new Error("Excel 中没有可读取的工作表");
return sheets.map((name) => parseWorksheet(strFromU8(entries[name]), sharedStrings));
}
function parseCsv(text: string) {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (char === '"' && text[index + 1] === '"') {
cell += '"';
index += 1;
} else if (char === '"') quoted = false;
else cell += char;
} else if (char === '"') quoted = true;
else if (char === ",") {
row.push(cell.trim());
cell = "";
} else if (char === "\n" || char === "\r") {
if (char === "\r" && text[index + 1] === "\n") index += 1;
row.push(cell.trim());
if (row.some(Boolean)) rows.push(row);
row = [];
cell = "";
} else cell += char;
}
row.push(cell.trim());
if (row.some(Boolean)) rows.push(row);
return rows;
}
function normalizeHeader(value: string) {
return value
.replace(/[\s_\-()]/g, "")
.replace(/必填|选填/g, "")
.toLocaleLowerCase("zh-CN");
}
function canonicalHeader(value: string): CanonicalHeader | null {
const normalized = normalizeHeader(value);
for (const [key, aliases] of Object.entries(HEADER_ALIASES)) {
if (aliases.some((alias) => normalizeHeader(alias) === normalized)) {
return key as CanonicalHeader;
}
}
return null;
}
function findHeader(rows: string[][]) {
for (let index = 0; index < Math.min(rows.length, 12); index += 1) {
const mapping = new Map<CanonicalHeader, number>();
rows[index].forEach((cell, column) => {
const header = canonicalHeader(cell);
if (header && !mapping.has(header)) mapping.set(header, column);
});
if (mapping.has("profileUrl")) {
return { rowIndex: index, mapping };
}
}
return null;
}
export function normalizeProfileUrl(value: string) {
const extracted = value.match(/https?:\/\/[^\s,;]+/i)?.[0] ?? value.trim();
if (!extracted) return "";
try {
const url = new URL(extracted);
if (!(["http:", "https:"].includes(url.protocol))) return "";
url.protocol = "https:";
url.hostname = url.hostname.toLowerCase();
url.search = "";
url.hash = "";
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
return url.toString().replace(/\/$/, "");
} catch {
return "";
}
}
export function platformFromProfileUrl(profileUrl: string) {
if (!profileUrl) return "";
try {
const url = new URL(profileUrl);
if (
(url.hostname === "xiaohongshu.com" ||
url.hostname.endsWith(".xiaohongshu.com")) &&
/^\/user\/profile\/[^/]+/i.test(url.pathname)
) {
return "小红书";
}
if (
((url.hostname === "douyin.com" || url.hostname.endsWith(".douyin.com")) &&
/^\/user\/[^/]+/i.test(url.pathname)) ||
((url.hostname === "iesdouyin.com" ||
url.hostname.endsWith(".iesdouyin.com")) &&
/^\/share\/user\/[^/]+/i.test(url.pathname))
) {
return "抖音";
}
} catch {
// URL validation is reported by normalizeRows.
}
return "";
}
function valueAt(row: string[], mapping: Map<CanonicalHeader, number>, key: CanonicalHeader) {
const index = mapping.get(key);
return index === undefined ? "" : String(row[index] ?? "").trim();
}
export function parseResourceFollowers(value: string) {
const normalized = value.trim().replace(/[,\s]/g, "").replace(/\+$/, "");
if (!normalized) return { value: 0, resolved: false, valid: true };
const match = normalized.match(/^(\d+(?:\.\d+)?)(万|w|W|千|k|K)?$/);
if (!match) return { value: 0, resolved: false, valid: false };
const multiplier =
match[2] === "万" || match[2]?.toLowerCase() === "w"
? 10_000
: match[2] === "千" || match[2]?.toLowerCase() === "k"
? 1_000
: 1;
return {
value: Math.round(Number(match[1]) * multiplier),
resolved: true,
valid: true,
};
}
export function normalizeResourceGender(value: string) {
const normalized = value.trim().toLocaleLowerCase("zh-CN");
if (!normalized || ["未知", "未填写", "待识别", "unknown"].includes(normalized)) {
return { value: "" as const, valid: true };
}
if (["男", "男性", "male", "m"].includes(normalized)) {
return { value: "男" as const, valid: true };
}
if (["女", "女性", "female", "f"].includes(normalized)) {
return { value: "女" as const, valid: true };
}
return { value: "" as const, valid: false };
}
export function normalizeResourceTags(value: string | string[]) {
const source = Array.isArray(value) ? value.join(",") : value;
return [
...new Set(
source
.split(/[,,、;|]/)
.map((item) => item.trim().replace(/^#+/, ""))
.filter(Boolean),
),
];
}
export function resourceImportMissingFields(
row: Pick<
ResourceImportRow,
| "nickname"
| "publicAccountId"
| "ipLocation"
| "followersResolved"
| "gender"
| "bio"
| "tags"
>,
) {
const missing: string[] = [];
if (!row.nickname.trim()) missing.push("nickname");
if (!row.publicAccountId.trim()) missing.push("publicAccountId");
if (!row.ipLocation.trim() || row.ipLocation.trim() === "待识别") {
missing.push("ipLocation");
}
if (!row.followersResolved) missing.push("followers");
if (!row.gender) missing.push("gender");
if (!row.bio.trim()) missing.push("bio");
return missing;
}
function normalizeRows(rows: string[][]) {
const header = findHeader(rows);
if (!header) {
throw new Error("没有找到模板表头,请使用系统提供的导入模板");
}
const result: ResourceImportRow[] = [];
for (let index = header.rowIndex + 1; index < rows.length; index += 1) {
const source = rows[index];
if (!source.some((cell) => String(cell ?? "").trim())) continue;
const rawProfileUrl = valueAt(source, header.mapping, "profileUrl");
const profileUrl = normalizeProfileUrl(rawProfileUrl);
const platform = platformFromProfileUrl(profileUrl);
const rawFollowers = valueAt(source, header.mapping, "followers");
const parsedFollowers = parseResourceFollowers(rawFollowers);
const parsedGender = normalizeResourceGender(
valueAt(source, header.mapping, "gender"),
);
const tags = normalizeResourceTags(valueAt(source, header.mapping, "tags"));
const ipLocation = valueAt(source, header.mapping, "ipLocation");
const errors: string[] = [];
if (!rawProfileUrl) errors.push("账号主页不能为空");
else if (!profileUrl) errors.push("账号主页链接格式不正确");
else if (!platform) errors.push("当前自动解析仅支持小红书或抖音账号主页");
if (!parsedFollowers.valid) {
errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+");
}
if (!parsedGender.valid) {
errors.push("性别格式不正确,请填写男、女或留空");
}
if (tags.length > 5) {
errors.push("单个账号最多填写 5 个标签,请用逗号分隔");
}
if (/^\d+$/.test(ipLocation)) {
errors.push("IP属地格式不正确请填写省份、地区或国家名称");
}
result.push({
rowNumber: index + 1,
platform,
nickname: valueAt(source, header.mapping, "nickname"),
publicAccountId: valueAt(source, header.mapping, "publicAccountId"),
profileUrl,
ipLocation,
followers: parsedFollowers.value,
followersResolved: parsedFollowers.resolved,
gender: parsedGender.value,
bio: valueAt(source, header.mapping, "bio"),
tags: tags.slice(0, 5),
cooperationSource: valueAt(source, header.mapping, "cooperationSource"),
errors,
});
}
if (result.length === 0) throw new Error("表格中没有可导入的账号数据");
if (result.length > RESOURCE_IMPORT_MAX_ROWS) {
throw new Error(`单次最多导入 ${RESOURCE_IMPORT_MAX_ROWS} 个账号`);
}
return result;
}
export function parseResourceImportFile(fileName: string, bytes: Uint8Array) {
const extension = fileName.toLocaleLowerCase().split(".").pop();
const workbooks =
extension === "csv"
? [parseCsv(new TextDecoder("utf-8").decode(bytes).replace(/^\uFEFF/, ""))]
: extension === "xlsx"
? parseXlsx(bytes)
: null;
if (!workbooks) throw new Error("仅支持 .xlsx 或 .csv 文件");
for (const rows of workbooks) {
if (findHeader(rows)) return normalizeRows(rows);
}
throw new Error("没有找到模板表头,请使用系统提供的导入模板");
}
function shortHash(value: string) {
let hash = 2166136261;
for (const character of value) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash >>> 0).toString(36);
}
export function resourcePlatformUid(row: Pick<ResourceImportRow, "platform" | "profileUrl" | "publicAccountId">) {
if (row.profileUrl) {
try {
const url = new URL(row.profileUrl);
const candidate =
url.pathname.match(/\/user\/profile\/([^/]+)/i)?.[1] ??
url.pathname.match(/\/(?:user|profile)\/([^/]+)/i)?.[1] ??
url.pathname.split("/").filter(Boolean).at(-1);
if (candidate && candidate.length >= 3) return candidate;
} catch {
// Validation already reports malformed profile links.
}
}
const identity = row.publicAccountId || row.profileUrl;
return `manual-${shortHash(`${row.platform}:${identity}`)}`;
}
export function mergeCooperationSources(existing: string, incoming: string) {
return [
...new Set(
`${existing}${incoming}`
.split(/[、,;|]/)
.map((item) => item.trim())
.filter(Boolean),
),
].join("、");
}