feat: export author spread metrics
This commit is contained in:
@@ -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, "\\$&");
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user