feat: filter exports by spread thresholds

This commit is contained in:
wxs 2026-06-29 15:50:33 +08:00
parent 121977fd0d
commit 9eb1fe43cc
8 changed files with 803 additions and 24 deletions

View File

@ -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`

View File

@ -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 和提交批次都应用二次筛选。

View File

@ -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 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 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 [];

View File

@ -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(

View File

@ -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;

View File

@ -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>;

View File

@ -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 }));

View File

@ -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);
});
});