diff --git a/app/admin-app.tsx b/app/admin-app.tsx index b5f284a..5f63210 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -2261,9 +2261,9 @@ function ResourcesPage({
- 1

填写账号主页与合作来源

+ 1

账号链接必填,其余资料选填

- 2

系统解析公开账号资料

+ 2

系统只补全空缺的公开资料

3

确认后写入资源库

@@ -2282,7 +2282,7 @@ function ResourcesPage({ {!importPreview && (
-
还没有模板?只需填写小红书账号主页;合作来源可选填,账号名称、小红书号、IP属地和粉丝数会自动解析。
+
还没有模板?只有账号链接必填;已有昵称、账号ID、IP属地、粉丝数和合作来源可直接填写,系统只补全空缺字段。
下载模板
)} diff --git a/app/api/resources-import/route.ts b/app/api/resources-import/route.ts index 3c9fb64..86a4788 100644 --- a/app/api/resources-import/route.ts +++ b/app/api/resources-import/route.ts @@ -13,6 +13,7 @@ import { parseResourceImportFile, RESOURCE_IMPORT_MAX_BYTES, resourcePlatformUid, + resourceImportMissingFields, type ResourceImportRow, } from "../../../lib/resource-import"; @@ -86,20 +87,38 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) { return row; } const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl)); - if (existing?.nickname && existing.public_account_id) { - return { - ...row, - nickname: existing.nickname, - publicAccountId: existing.public_account_id, - ipLocation: existing.ip_location || "待识别", - followers: Number(existing.followers || 0), - }; + const existingIpLocation = + existing?.ip_location && existing.ip_location !== "待识别" + ? existing.ip_location + : ""; + const baseline: ResourceImportRow = { + ...row, + nickname: row.nickname || existing?.nickname || "", + publicAccountId: row.publicAccountId || existing?.public_account_id || "", + ipLocation: row.ipLocation || existingIpLocation, + followers: row.followersResolved + ? row.followers + : Number(existing?.followers || 0), + followersResolved: + row.followersResolved || Number(existing?.followers || 0) > 0, + }; + if (resourceImportMissingFields(baseline).length === 0) { + return baseline; } - let details = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig) - .catch(() => resolveXhsPublicAccountDetails(row.profileUrl)); - if (!details.nickname || !details.redId || details.followers === null) { - const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl); + let details = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig).catch( + () => ({ nickname: null, redId: null, followers: null, ipLocation: null }), + ); + const mcpResult = { + nickname: baseline.nickname || details.nickname?.trim() || "", + publicAccountId: baseline.publicAccountId || details.redId?.trim() || "", + ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "", + followersResolved: baseline.followersResolved || details.followers !== null, + }; + if (resourceImportMissingFields(mcpResult).length > 0) { + const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch( + () => ({ nickname: null, redId: null, followers: null, ipLocation: null }), + ); details = { nickname: details.nickname || publicDetails.nickname, redId: details.redId || publicDetails.redId, @@ -107,18 +126,16 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) { ipLocation: details.ipLocation || publicDetails.ipLocation, }; } - const errors = [...row.errors]; - const nickname = details.nickname?.trim() || existing?.nickname || ""; - const publicAccountId = details.redId?.trim() || existing?.public_account_id || ""; - if (!nickname) errors.push("无法识别账号名称,请确认主页可公开访问"); - if (!publicAccountId) errors.push("无法识别小红书号,请确认主页可公开访问"); return { - ...row, - nickname, - publicAccountId, - ipLocation: details.ipLocation?.trim() || existing?.ip_location || "待识别", - followers: details.followers ?? Number(existing?.followers || 0), - errors, + ...baseline, + nickname: baseline.nickname || details.nickname?.trim() || "待识别账号", + publicAccountId: baseline.publicAccountId || details.redId?.trim() || "", + ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "待识别", + followers: baseline.followersResolved + ? baseline.followers + : (details.followers ?? 0), + followersResolved: + baseline.followersResolved || details.followers !== null, }; }); } @@ -251,7 +268,7 @@ export async function POST(request: Request) { profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END, ip_location = CASE WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END, - followers = CASE WHEN ? > 0 OR followers = 0 THEN ? ELSE followers END, + followers = CASE WHEN ? = 1 THEN ? ELSE followers END, cooperation_source = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`, @@ -265,7 +282,7 @@ export async function POST(request: Request) { row.ipLocation, row.ipLocation, row.ipLocation, - row.followers, + row.followersResolved ? 1 : 0, row.followers, row.cooperationSource, row.accountId, diff --git a/lib/resource-import.ts b/lib/resource-import.ts index 116d6af..cb14ef7 100644 --- a/lib/resource-import.ts +++ b/lib/resource-import.ts @@ -11,12 +11,17 @@ export type ResourceImportRow = { profileUrl: string; ipLocation: string; followers: number; + followersResolved: boolean; cooperationSource: string; errors: string[]; }; const HEADER_ALIASES = { - profileUrl: ["账号主页", "账号主页链接", "主页链接", "账号链接"], + profileUrl: ["账号链接", "账号主页", "账号主页链接", "主页链接"], + nickname: ["账号昵称", "账号名称", "昵称"], + publicAccountId: ["账号ID", "账号号", "小红书号", "抖音号"], + ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"], + followers: ["粉丝数", "粉丝", "粉丝量"], cooperationSource: ["合作来源", "资源来源", "历史合作来源"], } as const; @@ -120,7 +125,10 @@ function parseCsv(text: string) { } function normalizeHeader(value: string) { - return value.replace(/[\s_\-()()]/g, "").toLocaleLowerCase("zh-CN"); + return value + .replace(/[\s_\-()()]/g, "") + .replace(/必填|选填/g, "") + .toLocaleLowerCase("zh-CN"); } function canonicalHeader(value: string): CanonicalHeader | null { @@ -186,6 +194,40 @@ function valueAt(row: string[], mapping: Map, key: Cano 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 resourceImportMissingFields( + row: Pick< + ResourceImportRow, + "nickname" | "publicAccountId" | "ipLocation" | "followersResolved" + >, +) { + 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"); + return missing; +} + function normalizeRows(rows: string[][]) { const header = findHeader(rows); if (!header) { @@ -198,18 +240,24 @@ function normalizeRows(rows: string[][]) { 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 errors: string[] = []; if (!rawProfileUrl) errors.push("账号主页不能为空"); else if (!profileUrl) errors.push("账号主页链接格式不正确"); else if (!platform) errors.push("当前自动解析仅支持小红书账号主页"); + if (!parsedFollowers.valid) { + errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+"); + } result.push({ rowNumber: index + 1, platform, - nickname: "", - publicAccountId: "", + nickname: valueAt(source, header.mapping, "nickname"), + publicAccountId: valueAt(source, header.mapping, "publicAccountId"), profileUrl, - ipLocation: "待识别", - followers: 0, + ipLocation: valueAt(source, header.mapping, "ipLocation"), + followers: parsedFollowers.value, + followersResolved: parsedFollowers.resolved, cooperationSource: valueAt(source, header.mapping, "cooperationSource"), errors, }); diff --git a/public/KOC资源导入模板.xlsx b/public/KOC资源导入模板.xlsx index 68293bd..15346f9 100644 Binary files a/public/KOC资源导入模板.xlsx and b/public/KOC资源导入模板.xlsx differ diff --git a/tests/resource-import.test.mjs b/tests/resource-import.test.mjs index 54562ca..456f2a8 100644 --- a/tests/resource-import.test.mjs +++ b/tests/resource-import.test.mjs @@ -4,7 +4,9 @@ import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts"; import { mergeCooperationSources, normalizeProfileUrl, + parseResourceFollowers, parseResourceImportFile, + resourceImportMissingFields, resourcePlatformUid, } from "../lib/resource-import.ts"; @@ -21,14 +23,50 @@ test("parses CSV resources and normalizes public profile data", () => { nickname: "", publicAccountId: "", profileUrl: "https://www.xiaohongshu.com/user/profile/abc123", - ipLocation: "待识别", + ipLocation: "", followers: 0, + followersResolved: false, cooperationSource: "林林KOC社群", errors: [], }); assert.equal(resourcePlatformUid(rows[0]), "abc123"); }); +test("uses optional account fields directly and only requires the profile URL", () => { + const csv = [ + "账号链接,账号昵称,账号ID,IP属地,粉丝数,合作来源", + "https://www.xiaohongshu.com/user/profile/abc123,番茄不炒蛋,4171542126,江西,10+,历史资源", + ].join("\n"); + const [row] = parseResourceImportFile( + "resources.csv", + new TextEncoder().encode(csv), + ); + assert.equal(row.nickname, "番茄不炒蛋"); + assert.equal(row.publicAccountId, "4171542126"); + assert.equal(row.ipLocation, "江西"); + assert.equal(row.followers, 10); + assert.equal(row.followersResolved, true); + assert.deepEqual(resourceImportMissingFields(row), []); +}); + +test("normalizes common follower formats and identifies missing enrichment fields", () => { + assert.deepEqual(parseResourceFollowers("1.3万"), { + value: 13_000, + resolved: true, + valid: true, + }); + assert.deepEqual(parseResourceFollowers("10+"), { + value: 10, + resolved: true, + valid: true, + }); + assert.deepEqual(parseResourceFollowers(""), { + value: 0, + resolved: false, + valid: true, + }); +}); + test("parses the first matching worksheet from an XLSX workbook", () => { const workbook = buildRecoveryWorkbook({ sheetName: "KOC资源导入", @@ -45,6 +83,7 @@ test("parses the first matching worksheet from an XLSX workbook", () => { assert.equal(rows[0].platform, "小红书"); assert.equal(rows[0].cooperationSource, "存量资源包"); assert.equal(rows[0].errors.length, 0); + assert.equal(rows[0].followersResolved, false); }); test("reports invalid required fields without hiding valid rows", () => { @@ -54,6 +93,18 @@ test("reports invalid required fields without hiding valid rows", () => { assert.match(rows[1].errors.join(";"), /仅支持小红书账号主页/); }); +test("rejects invalid optional follower values without requiring other optional fields", () => { + const csv = [ + "账号链接,粉丝数", + "https://www.xiaohongshu.com/user/profile/abc123,很多", + ].join("\n"); + const [row] = parseResourceImportFile( + "resources.csv", + new TextEncoder().encode(csv), + ); + assert.match(row.errors.join(";"), /粉丝数格式不正确/); +}); + test("normalizes profile URLs and merges cooperation sources", () => { assert.equal( normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"),