From ed132621663671e7673db1436fe407f10f52d194 Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Fri, 28 Aug 2026 14:00:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=B9=E9=87=8F=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E8=B5=84=E6=96=99=E6=94=AF=E6=8C=81=E5=8B=BE?= =?UTF-8?q?=E9=80=89=E5=AD=97=E6=AE=B5=EF=BC=88=E5=90=ABAI=E6=89=93?= =?UTF-8?q?=E6=A0=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新全部账号ID 升级为 批量更新账号资料:弹窗勾选 账号ID/昵称/粉丝数/IP属地/性别/简介/标签 - 标签勾选时调用 LLM 重新打标(覆盖已有标签),AI_TAGGING_TOOL 未配置自动跳过 - 只写入选中字段,爬取为空的字段跳过不清空;演示账号(xhs- 前缀)不参与 - .env.app.example 为 SEED_DEMO_DATA 补充注释 --- .env.app.example | 1 + app/admin-app.tsx | 119 +++++++++++-- app/api/action/route.ts | 15 +- app/globals.css | 42 +++++ lib/account-field-refresh-service.ts | 253 +++++++++++++++++++++++++++ lib/account-id-refresh-service.ts | 134 -------------- tests/account-field-refresh.test.mjs | 141 +++++++++++++++ tests/account-id-refresh.test.mjs | 98 ----------- 8 files changed, 546 insertions(+), 257 deletions(-) create mode 100644 lib/account-field-refresh-service.ts delete mode 100644 lib/account-id-refresh-service.ts create mode 100644 tests/account-field-refresh.test.mjs delete mode 100644 tests/account-id-refresh.test.mjs diff --git a/.env.app.example b/.env.app.example index 74f197c..9a46c89 100644 --- a/.env.app.example +++ b/.env.app.example @@ -54,5 +54,6 @@ WECOM_NOTIFY_ENABLED=true # ── 调度与入口 ──────────────────────────────────────────── # 每天北京时间 09:00 自动执行采集计划,生产保持 true。 ENABLE_SCHEDULER=true +# 是否在空库时写入演示数据,生产保持 false。 SEED_DEMO_DATA=false HTTP_PORT=80 diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 39d441a..aa0cbf4 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -74,8 +74,9 @@ type Account = { last_seen_at: string; }; -type IdRefreshStatus = { +type FieldRefreshStatus = { status: "running" | "done"; + fields: string[]; total: number; processed: number; updated: number; @@ -85,6 +86,16 @@ type IdRefreshStatus = { finishedAt: string | null; }; +const REFRESH_FIELD_OPTIONS = [ + { key: "publicAccountId", label: "账号ID(小红书号/抖音号)" }, + { key: "nickname", label: "昵称" }, + { key: "followers", label: "粉丝数" }, + { key: "ipLocation", label: "IP属地" }, + { key: "gender", label: "性别" }, + { key: "bio", label: "简介" }, + { key: "tags", label: "标签(AI 重新打标,覆盖已有标签)" }, +] as const; + type ResourceImportPreview = { summary: { total: number; create: number; update: number; error: number }; rows: Array<{ @@ -3228,10 +3239,20 @@ function ResourcesPage({ const [editAccountId, setEditAccountId] = useState(""); const [editWorking, setEditWorking] = useState(false); const [editError, setEditError] = useState(""); - const [idRefresh, setIdRefresh] = useState(null); + const [idRefresh, setIdRefresh] = useState(null); const [idRefreshStarting, setIdRefreshStarting] = useState(false); const idRefreshNotifiedRef = useRef(true); const [idRefreshError, setIdRefreshError] = useState(""); + const [fieldPickOpen, setFieldPickOpen] = useState(false); + const [fieldChecks, setFieldChecks] = useState>( + () => + Object.fromEntries( + REFRESH_FIELD_OPTIONS.map((option) => [ + option.key, + option.key === "publicAccountId", + ]), + ), + ); const resourceAccounts = useMemo(() => { const deliveriesByAccount = new Map(); distributions.forEach((distribution) => { @@ -3494,9 +3515,9 @@ function ResourcesPage({ const response = await fetch("/api/action", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "public_account_id_refresh_status" }), + body: JSON.stringify({ action: "account_field_refresh_status" }), }); - const result = await readApiResponse<{ refresh: IdRefreshStatus | null }>( + const result = await readApiResponse<{ refresh: FieldRefreshStatus | null }>( response, "查询失败", ); @@ -3504,10 +3525,10 @@ function ResourcesPage({ return result.refresh; }, []); const applyIdRefreshStatus = useCallback( - (status: IdRefreshStatus) => { + (status: FieldRefreshStatus) => { if (status.status === "done" && !idRefreshNotifiedRef.current) { idRefreshNotifiedRef.current = true; - const summary = `账号ID更新完成:成功 ${status.updated} 个${ + const summary = `账号资料更新完成:成功 ${status.updated} 个${ status.skipped ? `,未识别 ${status.skipped} 个` : "" }${status.failed ? `,失败 ${status.failed} 个` : ""}`; void onImported(summary); @@ -3537,30 +3558,32 @@ function ResourcesPage({ }, 3000); return () => window.clearInterval(timer); }, [applyIdRefreshStatus, fetchIdRefreshStatus, runningIdRefresh]); + const closeFieldPick = () => { + if (idRefreshStarting) return; + setFieldPickOpen(false); + }; const startIdRefresh = async () => { if (runningIdRefresh || idRefreshStarting) return; - if ( - !window.confirm( - "将逐个爬取全部账号主页以更新小红书号/抖音号(其他资料不变),账号较多时需要几分钟。确定开始?", - ) - ) { - return; - } + const fields = REFRESH_FIELD_OPTIONS.filter( + (option) => fieldChecks[option.key], + ).map((option) => option.key); + if (fields.length === 0) return; try { setIdRefreshStarting(true); setIdRefreshError(""); const response = await fetch("/api/action", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "refresh_public_account_ids" }), + body: JSON.stringify({ action: "refresh_account_fields", fields }), }); - const result = await readApiResponse<{ refresh: IdRefreshStatus }>( + const result = await readApiResponse<{ refresh: FieldRefreshStatus }>( response, "启动失败", ); if (!response.ok) throw new Error(result.error || "启动失败"); idRefreshNotifiedRef.current = false; setIdRefresh(result.refresh); + setFieldPickOpen(false); } catch (reason) { setIdRefreshError(reason instanceof Error ? reason.message : "启动失败"); } finally { @@ -3680,14 +3703,14 @@ function ResourcesPage({ + +

+ 将逐个爬取全部账号主页(小红书 + 抖音),只写入选中的字段; + 爬取为空的字段自动跳过,不会清空已有数据。账号较多时需要几分钟。 +

+
+ {REFRESH_FIELD_OPTIONS.map((option) => ( + + ))} +
+ {idRefreshError &&
{idRefreshError}
} +
+ + +
+ + + )} {editAccount && (
= { + publicAccountId: "账号ID(小红书号/抖音号)", + nickname: "昵称", + followers: "粉丝数", + ipLocation: "IP属地", + gender: "性别", + bio: "简介", + tags: "标签(AI 重新打标,覆盖已有标签)", +}; + +const FIELD_COLUMNS: Record = { + publicAccountId: "public_account_id", + nickname: "nickname", + followers: "followers", + ipLocation: "ip_location", + gender: "gender", + bio: "bio", + tags: "tags", +}; + +export function sanitizeRefreshFields(value: unknown): AccountRefreshField[] { + const requested = Array.isArray(value) ? value : []; + const fields = ACCOUNT_REFRESH_FIELDS.filter((field) => + requested.includes(field), + ); + return fields.length > 0 ? fields : ["publicAccountId"]; +} + +export type AccountFieldRefreshStatus = { + status: "running" | "done"; + fields: AccountRefreshField[]; + total: number; + processed: number; + updated: number; + failed: number; + skipped: number; + startedAt: string; + finishedAt: string | null; +}; + +type RefreshTargetRow = { + id: string; + platform: string; + platform_uid: string; + profile_url: string; +}; + +export type ScrapedProfile = { + nickname: string | null; + redId: string | null; + followers: number | null; + ipLocation: string | null; + gender: string; + bio: string; + recentNoteTitles: string[]; + providerTags: string[]; +}; + +export type ProfileScraper = ( + profileUrl: string, + platform: "小红书" | "抖音", +) => Promise; + +export type TagGenerator = (input: { + platform: string; + nickname: string; + bio: string; + gender: string; + ipLocation: string; + followers: number | null; + providerTags: string[]; + recentNoteTitles: string[]; +}) => Promise; + +function scrapedFieldValues( + details: ScrapedProfile, +): Partial> { + const values: Partial> = {}; + const redId = details.redId?.trim(); + if (redId) values.publicAccountId = redId; + const nickname = details.nickname?.trim(); + if (nickname) values.nickname = nickname; + if (details.followers !== null && Number.isFinite(details.followers)) { + values.followers = details.followers; + } + const ipLocation = details.ipLocation?.trim(); + if (ipLocation) values.ipLocation = ipLocation; + if (details.gender === "男" || details.gender === "女") { + values.gender = details.gender; + } + const bio = details.bio?.trim(); + if (bio) values.bio = bio; + return values; +} + +let currentJob: AccountFieldRefreshStatus | null = null; + +export function getAccountFieldRefreshStatus(): AccountFieldRefreshStatus | null { + return currentJob ? { ...currentJob } : null; +} + +export function startAccountFieldRefresh( + db: DatabaseClient, + config: CollectionMcpConfig, + requestedFields: unknown, +): AccountFieldRefreshStatus { + if (currentJob?.status === "running") { + return { ...currentJob }; + } + const fields = sanitizeRefreshFields(requestedFields); + const job: AccountFieldRefreshStatus = { + status: "running", + fields, + total: 0, + processed: 0, + updated: 0, + failed: 0, + skipped: 0, + startedAt: new Date().toISOString(), + finishedAt: null, + }; + currentJob = job; + const scraper: ProfileScraper = (profileUrl, platform) => + resolveProfileDetailsFromMcp(profileUrl, platform, config); + const tagger: TagGenerator = (input) => + generateAccountTagsFromMcp( + input, + config, + getRuntimeEnv() as AccountTaggingEnv, + ); + runInBackground( + executeAccountFieldRefresh(job, db, scraper, tagger), + "account field refresh", + ); + return { ...job }; +} + +async function executeAccountFieldRefresh( + job: AccountFieldRefreshStatus, + db: DatabaseClient, + scraper: ProfileScraper, + tagger: TagGenerator, +) { + const rows = await db + .prepare( + `SELECT id, platform, platform_uid, profile_url + FROM accounts + WHERE profile_url IS NOT NULL AND profile_url != '' + AND platform IN ('小红书', '抖音')`, + ) + .all(); + const targets = rows.results.filter( + (row) => !row.platform_uid.startsWith("xhs-"), + ); + job.total = targets.length; + const fields = job.fields; + const wantTags = fields.includes("tags"); + + let cursor = 0; + const worker = async () => { + while (cursor < targets.length) { + const row = targets[cursor]; + cursor += 1; + try { + const details = await scraper( + row.profile_url, + row.platform === "抖音" ? "抖音" : "小红书", + ); + const values = scrapedFieldValues(details); + if (wantTags) { + const tags = await tagger({ + platform: row.platform, + nickname: details.nickname?.trim() || "", + bio: details.bio, + gender: details.gender, + ipLocation: details.ipLocation ?? "", + followers: details.followers, + providerTags: details.providerTags, + recentNoteTitles: details.recentNoteTitles, + }).catch(() => []); + if (tags.length > 0) values.tags = tags.join(","); + } + const updates = fields.filter( + (field) => values[field] !== undefined, + ); + if (updates.length === 0) { + job.skipped += 1; + } else { + const assignments = updates + .map((field) => `${FIELD_COLUMNS[field]} = ?`) + .join(", "); + await db + .prepare(`UPDATE accounts SET ${assignments} WHERE id = ?`) + .bind( + ...updates.map((field) => values[field]), + row.id, + ) + .run(); + job.updated += 1; + } + } catch { + job.failed += 1; + } + job.processed += 1; + } + }; + await Promise.all(Array.from({ length: Math.min(4, targets.length) }, worker)); + job.status = "done"; + job.finishedAt = new Date().toISOString(); +} + +export async function runAccountFieldRefresh( + db: DatabaseClient, + scraper: ProfileScraper, + tagger: TagGenerator, + fields: AccountRefreshField[], +): Promise { + const job: AccountFieldRefreshStatus = { + status: "running", + fields: sanitizeRefreshFields(fields), + total: 0, + processed: 0, + updated: 0, + failed: 0, + skipped: 0, + startedAt: new Date().toISOString(), + finishedAt: null, + }; + await executeAccountFieldRefresh(job, db, scraper, tagger); + return job; +} diff --git a/lib/account-id-refresh-service.ts b/lib/account-id-refresh-service.ts deleted file mode 100644 index c04c6e8..0000000 --- a/lib/account-id-refresh-service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { runInBackground } from "./background"; -import { - resolveProfileDetailsFromMcp, - type CollectionMcpConfig, -} from "./mcp-collection-client"; -import type { DatabaseClient } from "./database"; - -export type PublicAccountIdRefreshStatus = { - status: "running" | "done"; - total: number; - processed: number; - updated: number; - failed: number; - skipped: number; - startedAt: string; - finishedAt: string | null; -}; - -type RefreshTargetRow = { - id: string; - platform: string; - platform_uid: string; - profile_url: string; -}; - -export type ProfileScraper = ( - profileUrl: string, - platform: "小红书" | "抖音", -) => Promise<{ redId: string | null }>; - -const defaultScraper = - (config: CollectionMcpConfig): ProfileScraper => - (profileUrl, platform) => - resolveProfileDetailsFromMcp(profileUrl, platform, config); - -let currentJob: PublicAccountIdRefreshStatus | null = null; - -export function getPublicAccountIdRefreshStatus(): PublicAccountIdRefreshStatus | null { - return currentJob ? { ...currentJob } : null; -} - -export function startPublicAccountIdRefresh( - db: DatabaseClient, - config: CollectionMcpConfig, -): PublicAccountIdRefreshStatus { - if (currentJob?.status === "running") { - return { ...currentJob }; - } - const job: PublicAccountIdRefreshStatus = { - status: "running", - total: 0, - processed: 0, - updated: 0, - failed: 0, - skipped: 0, - startedAt: new Date().toISOString(), - finishedAt: null, - }; - currentJob = job; - runInBackground( - executePublicAccountIdRefresh(job, db, defaultScraper(config)), - "public account id refresh", - ); - return { ...job }; -} - -async function executePublicAccountIdRefresh( - job: PublicAccountIdRefreshStatus, - db: DatabaseClient, - scraper: ProfileScraper, -) { - const rows = await db - .prepare( - `SELECT id, platform, platform_uid, profile_url - FROM accounts - WHERE profile_url IS NOT NULL AND profile_url != '' - AND platform IN ('小红书', '抖音')`, - ) - .all(); - const targets = rows.results.filter( - (row) => !row.platform_uid.startsWith("xhs-"), - ); - job.total = targets.length; - - let cursor = 0; - const worker = async () => { - while (cursor < targets.length) { - const row = targets[cursor]; - cursor += 1; - try { - const details = await scraper( - row.profile_url, - row.platform === "抖音" ? "抖音" : "小红书", - ); - const redId = details.redId?.trim(); - if (redId) { - await db - .prepare(`UPDATE accounts SET public_account_id = ? WHERE id = ?`) - .bind(redId, row.id) - .run(); - job.updated += 1; - } else { - job.skipped += 1; - } - } catch { - job.failed += 1; - } - job.processed += 1; - } - }; - await Promise.all( - Array.from({ length: Math.min(4, targets.length) }, worker), - ); - job.status = "done"; - job.finishedAt = new Date().toISOString(); -} - -export async function runPublicAccountIdRefresh( - db: DatabaseClient, - scraper: ProfileScraper, -): Promise { - const job: PublicAccountIdRefreshStatus = { - status: "running", - total: 0, - processed: 0, - updated: 0, - failed: 0, - skipped: 0, - startedAt: new Date().toISOString(), - finishedAt: null, - }; - await executePublicAccountIdRefresh(job, db, scraper); - return job; -} diff --git a/tests/account-field-refresh.test.mjs b/tests/account-field-refresh.test.mjs new file mode 100644 index 0000000..8e53f1f --- /dev/null +++ b/tests/account-field-refresh.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + runAccountFieldRefresh, + sanitizeRefreshFields, +} from "../lib/account-field-refresh-service.ts"; + +function fakeDb(accounts) { + const updates = []; + return { + db: { + prepare: (sql) => { + if (sql.startsWith("SELECT")) { + return { + all: async () => ({ + results: accounts.map((account) => ({ + id: account.id, + platform: account.platform, + platform_uid: account.platform_uid, + profile_url: account.profile_url, + })), + success: true, + meta: { changes: 0, last_row_id: 0 }, + }), + }; + } + assert.match(sql, /^UPDATE accounts SET [a-z_, ?= ]+ WHERE id = \?$/); + return { + bind: (...args) => ({ + run: async () => { + updates.push({ sql, args }); + return { success: true, meta: { changes: 1, last_row_id: 0 } }; + }, + }), + }; + }, + }, + updates, + }; +} + +const profile = (overrides = {}) => ({ + nickname: "昵称A", + redId: "id-a", + followers: 123, + ipLocation: "上海", + gender: "女", + bio: "简介A", + recentNoteTitles: [], + providerTags: [], + ...overrides, +}); + +const noTag = async () => []; + +test("writes only the selected fields as dynamic columns", async () => { + const accounts = [ + { id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" }, + ]; + const { db, updates } = fakeDb(accounts); + const status = await runAccountFieldRefresh( + db, + async () => profile(), + noTag, + ["nickname", "ipLocation"], + ); + + assert.equal(status.status, "done"); + assert.equal(status.updated, 1); + assert.equal(status.fields.join(","), "nickname,ipLocation"); + assert.equal(updates.length, 1); + assert.match(updates[0].sql, /SET nickname = \?, ip_location = \?/); + assert.doesNotMatch(updates[0].sql, /public_account_id|followers|gender|bio|tags/); + assert.deepEqual(updates[0].args, ["昵称A", "上海", "a1"]); +}); + +test("tags field uses the tagger output and joins with commas", async () => { + const accounts = [ + { id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" }, + ]; + const { db, updates } = fakeDb(accounts); + const status = await runAccountFieldRefresh( + db, + async () => profile({ redId: "" }), + async () => ["美妆", "学生党"], + ["tags"], + ); + + assert.equal(status.updated, 1); + assert.match(updates[0].sql, /SET tags = \?/); + assert.deepEqual(updates[0].args, ["美妆,学生党", "a1"]); +}); + +test("empty scrape values and empty tagger result are skipped without writing", async () => { + const accounts = [ + { id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" }, + ]; + const { db, updates } = fakeDb(accounts); + const status = await runAccountFieldRefresh( + db, + async () => profile({ redId: " ", nickname: null, followers: null, ipLocation: "", gender: "", bio: "" }), + noTag, + ["publicAccountId", "nickname", "followers", "ipLocation", "gender", "bio", "tags"], + ); + + assert.equal(status.updated, 0); + assert.equal(status.skipped, 1); + assert.equal(updates.length, 0); +}); + +test("scrape failures count as failed and demo accounts are excluded", async () => { + const accounts = [ + { id: "boom", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/boom" }, + { id: "demo", platform: "小红书", platform_uid: "xhs-8af3", profile_url: "https://www.xiaohongshu.com/user/profile/demo" }, + { id: "ok", platform: "抖音", platform_uid: "uid-2", profile_url: "https://www.douyin.com/user/ok" }, + ]; + const { db, updates } = fakeDb(accounts); + const status = await runAccountFieldRefresh( + db, + async (url) => { + if (url.includes("boom")) throw new Error("mcp down"); + return profile({ redId: "ok-2" }); + }, + noTag, + ["publicAccountId"], + ); + + assert.equal(status.total, 2); + assert.equal(status.updated, 1); + assert.equal(status.failed, 1); + assert.deepEqual(updates[0].args.slice(-1), ["ok"]); +}); + +test("sanitizeRefreshFields filters unknown values and falls back to account id", () => { + assert.deepEqual(sanitizeRefreshFields(["nickname", "hacked", "tags"]), [ + "nickname", + "tags", + ]); + assert.deepEqual(sanitizeRefreshFields([]), ["publicAccountId"]); + assert.deepEqual(sanitizeRefreshFields("bad"), ["publicAccountId"]); +}); diff --git a/tests/account-id-refresh.test.mjs b/tests/account-id-refresh.test.mjs deleted file mode 100644 index 851e8f5..0000000 --- a/tests/account-id-refresh.test.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - runPublicAccountIdRefresh, -} from "../lib/account-id-refresh-service.ts"; - -function fakeDb(accounts) { - const updates = []; - return { - db: { - prepare: (sql) => { - if (sql.startsWith("SELECT")) { - return { - all: async () => ({ - results: accounts.map((account) => ({ - id: account.id, - platform: account.platform, - platform_uid: account.platform_uid, - profile_url: account.profile_url, - })), - success: true, - meta: { changes: 0, last_row_id: 0 }, - }), - }; - } - const match = sql.match(/WHERE id = \?$/); - assert.ok(match, `unexpected sql: ${sql}`); - assert.match(sql, /UPDATE accounts SET public_account_id = \?/); - assert.doesNotMatch(sql, /followers|tags|nickname|bio|gender|ip_location/); - return { - bind: (value, id) => ({ - run: async () => { - updates.push({ value, id }); - return { success: true, meta: { changes: 1, last_row_id: 0 } }; - }, - }), - }; - }, - }, - updates, - }; -} - -test("updates only public_account_id for scrapeable accounts", async () => { - const accounts = [ - { id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" }, - { id: "a2", platform: "抖音", platform_uid: "uid-2", profile_url: "https://www.douyin.com/user/a2" }, - ]; - const { db, updates } = fakeDb(accounts); - const status = await runPublicAccountIdRefresh(db, async (url) => { - if (url.endsWith("a1")) return { redId: "xhs-id-1" }; - if (url.endsWith("a2")) return { redId: " dy-id-2 " }; - return { redId: null }; - }); - - assert.equal(status.status, "done"); - assert.equal(status.total, 2); - assert.equal(status.processed, 2); - assert.equal(status.updated, 2); - assert.equal(status.failed, 0); - assert.deepEqual(updates, [ - { value: "xhs-id-1", id: "a1" }, - { value: "dy-id-2", id: "a2" }, - ]); -}); - -test("counts failures and skips empty redId without writing", async () => { - const accounts = [ - { id: "a1", platform: "小红书", platform_uid: "uid-1", profile_url: "https://www.xiaohongshu.com/user/profile/a1" }, - { id: "a2", platform: "小红书", platform_uid: "uid-2", profile_url: "https://www.xiaohongshu.com/user/profile/a2" }, - { id: "a3", platform: "小红书", platform_uid: "uid-3", profile_url: "https://www.xiaohongshu.com/user/profile/a3" }, - ]; - const { db, updates } = fakeDb(accounts); - const status = await runPublicAccountIdRefresh(db, async (url) => { - if (url.endsWith("a1")) throw new Error("mcp down"); - if (url.endsWith("a2")) return { redId: " " }; - return { redId: "ok-3" }; - }); - - assert.equal(status.total, 3); - assert.equal(status.updated, 1); - assert.equal(status.failed, 1); - assert.equal(status.skipped, 1); - assert.deepEqual(updates, [{ value: "ok-3", id: "a3" }]); -}); - -test("ignores demo accounts and accounts without profile_url at query level", async () => { - const accounts = [ - { id: "demo", platform: "小红书", platform_uid: "xhs-8af3", profile_url: "https://www.xiaohongshu.com/user/profile/demo" }, - { id: "real", platform: "小红书", platform_uid: "uid-9", profile_url: "https://www.xiaohongshu.com/user/profile/real" }, - ]; - const { db, updates } = fakeDb(accounts); - const status = await runPublicAccountIdRefresh(db, async () => ({ redId: "id-9" })); - - assert.equal(status.total, 1); - assert.equal(status.updated, 1); - assert.deepEqual(updates, [{ value: "id-9", id: "real" }]); -});