feat: filter exports by spread thresholds
This commit is contained in:
parent
121977fd0d
commit
9eb1fe43cc
@ -0,0 +1,33 @@
|
||||
# 星图达人传播指标阈值筛选 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在导出 CSV 和提交批次前,按用户选择的 spread-info 参数组合和指标阈值过滤达人。
|
||||
|
||||
**Architecture:** 工具栏负责读取筛选配置;`spread-info.ts` 提供单参数组合加载与阈值比较;`index.ts` 在 export range 收集后、CSV/批次 payload 生成前统一应用筛选。
|
||||
|
||||
**Tech Stack:** TypeScript, Chrome MV3 content script, Vitest, jsdom.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Toolbar Filter State
|
||||
|
||||
- [ ] 增加视频类别、指派、营销流量、数据范围和 7 个阈值输入控件。
|
||||
- [ ] 增加 `readToolbarSpreadFilter` 读取并校验筛选配置。
|
||||
- [ ] 测试个人视频时固定并禁用指派/营销流量。
|
||||
|
||||
### Task 2: Spread Filter Logic
|
||||
|
||||
- [ ] 在 `spread-info.ts` 增加单配置请求与阈值比较。
|
||||
- [ ] 测试百分比显示值、秒、普通数字比较。
|
||||
|
||||
### Task 3: Export And Batch Integration
|
||||
|
||||
- [ ] 在导出和提交批次流程中调用筛选逻辑。
|
||||
- [ ] 空阈值不触发筛选请求。
|
||||
- [ ] 测试导出和提交批次都只保留满足阈值的达人。
|
||||
|
||||
### Task 4: Verification
|
||||
|
||||
- [ ] 运行 focused tests。
|
||||
- [ ] 运行 `npm run build`。
|
||||
@ -0,0 +1,101 @@
|
||||
# 星图达人传播指标阈值筛选需求文档
|
||||
|
||||
## 目标
|
||||
|
||||
在导出 CSV 或提交批次之前,允许用户按一组视频传播数据参数和指标阈值对达人做二次筛选。
|
||||
|
||||
只有满足筛选条件的达人,才进入最终导出或提交批次。
|
||||
|
||||
## 筛选维度
|
||||
|
||||
筛选维度对应 `get_author_spread_info` 的请求参数:
|
||||
|
||||
| UI 维度 | 接口参数 | 可选值 |
|
||||
| --- | --- | --- |
|
||||
| 视频类别 | `type` | 个人视频 / 星图视频 |
|
||||
| 是否指派 | `only_assign` | 只看指派 / 不限指派 |
|
||||
| 是否排除营销流量 | `flow_type` | 排除营销流量 / 不排除营销流量 |
|
||||
| 数据范围 | `range` | 近30天 / 近90天 |
|
||||
|
||||
个人视频的参数约束:
|
||||
|
||||
- `type=1`
|
||||
- `only_assign=false`
|
||||
- `flow_type=0`
|
||||
- `range` 可选近30天或近90天
|
||||
|
||||
星图视频的参数约束:
|
||||
|
||||
- `type=2`
|
||||
- `only_assign` 可选
|
||||
- `flow_type` 可选
|
||||
- `range` 可选近30天或近90天
|
||||
|
||||
## 指标阈值
|
||||
|
||||
支持下面 7 个指标阈值:
|
||||
|
||||
- 完播率 >=
|
||||
- 播放量中位数 >=
|
||||
- 互动率 >=
|
||||
- 作品平均时长 >=
|
||||
- 作品平均评论数 >=
|
||||
- 作品平均点赞数 >=
|
||||
- 作品平均转发数 >=
|
||||
|
||||
规则:
|
||||
|
||||
- 没填的阈值不参与筛选。
|
||||
- 填了多个阈值时,必须全部满足才保留达人。
|
||||
- 完播率和互动率使用百分数显示值,例如填 `30` 表示 `30%`。
|
||||
- 作品平均时长使用秒,例如填 `56` 表示 `56秒`。
|
||||
- 播放量、评论、点赞、转发使用普通数字。
|
||||
- 如果某个达人在所选参数组合下接口请求失败或缺少被启用的指标,则视为不满足筛选。
|
||||
|
||||
## 生效范围
|
||||
|
||||
阈值筛选同时作用于:
|
||||
|
||||
- 导出 CSV
|
||||
- 提交批次
|
||||
|
||||
处理顺序:
|
||||
|
||||
1. 先按现有导出范围收集达人,例如当前页、前5页、前10页、全部或自定义页数。
|
||||
2. 如果用户没有填写任何阈值,保持现有导出/提交行为。
|
||||
3. 如果用户填写了阈值,对收集到的每个达人按当前筛选维度调用一次 `get_author_spread_info`。
|
||||
4. 将接口响应映射为显示值。
|
||||
5. 用已填写的阈值过滤达人。
|
||||
6. 过滤后的达人进入导出 CSV 或提交批次。
|
||||
|
||||
## UI 设计
|
||||
|
||||
在现有插件操作区中增加一组紧凑控件:
|
||||
|
||||
- 视频类别下拉框
|
||||
- 指派下拉框
|
||||
- 营销流量下拉框
|
||||
- 数据范围下拉框
|
||||
- 7 个数字输入框
|
||||
|
||||
当视频类别选择“个人视频”时:
|
||||
|
||||
- 指派固定为“不限指派”
|
||||
- 营销流量固定为“不排除营销流量”
|
||||
- 对应控件禁用
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 单个达人筛选请求失败:该达人不满足筛选。
|
||||
- 全部达人都不满足:导出空 CSV 表头;提交批次时按现有空记录处理。
|
||||
- 阈值输入非法:阻止导出/提交,并提示用户修正。
|
||||
|
||||
## 测试要求
|
||||
|
||||
- 读取 toolbar 中的筛选参数和阈值。
|
||||
- 个人视频禁用指派和营销流量控件。
|
||||
- 空阈值时不触发二次筛选。
|
||||
- 有阈值时按所选参数调用 `get_author_spread_info`。
|
||||
- 百分比阈值按显示值比较。
|
||||
- 多个阈值按 AND 关系过滤。
|
||||
- 导出 CSV 和提交批次都应用二次筛选。
|
||||
@ -31,9 +31,10 @@ import { createMarketApiClient } from "./api-client";
|
||||
import { createExportRangeController } from "./export-range-controller";
|
||||
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
||||
import { createSilentExportController } from "./silent-export-controller";
|
||||
import { createSpreadInfoClient } from "./spread-info";
|
||||
import { createSpreadInfoClient, matchesSpreadThresholds } from "./spread-info";
|
||||
import {
|
||||
readToolbarExportTarget,
|
||||
readToolbarSpreadFilter,
|
||||
setToolbarBusyState,
|
||||
setToolbarExportStatus
|
||||
} from "./plugin-toolbar";
|
||||
@ -55,7 +56,8 @@ import type {
|
||||
MarketExportTarget,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
MarketSortState
|
||||
MarketSortState,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
interface MutationObserverLike {
|
||||
@ -80,6 +82,10 @@ export interface CreateMarketControllerOptions {
|
||||
target: AudienceProfileRequestTarget
|
||||
) => Promise<AudienceProfileResult>;
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
loadSpreadFilterMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
config: SpreadThresholdFilter["config"]
|
||||
) => Promise<Record<string, string | undefined>>;
|
||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
@ -108,6 +114,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||
const loadAuthorMetrics =
|
||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||
const loadSpreadFilterMetrics =
|
||||
options.loadSpreadFilterMetrics ??
|
||||
spreadInfoClient.loadAuthorSpreadMetricSnapshot;
|
||||
const loadSpreadMetrics =
|
||||
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
||||
const searchBackendMetrics =
|
||||
@ -211,14 +220,22 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||
return;
|
||||
}
|
||||
const spreadFilter = readToolbarSpreadFilter(toolbar);
|
||||
if (spreadFilter.error) {
|
||||
setToolbarExportStatus(toolbar, spreadFilter.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const records = filterRecordsBySelection(
|
||||
await exportRecords(exportTarget.target, "导出中", {
|
||||
includeSpreadMetrics: true,
|
||||
showDetailedProgress: selectedAuthorIds.size === 0
|
||||
})
|
||||
await applySpreadThresholdFilter(
|
||||
await exportRecords(exportTarget.target, "导出中", {
|
||||
includeSpreadMetrics: true,
|
||||
showDetailedProgress: selectedAuthorIds.size === 0
|
||||
}),
|
||||
spreadFilter.filter
|
||||
)
|
||||
);
|
||||
options.onCsvReady?.(buildCsv(records));
|
||||
setToolbarExportStatus(toolbar, "");
|
||||
@ -375,6 +392,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||
return;
|
||||
}
|
||||
const spreadFilter = readToolbarSpreadFilter(toolbar);
|
||||
if (spreadFilter.error) {
|
||||
setToolbarExportStatus(toolbar, spreadFilter.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const batchName = await promptBatchName();
|
||||
if (batchName === null) {
|
||||
@ -390,12 +412,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
try {
|
||||
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
||||
const records = filterRecordsBySelection(
|
||||
await exportRecords(
|
||||
exportTarget.target,
|
||||
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
||||
{
|
||||
showDetailedProgress: !hasSelectedAuthors
|
||||
}
|
||||
await applySpreadThresholdFilter(
|
||||
await exportRecords(
|
||||
exportTarget.target,
|
||||
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
||||
{
|
||||
showDetailedProgress: !hasSelectedAuthors
|
||||
}
|
||||
),
|
||||
spreadFilter.filter
|
||||
)
|
||||
);
|
||||
const authState = await getAuthState();
|
||||
@ -772,6 +797,37 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||
}
|
||||
|
||||
async function applySpreadThresholdFilter(
|
||||
records: MarketRecord[],
|
||||
filter: SpreadThresholdFilter | undefined
|
||||
): Promise<MarketRecord[]> {
|
||||
if (!filter) {
|
||||
return records;
|
||||
}
|
||||
|
||||
const matchedAuthorIds = new Set<string>();
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const spreadAuthorId = record.spreadAuthorId;
|
||||
if (!spreadAuthorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await loadSpreadFilterMetrics(
|
||||
spreadAuthorId,
|
||||
filter.config
|
||||
);
|
||||
if (matchesSpreadThresholds(metrics, filter.thresholds)) {
|
||||
matchedAuthorIds.add(record.authorId);
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
);
|
||||
|
||||
return records.filter((record) => matchedAuthorIds.has(record.authorId));
|
||||
}
|
||||
|
||||
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||
if (selectedAuthorIds.size === 0) {
|
||||
return [];
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type {
|
||||
MarketExportScope,
|
||||
MarketExportTarget
|
||||
MarketExportTarget,
|
||||
SpreadThresholdFilter
|
||||
} from "./types";
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
@ -20,6 +21,11 @@ export interface PluginToolbarDom {
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||
spreadFilterRangeSelect: HTMLSelectElement;
|
||||
spreadFilterTypeSelect: HTMLSelectElement;
|
||||
spreadThresholdInputs: Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement>;
|
||||
root: HTMLElement;
|
||||
}
|
||||
|
||||
@ -114,15 +120,91 @@ export function ensurePluginToolbar(
|
||||
exportStatusText.dataset.pluginExportStatus = "text";
|
||||
applyStatusStyles(exportStatusText);
|
||||
|
||||
const spreadFilterTypeSelect = document.createElement("select");
|
||||
spreadFilterTypeSelect.dataset.pluginSpreadFilter = "type";
|
||||
appendOption(spreadFilterTypeSelect, "1", "个人视频");
|
||||
appendOption(spreadFilterTypeSelect, "2", "星图视频");
|
||||
spreadFilterTypeSelect.value = "1";
|
||||
|
||||
const spreadFilterOnlyAssignSelect = document.createElement("select");
|
||||
spreadFilterOnlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign";
|
||||
appendOption(spreadFilterOnlyAssignSelect, "false", "不限指派");
|
||||
appendOption(spreadFilterOnlyAssignSelect, "true", "只看指派");
|
||||
spreadFilterOnlyAssignSelect.value = "false";
|
||||
|
||||
const spreadFilterFlowTypeSelect = document.createElement("select");
|
||||
spreadFilterFlowTypeSelect.dataset.pluginSpreadFilter = "flowType";
|
||||
appendOption(spreadFilterFlowTypeSelect, "0", "不排除营销");
|
||||
appendOption(spreadFilterFlowTypeSelect, "1", "排除营销");
|
||||
spreadFilterFlowTypeSelect.value = "0";
|
||||
|
||||
const spreadFilterRangeSelect = document.createElement("select");
|
||||
spreadFilterRangeSelect.dataset.pluginSpreadFilter = "range";
|
||||
appendOption(spreadFilterRangeSelect, "2", "近30天");
|
||||
appendOption(spreadFilterRangeSelect, "3", "近90天");
|
||||
spreadFilterRangeSelect.value = "2";
|
||||
|
||||
const spreadThresholdInputs = createSpreadThresholdInputs(document);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.dataset.pluginToolbarPanel = "root";
|
||||
applyToolbarPanelStyles(panel);
|
||||
|
||||
const firstRow = document.createElement("div");
|
||||
firstRow.dataset.pluginToolbarRow = "primary";
|
||||
applyToolbarRowStyles(firstRow);
|
||||
|
||||
const secondRow = document.createElement("div");
|
||||
secondRow.dataset.pluginToolbarRow = "thresholds";
|
||||
applyToolbarRowStyles(secondRow);
|
||||
|
||||
const dataGroup = document.createElement("div");
|
||||
dataGroup.dataset.pluginToolbarGroup = "data";
|
||||
applyToolbarGroupStyles(dataGroup);
|
||||
dataGroup.append(
|
||||
createToolbarGroupTitle(document, "达人数据"),
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton
|
||||
);
|
||||
|
||||
const videoGroup = document.createElement("div");
|
||||
videoGroup.dataset.pluginToolbarGroup = "video";
|
||||
applyToolbarGroupStyles(videoGroup);
|
||||
videoGroup.append(
|
||||
createToolbarGroupTitle(document, "视频口径"),
|
||||
spreadFilterTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterRangeSelect
|
||||
);
|
||||
|
||||
const thresholdGroup = document.createElement("div");
|
||||
thresholdGroup.dataset.pluginToolbarGroup = "thresholds";
|
||||
applyToolbarGroupStyles(thresholdGroup);
|
||||
thresholdGroup.append(
|
||||
createToolbarGroupTitle(document, "传播指标"),
|
||||
...Object.values(spreadThresholdInputs)
|
||||
);
|
||||
|
||||
const divider = document.createElement("span");
|
||||
applyToolbarDividerStyles(divider);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.dataset.pluginToolbarActions = "root";
|
||||
applyToolbarActionStyles(actions);
|
||||
actions.append(batchSubmitButton);
|
||||
|
||||
firstRow.append(dataGroup, divider, videoGroup, exportStatusText);
|
||||
secondRow.append(thresholdGroup);
|
||||
panel.append(firstRow, secondRow);
|
||||
|
||||
root.append(
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput,
|
||||
exportButton,
|
||||
audienceProfileExportButton,
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportStatusText
|
||||
panel,
|
||||
actions
|
||||
);
|
||||
|
||||
document.body.appendChild(root);
|
||||
@ -133,7 +215,12 @@ export function ensurePluginToolbar(
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect
|
||||
exportRangeSelect,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
...Object.values(spreadThresholdInputs)
|
||||
});
|
||||
ensureToolbarMounted(root, document);
|
||||
|
||||
@ -165,6 +252,21 @@ export function ensurePluginToolbar(
|
||||
root
|
||||
});
|
||||
});
|
||||
spreadFilterTypeSelect.addEventListener("change", () => {
|
||||
syncSpreadFilterControlState({
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
root,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs
|
||||
});
|
||||
});
|
||||
|
||||
const toolbarDom = {
|
||||
audienceProfileExportButton,
|
||||
@ -175,9 +277,15 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
spreadFilterFlowTypeSelect,
|
||||
spreadFilterOnlyAssignSelect,
|
||||
spreadFilterRangeSelect,
|
||||
spreadFilterTypeSelect,
|
||||
spreadThresholdInputs,
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
syncSpreadFilterControlState(toolbarDom);
|
||||
|
||||
return toolbarDom;
|
||||
}
|
||||
@ -193,6 +301,73 @@ function appendOption(
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
function createSpreadThresholdInputs(
|
||||
document: Document
|
||||
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
||||
return {
|
||||
averageCommentCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageCommentCount",
|
||||
"评论>="
|
||||
),
|
||||
averageDuration: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageDuration",
|
||||
"时长>="
|
||||
),
|
||||
averageLikeCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageLikeCount",
|
||||
"点赞>="
|
||||
),
|
||||
averageShareCount: createSpreadThresholdInput(
|
||||
document,
|
||||
"averageShareCount",
|
||||
"转发>="
|
||||
),
|
||||
finishRate: createSpreadThresholdInput(document, "finishRate", "完播率>="),
|
||||
interactionRate: createSpreadThresholdInput(document, "interactionRate", "互动率>="),
|
||||
playMedian: createSpreadThresholdInput(document, "playMedian", "播放中位数>=")
|
||||
};
|
||||
}
|
||||
|
||||
function createSpreadThresholdInput(
|
||||
document: Document,
|
||||
key: keyof SpreadThresholdFilter["thresholds"],
|
||||
placeholder: string
|
||||
): HTMLInputElement {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "0";
|
||||
input.step = "0.01";
|
||||
input.placeholder = placeholder;
|
||||
input.dataset.pluginSpreadThreshold = key;
|
||||
return input;
|
||||
}
|
||||
|
||||
function readSpreadThresholdInputs(
|
||||
root: HTMLElement
|
||||
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
||||
return {
|
||||
averageCommentCount: readSpreadThresholdInput(root, "averageCommentCount"),
|
||||
averageDuration: readSpreadThresholdInput(root, "averageDuration"),
|
||||
averageLikeCount: readSpreadThresholdInput(root, "averageLikeCount"),
|
||||
averageShareCount: readSpreadThresholdInput(root, "averageShareCount"),
|
||||
finishRate: readSpreadThresholdInput(root, "finishRate"),
|
||||
interactionRate: readSpreadThresholdInput(root, "interactionRate"),
|
||||
playMedian: readSpreadThresholdInput(root, "playMedian")
|
||||
};
|
||||
}
|
||||
|
||||
function readSpreadThresholdInput(
|
||||
root: HTMLElement,
|
||||
key: keyof SpreadThresholdFilter["thresholds"]
|
||||
): HTMLInputElement {
|
||||
return root.querySelector(
|
||||
`[data-plugin-spread-threshold="${key}"]`
|
||||
) as HTMLInputElement;
|
||||
}
|
||||
|
||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
const toolbarDom = {
|
||||
audienceProfileByIdExportButton: root.querySelector(
|
||||
@ -219,9 +394,23 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
exportStatusText: root.querySelector(
|
||||
'[data-plugin-export-status="text"]'
|
||||
) as HTMLElement,
|
||||
spreadFilterFlowTypeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterOnlyAssignSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterRangeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="range"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadFilterTypeSelect: root.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement,
|
||||
spreadThresholdInputs: readSpreadThresholdInputs(root),
|
||||
root
|
||||
} satisfies PluginToolbarDom;
|
||||
syncCustomPagesInputVisibility(toolbarDom);
|
||||
syncSpreadFilterControlState(toolbarDom);
|
||||
return toolbarDom;
|
||||
}
|
||||
|
||||
@ -280,6 +469,51 @@ export function readToolbarExportTarget(
|
||||
};
|
||||
}
|
||||
|
||||
export function readToolbarSpreadFilter(
|
||||
toolbar: PluginToolbarDom
|
||||
): { error?: string; filter?: SpreadThresholdFilter } {
|
||||
const thresholds: SpreadThresholdFilter["thresholds"] = {};
|
||||
|
||||
for (const [key, input] of Object.entries(toolbar.spreadThresholdInputs)) {
|
||||
const trimmedValue = input.value.trim();
|
||||
if (!trimmedValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const numericValue = Number(trimmedValue);
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return {
|
||||
error: "请输入有效筛选阈值"
|
||||
};
|
||||
}
|
||||
|
||||
thresholds[key as keyof SpreadThresholdFilter["thresholds"]] = numericValue;
|
||||
}
|
||||
|
||||
if (Object.keys(thresholds).length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const type = Number(toolbar.spreadFilterTypeSelect.value) === 2 ? 2 : 1;
|
||||
return {
|
||||
filter: {
|
||||
config: {
|
||||
flowType:
|
||||
type === 1
|
||||
? 0
|
||||
: Number(toolbar.spreadFilterFlowTypeSelect.value) === 1
|
||||
? 1
|
||||
: 0,
|
||||
onlyAssign:
|
||||
type === 1 ? false : toolbar.spreadFilterOnlyAssignSelect.value === "true",
|
||||
range: Number(toolbar.spreadFilterRangeSelect.value) === 3 ? 3 : 2,
|
||||
type
|
||||
},
|
||||
thresholds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function setToolbarBusyState(
|
||||
toolbar: PluginToolbarDom,
|
||||
isBusy: boolean
|
||||
@ -291,10 +525,18 @@ export function setToolbarBusyState(
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput
|
||||
toolbar.exportCustomPagesInput,
|
||||
toolbar.spreadFilterTypeSelect,
|
||||
toolbar.spreadFilterOnlyAssignSelect,
|
||||
toolbar.spreadFilterFlowTypeSelect,
|
||||
toolbar.spreadFilterRangeSelect,
|
||||
...Object.values(toolbar.spreadThresholdInputs)
|
||||
].forEach((element) => {
|
||||
element.disabled = isBusy;
|
||||
});
|
||||
if (!isBusy) {
|
||||
syncSpreadFilterControlState(toolbar);
|
||||
}
|
||||
}
|
||||
|
||||
export function setToolbarExportStatus(
|
||||
@ -309,6 +551,16 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
||||
toolbar.exportCustomPagesInput.hidden = true;
|
||||
}
|
||||
|
||||
function syncSpreadFilterControlState(toolbar: PluginToolbarDom): void {
|
||||
const isPersonalVideo = toolbar.spreadFilterTypeSelect.value !== "2";
|
||||
if (isPersonalVideo) {
|
||||
toolbar.spreadFilterOnlyAssignSelect.value = "false";
|
||||
toolbar.spreadFilterFlowTypeSelect.value = "0";
|
||||
}
|
||||
toolbar.spreadFilterOnlyAssignSelect.disabled = isPersonalVideo;
|
||||
toolbar.spreadFilterFlowTypeSelect.disabled = isPersonalVideo;
|
||||
}
|
||||
|
||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||
const actionRow = findNativeActionRow(document);
|
||||
if (!actionRow) {
|
||||
@ -482,6 +734,57 @@ function applyToolbarRootStyles(root: HTMLElement): void {
|
||||
root.style.flexWrap = "wrap";
|
||||
}
|
||||
|
||||
function applyToolbarPanelStyles(panel: HTMLElement): void {
|
||||
panel.style.display = "flex";
|
||||
panel.style.flexDirection = "column";
|
||||
panel.style.gap = "8px";
|
||||
panel.style.minWidth = "0";
|
||||
panel.style.padding = "4px 0";
|
||||
}
|
||||
|
||||
function applyToolbarRowStyles(row: HTMLElement): void {
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "center";
|
||||
row.style.gap = "10px";
|
||||
row.style.minHeight = "32px";
|
||||
row.style.minWidth = "0";
|
||||
row.style.flexWrap = "wrap";
|
||||
}
|
||||
|
||||
function applyToolbarGroupStyles(group: HTMLElement): void {
|
||||
group.style.display = "flex";
|
||||
group.style.alignItems = "center";
|
||||
group.style.gap = "8px";
|
||||
group.style.minWidth = "0";
|
||||
group.style.flexWrap = "wrap";
|
||||
}
|
||||
|
||||
function createToolbarGroupTitle(document: Document, label: string): HTMLElement {
|
||||
const title = document.createElement("span");
|
||||
title.textContent = label;
|
||||
title.style.color = "#64748b";
|
||||
title.style.fontSize = "12px";
|
||||
title.style.fontWeight = "700";
|
||||
title.style.lineHeight = "32px";
|
||||
title.style.whiteSpace = "nowrap";
|
||||
return title;
|
||||
}
|
||||
|
||||
function applyToolbarDividerStyles(divider: HTMLElement): void {
|
||||
divider.style.width = "1px";
|
||||
divider.style.height = "24px";
|
||||
divider.style.background = "#e5e7eb";
|
||||
divider.style.flex = "0 0 auto";
|
||||
}
|
||||
|
||||
function applyToolbarActionStyles(actions: HTMLElement): void {
|
||||
actions.style.display = "flex";
|
||||
actions.style.alignItems = "center";
|
||||
actions.style.gap = "8px";
|
||||
actions.style.paddingLeft = "12px";
|
||||
actions.style.borderLeft = "1px solid #e5e7eb";
|
||||
}
|
||||
|
||||
function applyNativeControlStyles(
|
||||
document: Document,
|
||||
controls: {
|
||||
@ -492,7 +795,14 @@ function applyNativeControlStyles(
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
}
|
||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
||||
spreadFilterRangeSelect: HTMLSelectElement;
|
||||
spreadFilterTypeSelect: HTMLSelectElement;
|
||||
} & Record<
|
||||
keyof SpreadThresholdFilter["thresholds"],
|
||||
HTMLInputElement
|
||||
>
|
||||
): void {
|
||||
const primaryButton =
|
||||
findButtonContainingText(document, "发布任务") ??
|
||||
@ -519,7 +829,13 @@ function applyNativeControlStyles(
|
||||
button.style.whiteSpace = "nowrap";
|
||||
});
|
||||
|
||||
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
|
||||
const nativeControls = Array.from(Object.values(controls)).filter(
|
||||
(element): element is HTMLInputElement | HTMLSelectElement =>
|
||||
element instanceof document.defaultView!.HTMLInputElement ||
|
||||
element instanceof document.defaultView!.HTMLSelectElement
|
||||
);
|
||||
|
||||
nativeControls.forEach((element) => {
|
||||
element.style.height = "32px";
|
||||
element.style.border = "1px solid #d0d7de";
|
||||
element.style.borderRadius = "6px";
|
||||
@ -531,6 +847,25 @@ function applyNativeControlStyles(
|
||||
|
||||
controls.exportRangeSelect.style.minWidth = "104px";
|
||||
controls.exportCustomPagesInput.style.width = "72px";
|
||||
|
||||
[
|
||||
controls.spreadFilterTypeSelect,
|
||||
controls.spreadFilterOnlyAssignSelect,
|
||||
controls.spreadFilterFlowTypeSelect,
|
||||
controls.spreadFilterRangeSelect
|
||||
].forEach((select) => {
|
||||
select.style.minWidth = "92px";
|
||||
});
|
||||
|
||||
Object.values(controls).forEach((element) => {
|
||||
if (
|
||||
element instanceof document.defaultView!.HTMLInputElement &&
|
||||
element.dataset.pluginSpreadThreshold
|
||||
) {
|
||||
element.style.width =
|
||||
element.dataset.pluginSpreadThreshold === "playMedian" ? "112px" : "86px";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyPrimaryButtonStyles(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { SpreadInfoMetrics } from "./types";
|
||||
import type { SpreadInfoMetrics, SpreadMetricThresholds } from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
@ -29,7 +29,7 @@ interface SpreadInfoMetricDefinition {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MappedSpreadInfoResponse {
|
||||
export interface MappedSpreadInfoResponse {
|
||||
averageCommentCount?: string;
|
||||
averageDuration?: string;
|
||||
averageLikeCount?: string;
|
||||
@ -108,6 +108,12 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
}
|
||||
|
||||
return metrics;
|
||||
},
|
||||
async loadAuthorSpreadMetricSnapshot(
|
||||
authorId: string,
|
||||
config: SpreadInfoConfig
|
||||
): Promise<MappedSpreadInfoResponse> {
|
||||
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
|
||||
}
|
||||
};
|
||||
|
||||
@ -201,6 +207,21 @@ export function mapSpreadInfoResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesSpreadThresholds(
|
||||
metrics: MappedSpreadInfoResponse,
|
||||
thresholds: SpreadMetricThresholds
|
||||
): boolean {
|
||||
return Object.entries(thresholds).every(([key, threshold]) => {
|
||||
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metricValue = metrics[key as keyof SpreadMetricThresholds];
|
||||
const numericValue = readDisplayNumber(metricValue);
|
||||
return numericValue !== null && numericValue >= threshold;
|
||||
});
|
||||
}
|
||||
|
||||
function buildSpreadInfoColumnHeader(
|
||||
config: SpreadInfoConfig,
|
||||
metric: SpreadInfoMetricDefinition
|
||||
@ -278,6 +299,15 @@ function readNumberLike(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDisplayNumber(value: string | undefined): number | null {
|
||||
if (!hasTextValue(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number(value.replace(/[% ,]/g, ""));
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
function formatBasisPointPercent(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
|
||||
@ -14,6 +14,26 @@ export interface BackendMetrics {
|
||||
|
||||
export type SpreadInfoMetrics = Record<string, string>;
|
||||
|
||||
export interface SpreadMetricThresholds {
|
||||
averageCommentCount?: number;
|
||||
averageDuration?: number;
|
||||
averageLikeCount?: number;
|
||||
averageShareCount?: number;
|
||||
finishRate?: number;
|
||||
interactionRate?: number;
|
||||
playMedian?: number;
|
||||
}
|
||||
|
||||
export interface SpreadThresholdFilter {
|
||||
config: {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
};
|
||||
thresholds: SpreadMetricThresholds;
|
||||
}
|
||||
|
||||
export type MarketSortField =
|
||||
| keyof Required<AfterSearchRates>
|
||||
| keyof Required<BackendMetrics>;
|
||||
|
||||
@ -1172,6 +1172,48 @@ describe("market-content-entry", () => {
|
||||
expect(customPagesInput?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
test("toolbar exposes spread threshold filters and disables fixed personal-video controls", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
|
||||
const videoTypeSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="type"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const assignSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="onlyAssign"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const flowTypeSelect = document.querySelector(
|
||||
'[data-plugin-spread-filter="flowType"]'
|
||||
) as HTMLSelectElement | null;
|
||||
const finishRateInput = document.querySelector(
|
||||
'[data-plugin-spread-threshold="finishRate"]'
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
expect(videoTypeSelect?.value).toBe("1");
|
||||
expect(assignSelect?.value).toBe("false");
|
||||
expect(assignSelect?.disabled).toBe(true);
|
||||
expect(flowTypeSelect?.value).toBe("0");
|
||||
expect(flowTypeSelect?.disabled).toBe(true);
|
||||
expect(finishRateInput?.placeholder).toBe("完播率>=");
|
||||
|
||||
setSelectValue('[data-plugin-spread-filter="type"]', "2");
|
||||
dispatchChange('[data-plugin-spread-filter="type"]');
|
||||
|
||||
expect(assignSelect?.disabled).toBe(false);
|
||||
expect(flowTypeSelect?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("export uses the current page ordering without triggering a full scan", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const resultStore = createMarketResultStore();
|
||||
@ -1285,6 +1327,64 @@ describe("market-content-entry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("export keeps only records that match spread threshold filters", 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 loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%",
|
||||
interactionRate: "5%"
|
||||
}));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadFilterMetrics,
|
||||
onCsvReady: vi.fn(),
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
click('[data-plugin-export="button"]');
|
||||
await waitForMockCall(buildCsv, 80, 50);
|
||||
|
||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
});
|
||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||||
"a"
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
|
||||
async () => {
|
||||
@ -2226,6 +2326,64 @@ describe("market-content-entry", () => {
|
||||
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
||||
});
|
||||
|
||||
test("batch submit keeps only records that match spread threshold filters", 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 submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
|
||||
}));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
getAuthState: async () => ({
|
||||
isAuthenticated: true,
|
||||
resource: "https://talent-search.intelligrow.cn",
|
||||
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
||||
}),
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
loadSpreadFilterMetrics,
|
||||
promptBatchName: () => "筛选批次",
|
||||
submitBatch,
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
||||
dispatchChange('[data-plugin-export-range="select"]');
|
||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
||||
click('[data-plugin-batch-submit="button"]');
|
||||
await waitForMockCall(submitBatch, 80, 50);
|
||||
|
||||
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
|
||||
expect.objectContaining({
|
||||
authorId: "a"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
test("opens a custom batch name dialog before submitting", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
buildSpreadInfoUrl,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
matchesSpreadThresholds,
|
||||
mapSpreadInfoResponse
|
||||
} from "../src/content/market/spread-info";
|
||||
|
||||
@ -165,4 +166,49 @@ describe("spread-info", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("matches thresholds using display values and requires every filled threshold", () => {
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
averageDuration: "56",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
},
|
||||
{
|
||||
averageDuration: 50,
|
||||
finishRate: 28,
|
||||
interactionRate: 4,
|
||||
playMedian: 10000000
|
||||
}
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
averageDuration: "56",
|
||||
finishRate: "28.24%",
|
||||
interactionRate: "4.02%",
|
||||
playMedian: "10913233"
|
||||
},
|
||||
{
|
||||
averageDuration: 57
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
matchesSpreadThresholds(
|
||||
{
|
||||
finishRate: "28.24%"
|
||||
},
|
||||
{
|
||||
finishRate: 20,
|
||||
interactionRate: 1
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user