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"),