feat: add selected audience profile csv export

This commit is contained in:
2026-05-18 16:59:05 +08:00
parent 03c2fe0cc7
commit 66bc49d498
17 changed files with 1458 additions and 16 deletions
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, test, vi } from "vitest";
import {
createAudienceProfileClient,
mapAudienceProfileResponse
} from "../src/content/market/audience-profile-client";
describe("audience-profile-client", () => {
test("loads connection user audience distributions from Xingtu", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => buildAudiencePayload(),
ok: true
}));
const client = createAudienceProfileClient({
baseUrl: "https://www.xingtu.cn",
fetchImpl,
timeoutMs: 1000
});
const result = await client.loadAudienceProfile({
authorId: "7294473194298146854",
authorName: "奇奇de海洋",
status: "success"
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=1",
expect.objectContaining({
credentials: "include",
method: "GET"
})
);
expect(result).toEqual(
expect.objectContaining({
status: "success",
gender: [
{ label: "男性", value: "71.7%" },
{ label: "女性", value: "28.3%" }
],
cityTop: expect.arrayContaining([{ label: "广州", value: "30.4%" }])
})
);
});
test("maps Xingtu audience distribution payload into named profile sections", () => {
const result = mapAudienceProfileResponse(buildAudiencePayload());
expect(result).toEqual(
expect.objectContaining({
status: "success",
age: [
{ label: "18-23", value: "20%" },
{ label: "24-30", value: "30%" },
{ label: "31-40", value: "50%" }
],
province: [
{ label: "广东", value: "60%" },
{ label: "浙江", value: "40%" }
],
cityTier: [{ label: "一线城市", value: "100%" }],
interest: [{ label: "随拍", value: "100%" }],
crowd: [{ label: "都市蓝领", value: "100%" }]
})
);
});
});
function buildAudiencePayload() {
return {
base_resp: {
status_code: 0,
status_message: ""
},
distributions: [
{
distribution_list: [
{ distribution_key: "male", distribution_value: 717 },
{ distribution_key: "female", distribution_value: 283 }
],
type_display: "性别分布"
},
{
distribution_list: [
{ distribution_key: "31-40", distribution_value: 50 },
{ distribution_key: "18-23", distribution_value: 20 },
{ distribution_key: "24-30", distribution_value: 30 }
],
type_display: "年龄分布"
},
{
distribution_list: [
{ distribution_key: "浙江", distribution_value: 40 },
{ distribution_key: "广东", distribution_value: 60 }
],
type_display: "省份分布"
},
{
distribution_list: [
{ distribution_key: "广州", distribution_value: 304 },
{ distribution_key: "北京", distribution_value: 291 },
{ distribution_key: "上海", distribution_value: 405 }
],
type_display: "城市分布"
},
{
distribution_list: [{ distribution_key: "一线", distribution_value: 1 }],
type_display: "城市等级分布"
},
{
distribution_list: [{ distribution_key: "随拍", distribution_value: 1 }],
type_display: "兴趣分布"
},
{
distribution_list: [{ distribution_key: "都市蓝领", distribution_value: 1 }],
type_display: "八大人群分布"
}
]
};
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, test } from "vitest";
import { buildAudienceProfileCsv } from "../src/content/market/audience-profile-csv";
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
describe("audience-profile-csv", () => {
test("appends structured audience profile columns after the market export columns", () => {
const csv = buildAudienceProfileCsv([
{
profile: {
age: [{ label: "31-40", value: "50%" }],
cityTier: [{ label: "一线城市", value: "100%" }],
cityTop: [{ label: "广州", value: "30.4%" }],
crowd: [{ label: "都市蓝领", value: "100%" }],
gender: [
{ label: "男性", value: "71.7%" },
{ label: "女性", value: "28.3%" }
],
interest: [{ label: "随拍", value: "100%" }],
province: [{ label: "广东", value: "60%" }],
status: "success"
},
record: {
authorId: "123",
authorName: "达人 A",
exportFields: {
: "达人 A",
: "300w"
},
status: "success"
}
}
] satisfies AudienceProfileExportRow[]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toContain("达人信息,连接用户数");
expect(headerLine).toContain("画像抓取状态");
expect(headerLine).toContain("连接用户-男性占比");
expect(headerLine).toContain("连接用户-31-40占比");
expect(headerLine).toContain("省份-广东占比");
expect(headerLine).toContain("地域TOP1名称,地域TOP1占比");
expect(headerLine).toContain("城市等级-一线城市占比");
expect(headerLine).toContain("兴趣TOP1名称,兴趣TOP1占比");
expect(headerLine).toContain("八大人群-都市蓝领占比");
expect(rowLine).toContain("成功");
expect(rowLine).toContain("71.7%");
expect(rowLine).toContain("广州,30.4%");
expect(rowLine).toContain("随拍,100%");
});
test("keeps failed profile rows and marks their failure reason", () => {
const csv = buildAudienceProfileCsv([
{
profile: {
failureReason: "request-failed",
status: "failed"
},
record: {
authorId: "123",
authorName: "达人 A",
status: "success"
}
}
] satisfies AudienceProfileExportRow[]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toContain("失败");
expect(rowLine).toContain("request-failed");
});
});
+21
View File
@@ -21,6 +21,27 @@ describe("background-auth-controller", () => {
);
});
test("returns unauthenticated state when the access token cannot be read", async () => {
const controller = createAuthController({
authClient: {
getAccessToken: vi.fn(async () => {
throw new Error("token expired");
}),
getIdTokenClaims: vi.fn(),
isAuthenticated: vi.fn(async () => true),
signIn: vi.fn(),
signOut: vi.fn()
}
});
await expect(controller.getAuthState()).resolves.toEqual(
expect.objectContaining({
isAuthenticated: false,
lastError: "token expired"
})
);
});
test("delegates sign in to the auth client", async () => {
const signIn = vi.fn(async () => undefined);
const controller = createAuthController({
+20
View File
@@ -24,4 +24,24 @@ describe("market-auth-gating", () => {
expect(createMarketController).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("请先登录插件");
});
test("shows an expired login message when auth state reports a token error", async () => {
document.body.innerHTML = "<div></div>";
await bootContentScript({
createMarketController: vi.fn(),
document,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: false,
lastError: "token expired"
}
})),
window
});
expect(document.body.textContent).toContain("登录已过期,请重新登录");
});
});
+135
View File
@@ -209,6 +209,46 @@ describe("market-content-entry", () => {
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url");
});
test("booted export callback can download a custom csv filename", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
let clickedDownload: { download: string; href: string } | null = null;
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement
) {
clickedDownload = {
download: this.download,
href: this.href
};
});
window.history.replaceState({}, "", "/ad/creator/market");
Object.defineProperty(window.URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:test-url")
});
Object.defineProperty(window.URL, "revokeObjectURL", {
configurable: true,
value: vi.fn()
});
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
}))
});
const controllerOptions = createMarketController.mock.calls[0]?.[0];
controllerOptions.onCsvReady("列1,列2\n值1,值2", "达人连接用户画像_20260518_1530.csv");
expect(clickedDownload?.download).toBe("达人连接用户画像_20260518_1530.csv");
});
test("booted export callback sends the csv to extension runtime when available", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
@@ -245,6 +285,7 @@ describe("market-content-entry", () => {
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
csv: "列1,列2\n值1,值2",
filename: expect.stringMatching(/^star-chart-search-enhancer-/),
type: "download-market-csv"
})
);
@@ -284,6 +325,9 @@ describe("market-content-entry", () => {
expect(document.body.firstElementChild).not.toBe(toolbar);
expect(document.querySelector('[data-plugin-export-range="select"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-export="button"]')).not.toBeNull();
expect(
document.querySelector('[data-plugin-export-audience-profile="button"]')
).not.toBeNull();
expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull();
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
@@ -293,10 +337,15 @@ describe("market-content-entry", () => {
const batchSubmitButton = document.querySelector(
'[data-plugin-batch-submit="button"]'
) as HTMLButtonElement | null;
const audienceProfileExportButton = document.querySelector(
'[data-plugin-export-audience-profile="button"]'
) as HTMLButtonElement | null;
expect(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
expect(exportButton?.style.color).toBe("rgb(255, 255, 255)");
expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)");
expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)");
});
test("remounts the plugin action bar when the native market action row appears later", async () => {
@@ -1535,6 +1584,92 @@ describe("market-content-entry", () => {
]);
});
test("audience profile export requires selected creators", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }
]);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAudienceProfile = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await flush();
expect(loadAudienceProfile).not.toHaveBeenCalled();
expect(buildAudienceProfileCsv).not.toHaveBeenCalled();
expect(
document.querySelector('[data-plugin-export-status="text"]')?.textContent
).toContain("请先勾选需要导出画像的达人");
});
test("audience profile export loads profiles only for selected creators", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" },
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
]);
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
const loadAudienceProfile = vi.fn(async () => ({
gender: [{ label: "男性", value: "60%" }],
status: "success" as const
}));
const onCsvReady = vi.fn();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildAudienceProfileCsv,
document,
loadAudienceProfile,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady,
window
}));
await controller.ready;
clickSelectionCheckboxForAuthor("222");
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export-audience-profile="button"]');
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
expect(loadAudienceProfile).toHaveBeenCalledTimes(1);
expect(loadAudienceProfile).toHaveBeenCalledWith(
expect.objectContaining({ authorId: "222" })
);
expect(buildAudienceProfileCsv).toHaveBeenCalledWith([
{
profile: {
gender: [{ label: "男性", value: "60%" }],
status: "success"
},
record: expect.objectContaining({ authorId: "222" })
}
]);
expect(onCsvReady).toHaveBeenCalledWith(
"profile-csv",
expect.stringMatching(/^达人连接用户画像_\d{8}_\d{4}\.csv$/)
);
});
test(
"selected export keeps a generic loading status while exporting the default paged range",
async () => {