2026-08-11 23:07:26 +08:00
|
|
|
|
import { strFromU8, unzipSync } from "fflate";
|
|
|
|
|
|
|
2026-08-15 03:53:09 +08:00
|
|
|
|
export const RESOURCE_IMPORT_MAX_ROWS = 10_000;
|
|
|
|
|
|
export const RESOURCE_IMPORT_MAX_BYTES = 20 * 1024 * 1024;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
|
|
|
|
|
|
export type ResourceImportRow = {
|
|
|
|
|
|
rowNumber: number;
|
|
|
|
|
|
platform: string;
|
|
|
|
|
|
nickname: string;
|
|
|
|
|
|
publicAccountId: string;
|
|
|
|
|
|
profileUrl: string;
|
|
|
|
|
|
ipLocation: string;
|
|
|
|
|
|
followers: number;
|
2026-08-12 17:51:57 +08:00
|
|
|
|
followersResolved: boolean;
|
2026-08-15 03:53:09 +08:00
|
|
|
|
gender: "" | "男" | "女";
|
|
|
|
|
|
bio: string;
|
|
|
|
|
|
tags: string[];
|
2026-08-11 23:07:26 +08:00
|
|
|
|
cooperationSource: string;
|
|
|
|
|
|
errors: string[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const HEADER_ALIASES = {
|
2026-08-12 17:51:57 +08:00
|
|
|
|
profileUrl: ["账号链接", "账号主页", "账号主页链接", "主页链接"],
|
|
|
|
|
|
nickname: ["账号昵称", "账号名称", "昵称"],
|
|
|
|
|
|
publicAccountId: ["账号ID", "账号号", "小红书号", "抖音号"],
|
|
|
|
|
|
ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"],
|
|
|
|
|
|
followers: ["粉丝数", "粉丝", "粉丝量"],
|
2026-08-15 03:53:09 +08:00
|
|
|
|
gender: ["性别"],
|
|
|
|
|
|
bio: ["简介", "账号简介", "个人简介"],
|
|
|
|
|
|
tags: ["标签", "账号标签"],
|
2026-08-11 23:07:26 +08:00
|
|
|
|
cooperationSource: ["合作来源", "资源来源", "历史合作来源"],
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
|
|
type CanonicalHeader = keyof typeof HEADER_ALIASES;
|
|
|
|
|
|
|
|
|
|
|
|
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 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[] = [];
|
2026-08-15 03:53:09 +08:00
|
|
|
|
for (const cellMatch of rowMatch[2].matchAll(
|
|
|
|
|
|
/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g,
|
|
|
|
|
|
)) {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const attributes = cellMatch[1];
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const body = cellMatch[2] ?? "";
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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) {
|
2026-08-12 17:51:57 +08:00
|
|
|
|
return value
|
|
|
|
|
|
.replace(/[\s_\-()()]/g, "")
|
|
|
|
|
|
.replace(/必填|选填/g, "")
|
|
|
|
|
|
.toLocaleLowerCase("zh-CN");
|
2026-08-11 23:07:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 "小红书";
|
|
|
|
|
|
}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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 "抖音";
|
|
|
|
|
|
}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
} 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 17:51:57 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 03:53:09 +08:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 17:51:57 +08:00
|
|
|
|
export function resourceImportMissingFields(
|
|
|
|
|
|
row: Pick<
|
|
|
|
|
|
ResourceImportRow,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
| "nickname"
|
|
|
|
|
|
| "publicAccountId"
|
|
|
|
|
|
| "ipLocation"
|
|
|
|
|
|
| "followersResolved"
|
|
|
|
|
|
| "gender"
|
|
|
|
|
|
| "bio"
|
|
|
|
|
|
| "tags"
|
2026-08-12 17:51:57 +08:00
|
|
|
|
>,
|
|
|
|
|
|
) {
|
|
|
|
|
|
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");
|
2026-08-15 03:53:09 +08:00
|
|
|
|
if (!row.gender) missing.push("gender");
|
|
|
|
|
|
if (!row.bio.trim()) missing.push("bio");
|
2026-08-12 17:51:57 +08:00
|
|
|
|
return missing;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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);
|
2026-08-12 17:51:57 +08:00
|
|
|
|
const rawFollowers = valueAt(source, header.mapping, "followers");
|
|
|
|
|
|
const parsedFollowers = parseResourceFollowers(rawFollowers);
|
2026-08-15 03:53:09 +08:00
|
|
|
|
const parsedGender = normalizeResourceGender(
|
|
|
|
|
|
valueAt(source, header.mapping, "gender"),
|
|
|
|
|
|
);
|
|
|
|
|
|
const tags = normalizeResourceTags(valueAt(source, header.mapping, "tags"));
|
|
|
|
|
|
const ipLocation = valueAt(source, header.mapping, "ipLocation");
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
|
if (!rawProfileUrl) errors.push("账号主页不能为空");
|
|
|
|
|
|
else if (!profileUrl) errors.push("账号主页链接格式不正确");
|
2026-08-15 03:53:09 +08:00
|
|
|
|
else if (!platform) errors.push("当前自动解析仅支持小红书或抖音账号主页");
|
2026-08-12 17:51:57 +08:00
|
|
|
|
if (!parsedFollowers.valid) {
|
|
|
|
|
|
errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+");
|
|
|
|
|
|
}
|
2026-08-15 03:53:09 +08:00
|
|
|
|
if (!parsedGender.valid) {
|
|
|
|
|
|
errors.push("性别格式不正确,请填写男、女或留空");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (tags.length > 5) {
|
|
|
|
|
|
errors.push("单个账号最多填写 5 个标签,请用逗号分隔");
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/^\d+$/.test(ipLocation)) {
|
|
|
|
|
|
errors.push("IP属地格式不正确,请填写省份、地区或国家名称");
|
|
|
|
|
|
}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
result.push({
|
|
|
|
|
|
rowNumber: index + 1,
|
|
|
|
|
|
platform,
|
2026-08-12 17:51:57 +08:00
|
|
|
|
nickname: valueAt(source, header.mapping, "nickname"),
|
|
|
|
|
|
publicAccountId: valueAt(source, header.mapping, "publicAccountId"),
|
2026-08-11 23:07:26 +08:00
|
|
|
|
profileUrl,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
ipLocation,
|
2026-08-12 17:51:57 +08:00
|
|
|
|
followers: parsedFollowers.value,
|
|
|
|
|
|
followersResolved: parsedFollowers.resolved,
|
2026-08-15 03:53:09 +08:00
|
|
|
|
gender: parsedGender.value,
|
|
|
|
|
|
bio: valueAt(source, header.mapping, "bio"),
|
|
|
|
|
|
tags: tags.slice(0, 5),
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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("、");
|
|
|
|
|
|
}
|