feat: export author spread metrics

This commit is contained in:
wxs
2026-06-29 16:06:14 +08:00
parent 7839380613
commit 121977fd0d
14 changed files with 1117 additions and 20 deletions
+69 -9
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { buildMarketCsv } from "../src/content/market/csv-exporter";
import { buildSpreadInfoColumns } from "../src/content/market/spread-info";
import type { MarketRecord } from "../src/content/market/types";
describe("csv-exporter", () => {
@@ -21,12 +22,13 @@ describe("csv-exporter", () => {
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
"秒思api-cp_search",
...buildSpreadInfoColumns()
].join(",")
);
});
test("uses page export field order and appends the two plugin columns", () => {
test("uses page export field order and appends the plugin columns", () => {
const csv = buildMarketCsv([
{
authorId: "123",
@@ -66,11 +68,16 @@ describe("csv-exporter", () => {
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
"秒思api-cp_search",
...buildSpreadInfoColumns()
].join(",")
);
expect(rowLine).toBe(
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
expect(rowLine).toMatch(
new RegExp(
`^${escapeRegExp(
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
)}`
)
);
});
@@ -101,10 +108,13 @@ describe("csv-exporter", () => {
"秒思api-新增A3数",
"秒思api-新增A3率",
"秒思api-CPA3",
"秒思api-cp_search"
"秒思api-cp_search",
...buildSpreadInfoColumns()
].join(",")
);
expect(rowLine).toBe("Alice,100w,,,,,,,,");
expect(rowLine.split(",").slice(0, 10).join(",")).toBe(
"Alice,100w,,,,,,,,"
);
});
test("escapes commas and quotes", () => {
@@ -137,7 +147,10 @@ describe("csv-exporter", () => {
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,,,,,,,,");
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
"123,Alice,,,,,,,,,,"
);
expect(rowLine.split(",").slice(12).every((cell) => cell === "")).toBe(true);
});
test("uses normalized display values in export rows", () => {
@@ -172,6 +185,53 @@ describe("csv-exporter", () => {
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
"123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,"
);
});
test("appends spread info metric columns after backend metrics", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
spreadMetrics: {
"个人视频_近30天_完播率": "28.24%",
"只看指派_排除营销流量_星图视频_近30天_互动率": "4.02%"
},
status: "success"
} satisfies MarketRecord
]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toContain(
[
"秒思api-cp_search",
"个人视频_近30天_完播率",
"个人视频_近30天_播放量中位数"
].join(",")
);
expect(headerLine).toContain(
"只看指派_排除营销流量_星图视频_近30天_互动率"
);
expect(rowLine).toContain("28.24%");
expect(rowLine).toContain("4.02%");
});
test("emits empty spread info cells when spread metrics are absent", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
status: "success"
} satisfies MarketRecord
]);
const [, rowLine] = csv.split("\n");
expect(rowLine.split(",").slice(-70).every((cell) => cell === "")).toBe(true);
});
});
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+69 -1
View File
@@ -293,7 +293,10 @@ describe("market-content-entry", () => {
});
test("renders the plugin action bar inside the native market action row", async () => {
document.body.innerHTML = buildMarketFixture();
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
@@ -1217,6 +1220,71 @@ describe("market-content-entry", () => {
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
});
test("export hydrates spread info with attribute_datas.id before building csv", async () => {
document.body.innerHTML = buildRealMarketFixture([
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
]);
attachMarketListState([
{
attribute_datas: {
id: "spread-a",
nickname: "Alpha"
},
star_id: "a"
},
{
attribute_datas: {
id: "spread-b",
nickname: "Beta"
},
star_id: "b"
}
]);
const buildCsv = vi.fn(() => "csv-output");
const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => ({
"个人视频_近30天_完播率": spreadAuthorId === "spread-a" ? "28.24%" : "18.24%"
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
loadSpreadMetrics,
onCsvReady: vi.fn(),
window
}));
await controller.ready;
setSelectValue('[data-plugin-export-range="select"]', "current");
dispatchChange('[data-plugin-export-range="select"]');
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 80, 50);
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-a");
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-b");
expect(buildCsv.mock.calls[0][0]).toEqual([
expect.objectContaining({
authorId: "a",
spreadAuthorId: "spread-a",
spreadMetrics: {
"个人视频_近30天_完播率": "28.24%"
}
}),
expect.objectContaining({
authorId: "b",
spreadAuthorId: "spread-b",
spreadMetrics: {
"个人视频_近30天_完播率": "18.24%"
}
})
]);
});
test(
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
async () => {
+43
View File
@@ -155,6 +155,49 @@ describe("silent-export-controller", () => {
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
});
test("keeps attribute_datas.id as the spread author id while preserving star_id as row id", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: 1
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const controller = createSilentExportController({
document,
fetchImpl: async () => ({
json: async () => ({
authors: [
{
attribute_datas: {
id: "spread-1",
nickname: "达人1"
},
star_id: "row-1"
}
]
}),
ok: true
})
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 1
});
expect(records?.[0]).toMatchObject({
authorId: "row-1",
spreadAuthorId: "spread-1"
});
});
test("starts from page 1 when the captured request omits an explicit page number", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
+168
View File
@@ -0,0 +1,168 @@
import { describe, expect, test, vi } from "vitest";
import {
buildSpreadInfoColumns,
buildSpreadInfoUrl,
createSpreadInfoClient,
DEFAULT_SPREAD_INFO_CONFIGS,
mapSpreadInfoResponse
} from "../src/content/market/spread-info";
describe("spread-info", () => {
test("builds the spread info url with all request parameters", () => {
expect(
buildSpreadInfoUrl(
"7361012802036695050",
{
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
},
"https://www.xingtu.cn"
)
).toBe(
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=2&flow_type=1&only_assign=true&range=2"
);
});
test("defines personal video ranges with fixed non-prefix parameters", () => {
const personalConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
(config) => config.type === 1
);
expect(personalConfigs).toEqual([
{
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
},
{
flowType: 0,
onlyAssign: false,
range: 3,
type: 1
}
]);
expect(buildSpreadInfoColumns(personalConfigs).slice(0, 2)).toEqual([
"个人视频_近30天_完播率",
"个人视频_近30天_播放量中位数"
]);
});
test("defines all xingtu video assign flow and range combinations", () => {
const xingtuConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
(config) => config.type === 2
);
expect(xingtuConfigs).toHaveLength(8);
expect(xingtuConfigs).toContainEqual({
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
});
expect(xingtuConfigs).toContainEqual({
flowType: 0,
onlyAssign: false,
range: 3,
type: 2
});
expect(buildSpreadInfoColumns([
{
flowType: 1,
onlyAssign: true,
range: 2,
type: 2
}
])).toEqual([
"只看指派_排除营销流量_星图视频_近30天_完播率",
"只看指派_排除营销流量_星图视频_近30天_播放量中位数",
"只看指派_排除营销流量_星图视频_近30天_互动率",
"只看指派_排除营销流量_星图视频_近30天_作品平均时长",
"只看指派_排除营销流量_星图视频_近30天_作品平均评论数",
"只看指派_排除营销流量_星图视频_近30天_作品平均点赞数",
"只看指派_排除营销流量_星图视频_近30天_作品平均转发数"
]);
});
test("maps spread info response values into display values", () => {
expect(
mapSpreadInfoResponse({
avg_duration: "5600",
comment_avg: "7502",
interact_rate: {
value: 402
},
item_rate: {
play_mid: {
value: 10913233
}
},
like_avg: "494458",
play_over_rate: {
value: 2824
},
share_avg: "188267"
})
).toEqual({
averageCommentCount: "7502",
averageDuration: "56",
averageLikeCount: "494458",
averageShareCount: "188267",
finishRate: "28.24%",
interactionRate: "4.02%",
playMedian: "10913233"
});
});
test("loads each configured spread metric column for one author", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => ({
avg_duration: "5600",
comment_avg: "7502",
interact_rate: {
value: 402
},
like_avg: "494458",
play_mid: "10913233",
play_over_rate: {
value: 2824
},
share_avg: "188267"
}),
ok: true
}));
const client = createSpreadInfoClient({
configs: [
{
flowType: 0,
onlyAssign: false,
range: 2,
type: 1
}
],
fetchImpl
});
await expect(
client.loadAuthorSpreadMetrics("7361012802036695050")
).resolves.toEqual({
"个人视频_近30天_互动率": "4.02%",
"个人视频_近30天_作品平均点赞数": "494458",
"个人视频_近30天_作品平均评论数": "7502",
"个人视频_近30天_作品平均时长": "56",
"个人视频_近30天_作品平均转发数": "188267",
"个人视频_近30天_完播率": "28.24%",
"个人视频_近30天_播放量中位数": "10913233"
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=false&range=2",
expect.objectContaining({
credentials: "include",
method: "GET"
})
);
});
});