Compare commits
4 Commits
7ef150e08b
...
ad3dbdcc86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3dbdcc86 | ||
|
|
ee6caaf9e5 | ||
|
|
e05041e037 | ||
|
|
51934b0638 |
@@ -2261,9 +2261,9 @@ function ResourcesPage({
|
||||
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
|
||||
</div>
|
||||
<div className="import-guide-strip">
|
||||
<span>1</span><p>填写账号主页与合作来源</p>
|
||||
<span>1</span><p>账号链接必填,其余资料选填</p>
|
||||
<i />
|
||||
<span>2</span><p>系统解析公开账号资料</p>
|
||||
<span>2</span><p>系统只补全空缺的公开资料</p>
|
||||
<i />
|
||||
<span>3</span><p>确认后写入资源库</p>
|
||||
</div>
|
||||
@@ -2282,7 +2282,7 @@ function ResourcesPage({
|
||||
</label>
|
||||
{!importPreview && (
|
||||
<div className="import-template-note">
|
||||
<div><strong>还没有模板?</strong><span>只需填写小红书账号主页;合作来源可选填,账号名称、小红书号、IP属地和粉丝数会自动解析。</span></div>
|
||||
<div><strong>还没有模板?</strong><span>只有账号链接必填;已有昵称、账号ID、IP属地、粉丝数和合作来源可直接填写,系统只补全空缺字段。</span></div>
|
||||
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
parseResourceImportFile,
|
||||
RESOURCE_IMPORT_MAX_BYTES,
|
||||
resourcePlatformUid,
|
||||
resourceImportMissingFields,
|
||||
type ResourceImportRow,
|
||||
} from "../../../lib/resource-import";
|
||||
|
||||
@@ -86,20 +87,43 @@ 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 {
|
||||
const existingIpLocation =
|
||||
existing?.ip_location && existing.ip_location !== "待识别"
|
||||
? existing.ip_location
|
||||
: "";
|
||||
const baseline: ResourceImportRow = {
|
||||
...row,
|
||||
nickname: existing.nickname,
|
||||
publicAccountId: existing.public_account_id,
|
||||
ipLocation: existing.ip_location || "待识别",
|
||||
followers: Number(existing.followers || 0),
|
||||
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: {
|
||||
nickname: string | null;
|
||||
redId: string | null;
|
||||
followers: number | null;
|
||||
ipLocation: string | null;
|
||||
} = 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 +131,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 +273,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 +287,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,
|
||||
|
||||
@@ -1050,6 +1050,11 @@ footer {
|
||||
padding: 34px 38px;
|
||||
}
|
||||
|
||||
.mobile-note-summary,
|
||||
.mobile-note-collapse-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.note-document-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -1810,6 +1815,64 @@ footer {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.note-document.mobile-collapsed {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.note-document.mobile-collapsed .note-document-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.note-document.mobile-collapsed .mobile-note-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mobile-note-summary > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mobile-note-summary span {
|
||||
color: var(--green);
|
||||
font-size: 9px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.mobile-note-summary strong {
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-note-summary small {
|
||||
color: #899590;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.mobile-note-summary button,
|
||||
.mobile-note-collapse-trigger {
|
||||
flex: 0 0 auto;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cfe1d9;
|
||||
border-radius: 9px;
|
||||
color: var(--green-deep);
|
||||
background: #f2f8f5;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobile-note-collapse-trigger {
|
||||
display: block;
|
||||
margin: 14px 0 0 auto;
|
||||
}
|
||||
|
||||
.note-document h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { zipSync } from "fflate";
|
||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
formatShanghaiDate as formatDate,
|
||||
parseStoredDate,
|
||||
@@ -96,6 +96,13 @@ function partnerApi(path: "/api/partner" | "/api/partner-upload") {
|
||||
return `${resolveAdminOrigin()}${path}`;
|
||||
}
|
||||
|
||||
function isMobilePortalViewport() {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(max-width: 620px)").matches
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(item: Assignment, taskType = "content_publish") {
|
||||
if (taskType === "screenshot_collect") {
|
||||
return item.result_submitted_at ? "已提交" : "待提交";
|
||||
@@ -280,6 +287,8 @@ export default function Home() {
|
||||
src: string;
|
||||
alt: string;
|
||||
} | null>(null);
|
||||
const [noteContentCollapsed, setNoteContentCollapsed] = useState(false);
|
||||
const submitCardRef = useRef<HTMLFormElement | null>(null);
|
||||
const publishScreenshotPreview = useFilePreview(screenshot);
|
||||
const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
|
||||
const taskResultPreviews = useMemo(
|
||||
@@ -384,6 +393,9 @@ export default function Home() {
|
||||
selected.exposure === null ? "" : String(selected.exposure),
|
||||
);
|
||||
setCreatorViews(selected.views === null ? "" : String(selected.views));
|
||||
setNoteContentCollapsed(
|
||||
Boolean(selected.publish_url) && isMobilePortalViewport(),
|
||||
);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [selected]);
|
||||
@@ -543,7 +555,9 @@ export default function Home() {
|
||||
};
|
||||
|
||||
const delegationUrl = (shareToken: string) => {
|
||||
const url = new URL(window.location.origin);
|
||||
const url = new URL(window.location.href);
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
url.searchParams.set("share", shareToken);
|
||||
return url.toString();
|
||||
};
|
||||
@@ -792,6 +806,7 @@ export default function Home() {
|
||||
const submitNote = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
const isFirstBackfill = !selected.publish_url;
|
||||
try {
|
||||
setWorking(true);
|
||||
if (screenshot) {
|
||||
@@ -816,6 +831,15 @@ export default function Home() {
|
||||
if (!response.ok) throw new Error(result.error || "回填失败");
|
||||
await loadTask(taskToken, claimToken, delegationToken);
|
||||
setScreenshot(null);
|
||||
if (isFirstBackfill && isMobilePortalViewport()) {
|
||||
setNoteContentCollapsed(true);
|
||||
window.setTimeout(() => {
|
||||
submitCardRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 120);
|
||||
}
|
||||
setToast("这篇笔记已回填,不会与其他笔记错配");
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "回填失败");
|
||||
@@ -1102,11 +1126,41 @@ export default function Home() {
|
||||
</header>
|
||||
<button className="back-link" onClick={closeNote}>← 返回我的笔记</button>
|
||||
<div className="detail-grid">
|
||||
<article className="note-document">
|
||||
<article
|
||||
className={`note-document ${
|
||||
noteContentCollapsed ? "mobile-collapsed" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mobile-note-summary">
|
||||
<div>
|
||||
<span>笔记内容已收起</span>
|
||||
<strong>{selected.title}</strong>
|
||||
<small>
|
||||
{selected.images.length} 张配图 · 已完成首次回填
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!noteContentCollapsed}
|
||||
onClick={() => setNoteContentCollapsed(false)}
|
||||
>
|
||||
展开笔记内容
|
||||
</button>
|
||||
</div>
|
||||
<div className="note-document-content">
|
||||
<div className="note-document-meta">
|
||||
<span>笔记 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
||||
<span>飞书源行 {selected.source_row ?? "—"}</span>
|
||||
</div>
|
||||
{selected.publish_url && (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-note-collapse-trigger"
|
||||
onClick={() => setNoteContentCollapsed(true)}
|
||||
>
|
||||
收起笔记内容
|
||||
</button>
|
||||
)}
|
||||
<div className="note-title-row">
|
||||
<h1>{selected.title}</h1>
|
||||
<button
|
||||
@@ -1183,9 +1237,10 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<form className="submit-card" onSubmit={submitNote}>
|
||||
<form ref={submitCardRef} className="submit-card" onSubmit={submitNote}>
|
||||
<div className="submit-heading">
|
||||
<div>
|
||||
<p className="micro">逐篇回填</p>
|
||||
|
||||
@@ -18,12 +18,13 @@ test("builds the branded external task shell", async () => {
|
||||
});
|
||||
|
||||
test("keeps claiming minimal and backfill one-to-one", async () => {
|
||||
const [page, layout, packageJson, nextConfig] =
|
||||
const [page, layout, packageJson, nextConfig, styles] =
|
||||
await Promise.all([
|
||||
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../next.config.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(page, /微信号\s*\/\s*手机号/);
|
||||
@@ -53,6 +54,13 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
||||
assert.match(page, /action:\s*"recover"/);
|
||||
assert.match(page, /同一任务多次领取会分批展示/);
|
||||
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
|
||||
assert.match(page, /笔记内容已收起/);
|
||||
assert.match(page, /展开笔记内容/);
|
||||
assert.match(page, /收起笔记内容/);
|
||||
assert.match(page, /isFirstBackfill/);
|
||||
assert.match(page, /scrollIntoView/);
|
||||
assert.match(page, /matchMedia\("\(max-width: 620px\)"\)/);
|
||||
assert.match(styles, /\.note-document\.mobile-collapsed/);
|
||||
assert.doesNotMatch(page, /批量回填/);
|
||||
assert.doesNotMatch(page, /复制标题和正文/);
|
||||
assert.match(page, /CONFIGURED_ADMIN_ORIGIN/);
|
||||
@@ -129,6 +137,9 @@ test("creates anonymous delegation bundles and reuses one-to-one backfill", asyn
|
||||
assert.match(page, /action:\s*"revoke_delegation"/);
|
||||
assert.match(page, /合作社转派 · 无需登录/);
|
||||
assert.match(page, /"X-KOC-Delegation"/);
|
||||
assert.match(page, /const url = new URL\(window\.location\.href\)/);
|
||||
assert.match(page, /url\.search = ""/);
|
||||
assert.doesNotMatch(page, /const url = new URL\(window\.location\.origin\)/);
|
||||
assert.match(page, /url\.searchParams\.set\("share", shareToken\)/);
|
||||
assert.match(page, /请保存当前分享链接/);
|
||||
assert.doesNotMatch(page, /底层KOC手机号|底层KOC企微|底层KOC微信/);
|
||||
|
||||
@@ -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<CanonicalHeader, number>, 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,
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user