feat: 完善视频任务与 KOC 资源库
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { strFromU8, unzipSync } from "fflate";
|
||||
|
||||
export const RESOURCE_IMPORT_MAX_ROWS = 100;
|
||||
export const RESOURCE_IMPORT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
export const RESOURCE_IMPORT_MAX_ROWS = 10_000;
|
||||
export const RESOURCE_IMPORT_MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export type ResourceImportRow = {
|
||||
rowNumber: number;
|
||||
@@ -12,6 +12,9 @@ export type ResourceImportRow = {
|
||||
ipLocation: string;
|
||||
followers: number;
|
||||
followersResolved: boolean;
|
||||
gender: "" | "男" | "女";
|
||||
bio: string;
|
||||
tags: string[];
|
||||
cooperationSource: string;
|
||||
errors: string[];
|
||||
};
|
||||
@@ -22,6 +25,9 @@ const HEADER_ALIASES = {
|
||||
publicAccountId: ["账号ID", "账号号", "小红书号", "抖音号"],
|
||||
ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"],
|
||||
followers: ["粉丝数", "粉丝", "粉丝量"],
|
||||
gender: ["性别"],
|
||||
bio: ["简介", "账号简介", "个人简介"],
|
||||
tags: ["标签", "账号标签"],
|
||||
cooperationSource: ["合作来源", "资源来源", "历史合作来源"],
|
||||
} as const;
|
||||
|
||||
@@ -60,9 +66,11 @@ function parseWorksheet(xml: string, sharedStrings: string[]) {
|
||||
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)) {
|
||||
for (const cellMatch of rowMatch[2].matchAll(
|
||||
/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g,
|
||||
)) {
|
||||
const attributes = cellMatch[1];
|
||||
const body = cellMatch[2];
|
||||
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] ?? "";
|
||||
@@ -183,6 +191,15 @@ export function platformFromProfileUrl(profileUrl: string) {
|
||||
) {
|
||||
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.
|
||||
}
|
||||
@@ -212,10 +229,42 @@ export function parseResourceFollowers(value: string) {
|
||||
};
|
||||
}
|
||||
|
||||
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"
|
||||
| "nickname"
|
||||
| "publicAccountId"
|
||||
| "ipLocation"
|
||||
| "followersResolved"
|
||||
| "gender"
|
||||
| "bio"
|
||||
| "tags"
|
||||
>,
|
||||
) {
|
||||
const missing: string[] = [];
|
||||
@@ -225,6 +274,8 @@ export function resourceImportMissingFields(
|
||||
missing.push("ipLocation");
|
||||
}
|
||||
if (!row.followersResolved) missing.push("followers");
|
||||
if (!row.gender) missing.push("gender");
|
||||
if (!row.bio.trim()) missing.push("bio");
|
||||
return missing;
|
||||
}
|
||||
|
||||
@@ -242,22 +293,39 @@ function normalizeRows(rows: string[][]) {
|
||||
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("当前自动解析仅支持小红书账号主页");
|
||||
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: valueAt(source, header.mapping, "ipLocation"),
|
||||
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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user