diff --git a/docs/superpowers/plans/2026-06-29-author-spread-threshold-filter.md b/docs/superpowers/plans/2026-06-29-author-spread-threshold-filter.md new file mode 100644 index 0000000..b7a8b1a --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-author-spread-threshold-filter.md @@ -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`。 diff --git a/docs/superpowers/specs/2026-06-29-author-spread-threshold-filter-design.md b/docs/superpowers/specs/2026-06-29-author-spread-threshold-filter-design.md new file mode 100644 index 0000000..7287924 --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-author-spread-threshold-filter-design.md @@ -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 和提交批次都应用二次筛选。 diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 41d75ae..f0075be 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -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; loadAuthorMetrics?: (authorId: string) => Promise; + loadSpreadFilterMetrics?: ( + spreadAuthorId: string, + config: SpreadThresholdFilter["config"] + ) => Promise>; loadSpreadMetrics?: (spreadAuthorId: string) => Promise>; searchBackendMetrics?: (starIds: string[]) => Promise< Array @@ -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 { + if (!filter) { + return records; + } + + const matchedAuthorIds = new Set(); + 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 []; diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 32dc339..41ade2f 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -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; 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 { + 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 { + 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( diff --git a/src/content/market/spread-info.ts b/src/content/market/spread-info.ts index ddf6428..ac3fe92 100644 --- a/src/content/market/spread-info.ts +++ b/src/content/market/spread-info.ts @@ -1,4 +1,4 @@ -import type { SpreadInfoMetrics } from "./types"; +import type { SpreadInfoMetrics, SpreadMetricThresholds } from "./types"; interface FetchResponseLike { json(): Promise; @@ -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 { + 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; diff --git a/src/content/market/types.ts b/src/content/market/types.ts index 2a3dd76..789014d 100644 --- a/src/content/market/types.ts +++ b/src/content/market/types.ts @@ -14,6 +14,26 @@ export interface BackendMetrics { export type SpreadInfoMetrics = Record; +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 | keyof Required; diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 15430c2..9af6b6b 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -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 })); diff --git a/tests/spread-info.test.ts b/tests/spread-info.test.ts index 9c5e360..5b5627e 100644 --- a/tests/spread-info.test.ts +++ b/tests/spread-info.test.ts @@ -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); + }); });