diff --git a/docs/superpowers/plans/2026-07-10-independent-spread-metric-filter.md b/docs/superpowers/plans/2026-07-10-independent-spread-metric-filter.md new file mode 100644 index 0000000..d98ae9a --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-independent-spread-metric-filter.md @@ -0,0 +1,665 @@ +# Independent Spread Metric Filter 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:** Replace the single shared spread-video configuration with independently configurable completion-rate and interaction-rate rules that are combined with AND semantics. + +**Architecture:** Represent each enabled metric as a `SpreadMetricFilterRule` containing its metric, threshold, and normalized `SpreadInfoConfig`. The toolbar owns metric selection and per-rule controls; the market controller groups rules by normalized config, loads one Xingtu snapshot per unique config and author, and evaluates every rule before export or batch submission. + +**Tech Stack:** TypeScript 6, Chrome MV3 content scripts, DOM APIs, Vitest 4 with JSDOM, tsup build scripts. + +--- + +## File Structure + +- Modify `src/content/market/types.ts`: own shared spread config and filter rule types. +- Modify `src/content/market/spread-info.ts`: normalize configs, build stable config keys, and compare one metric rule against a mapped snapshot. +- Modify `src/content/market/plugin-toolbar.ts`: render metric checkboxes and per-metric dynamic rule controls, enforce personal-video constraints, and read validated rule arrays. +- Modify `src/content/market/index.ts`: group rules by config, reuse snapshots, and apply AND filtering for export and batch submission. +- Modify `tests/spread-info.test.ts`: cover config normalization, grouping keys, and single-rule comparison. +- Modify `tests/market-content-entry.test.ts`: cover toolbar interaction, validation, request grouping, AND behavior, export, and batch submission. +- Modify `docs/项目流程说明文档.md`: replace the obsolete seven-threshold/global-config description with the implemented two-metric independent-rule flow. + +### Task 1: Shared Rule Model and Pure Spread Filter Logic + +**Files:** +- Modify: `src/content/market/types.ts:15-35` +- Modify: `src/content/market/spread-info.ts:1-25, 210-223` +- Test: `tests/spread-info.test.ts:1-214` + +- [ ] **Step 1: Write failing tests for normalized configs, stable keys, and metric-specific matching** + +Replace the old `matchesSpreadThresholds` test with focused tests and imports: + +```ts +import { + buildSpreadInfoConfigKey, + matchesSpreadMetricRule, + normalizeSpreadInfoConfig +} from "../src/content/market/spread-info"; + +test("normalizes fixed personal-video parameters before grouping", () => { + expect(normalizeSpreadInfoConfig({ + flowType: 1, + onlyAssign: true, + range: 3, + type: 1 + })).toEqual({ + flowType: 0, + onlyAssign: false, + range: 3, + type: 1 + }); +}); + +test("uses all normalized video dimensions in the config key", () => { + expect(buildSpreadInfoConfigKey({ + flowType: 1, + onlyAssign: true, + range: 3, + type: 1 + })).toBe(buildSpreadInfoConfigKey({ + flowType: 0, + onlyAssign: false, + range: 3, + type: 1 + })); + expect(buildSpreadInfoConfigKey({ + flowType: 0, + onlyAssign: false, + range: 2, + type: 2 + })).not.toBe(buildSpreadInfoConfigKey({ + flowType: 0, + onlyAssign: false, + range: 3, + type: 2 + })); +}); + +test("matches only the metric named by one filter rule", () => { + expect(matchesSpreadMetricRule( + { finishRate: "28.24%", interactionRate: "4.02%" }, + { + config: { flowType: 0, onlyAssign: false, range: 2, type: 1 }, + metric: "finishRate", + threshold: 28 + } + )).toBe(true); + expect(matchesSpreadMetricRule( + { finishRate: "28.24%" }, + { + config: { flowType: 0, onlyAssign: false, range: 2, type: 1 }, + metric: "interactionRate", + threshold: 1 + } + )).toBe(false); +}); +``` + +- [ ] **Step 2: Run the focused test and verify the new API is missing** + +Run: `npx vitest run tests/spread-info.test.ts` + +Expected: FAIL because `normalizeSpreadInfoConfig`, `buildSpreadInfoConfigKey`, and `matchesSpreadMetricRule` are not exported. + +- [ ] **Step 3: Replace the global threshold type with shared rule types** + +In `types.ts`, remove `SpreadMetricThresholds` and define: + +```ts +export interface SpreadInfoConfig { + flowType: 0 | 1; + onlyAssign: boolean; + range: 2 | 3; + type: 1 | 2; +} + +export type SpreadFilterMetric = "finishRate" | "interactionRate"; + +export interface SpreadMetricFilterRule { + config: SpreadInfoConfig; + metric: SpreadFilterMetric; + threshold: number; +} + +export interface SpreadThresholdFilter { + rules: SpreadMetricFilterRule[]; +} +``` + +Move `SpreadInfoConfig` ownership out of `spread-info.ts` and import it there from `types.ts`. + +- [ ] **Step 4: Implement pure normalization, keying, and comparison helpers** + +In `spread-info.ts`, replace `matchesSpreadThresholds` with: + +```ts +export function normalizeSpreadInfoConfig( + config: SpreadInfoConfig +): SpreadInfoConfig { + return config.type === 1 + ? { ...config, flowType: 0, onlyAssign: false } + : { ...config }; +} + +export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string { + const normalized = normalizeSpreadInfoConfig(config); + return [ + normalized.type, + normalized.onlyAssign ? 1 : 0, + normalized.flowType, + normalized.range + ].join(":"); +} + +export function matchesSpreadMetricRule( + metrics: MappedSpreadInfoResponse, + rule: SpreadMetricFilterRule +): boolean { + const numericValue = readDisplayNumber(metrics[rule.metric]); + return numericValue !== null && numericValue >= rule.threshold; +} +``` + +- [ ] **Step 5: Run the focused tests** + +Run: `npx vitest run tests/spread-info.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit the shared rule model** + +```bash +git add src/content/market/types.ts src/content/market/spread-info.ts tests/spread-info.test.ts +git commit -m "refactor: model independent spread metric rules" +``` + +### Task 2: Dynamic Per-Metric Toolbar Rules + +**Files:** +- Modify: `src/content/market/plugin-toolbar.ts:1-590, 810-1035` +- Test: `tests/market-content-entry.test.ts:390-480, 1285-1340` + +- [ ] **Step 1: Write failing DOM tests for the default and enabled states** + +Update the toolbar assertions to use metric checkboxes and scoped rule rows: + +```ts +const finishToggle = document.querySelector( + '[data-plugin-spread-metric="finishRate"]' +) as HTMLInputElement; +const interactionToggle = document.querySelector( + '[data-plugin-spread-metric="interactionRate"]' +) as HTMLInputElement; +const finishRule = document.querySelector( + '[data-plugin-spread-rule="finishRate"]' +) as HTMLElement; + +expect(finishToggle.checked).toBe(false); +expect(interactionToggle.checked).toBe(false); +expect(finishRule.hidden).toBe(true); + +finishToggle.checked = true; +finishToggle.dispatchEvent(new Event("change", { bubbles: true })); + +expect(finishRule.hidden).toBe(false); +expect(finishRule.querySelector('[data-plugin-spread-filter="type"]')).not.toBeNull(); +expect(finishRule.querySelector('[data-plugin-spread-threshold="finishRate"]')).not.toBeNull(); +``` + +Add reusable test helpers: + +```ts +function enableSpreadMetric(metric: "finishRate" | "interactionRate") { + const selector = `[data-plugin-spread-metric="${metric}"]`; + const input = document.querySelector(selector) as HTMLInputElement; + input.checked = true; + dispatchChange(selector); +} + +function setSpreadRuleSelect( + metric: "finishRate" | "interactionRate", + field: "type" | "onlyAssign" | "flowType" | "range", + value: string +) { + const selector = + `[data-plugin-spread-rule="${metric}"] [data-plugin-spread-filter="${field}"]`; + setSelectValue(selector, value); + dispatchChange(selector); +} +``` + +Then verify per-rule isolation: + +```ts +enableSpreadMetric("finishRate"); +enableSpreadMetric("interactionRate"); +setSpreadRuleSelect("finishRate", "type", "2"); +setSpreadRuleSelect("finishRate", "onlyAssign", "true"); +setSpreadRuleSelect("finishRate", "flowType", "1"); +setSpreadRuleSelect("interactionRate", "type", "2"); +setSpreadRuleSelect("interactionRate", "onlyAssign", "true"); +setSpreadRuleSelect("interactionRate", "flowType", "1"); +setSpreadRuleSelect("interactionRate", "type", "1"); + +const finishOnlyAssign = document.querySelector( + '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="onlyAssign"]' +) as HTMLSelectElement; +const interactionOnlyAssign = document.querySelector( + '[data-plugin-spread-rule="interactionRate"] [data-plugin-spread-filter="onlyAssign"]' +) as HTMLSelectElement; + +expect(finishOnlyAssign.value).toBe("true"); +expect(finishOnlyAssign.disabled).toBe(false); +expect(interactionOnlyAssign.value).toBe("false"); +expect(interactionOnlyAssign.disabled).toBe(true); +``` + +- [ ] **Step 2: Write failing validation tests for rule-array parsing** + +Exercise `readToolbarSpreadFilter` through export button integration. The validation path is synchronous before any export work starts: + +```ts +enableSpreadMetric("finishRate"); +click('[data-plugin-export="button"]'); +expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent +).toContain("完播率"); +expect(buildCsv).not.toHaveBeenCalled(); + +setInputValue( + '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-threshold="finishRate"]', + "30" +); +``` + +Import `ensurePluginToolbar` and `readToolbarSpreadFilter` in a focused test, then assert the empty and reset states directly: + +```ts +const { ensurePluginToolbar, readToolbarSpreadFilter } = await import( + "../src/content/market/plugin-toolbar" +); +const toolbar = ensurePluginToolbar(document, createNoopToolbarHandlers()); +expect(readToolbarSpreadFilter(toolbar)).toEqual({ filter: { rules: [] } }); + +enableSpreadMetric("finishRate"); +setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); +setSpreadRuleSelect("finishRate", "type", "2"); +const toggle = document.querySelector( + '[data-plugin-spread-metric="finishRate"]' +) as HTMLInputElement; +toggle.checked = false; +dispatchChange('[data-plugin-spread-metric="finishRate"]'); +toggle.checked = true; +dispatchChange('[data-plugin-spread-metric="finishRate"]'); + +expect( + (document.querySelector('[data-plugin-spread-threshold="finishRate"]') as HTMLInputElement).value +).toBe(""); +expectSelectValue( + '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="type"]', + "1" +); +expectSelectValue( + '[data-plugin-spread-rule="finishRate"] [data-plugin-spread-filter="range"]', + "2" +); +``` + +Define the no-op handlers next to the existing DOM test helpers: + +```ts +function createNoopToolbarHandlers() { + return { + onConfigureAudienceProfileFields: vi.fn(), + onExport: vi.fn(), + onExportAudienceProfile: vi.fn(), + onExportAudienceProfileByIds: vi.fn(), + onSubmitBatch: vi.fn() + }; +} +``` + +- [ ] **Step 3: Run the toolbar-focused test file and verify failure** + +Run: `npx vitest run tests/market-content-entry.test.ts -t "spread"` + +Expected: FAIL because metric checkboxes and per-rule controls do not exist. + +- [ ] **Step 4: Replace the global toolbar DOM contract with per-rule DOM objects** + +Define this internal shape in `plugin-toolbar.ts`: + +```ts +interface SpreadMetricRuleDom { + enabledInput: HTMLInputElement; + flowTypeSelect: HTMLSelectElement; + onlyAssignSelect: HTMLSelectElement; + rangeSelect: HTMLSelectElement; + root: HTMLElement; + thresholdInput: HTMLInputElement; + typeSelect: HTMLSelectElement; +} + +type SpreadMetricRuleDomMap = Record; +``` + +Replace the four global config selects and two loose threshold inputs in `PluginToolbarDom` with `spreadMetricRules: SpreadMetricRuleDomMap`. + +- [ ] **Step 5: Build metric selectors and hidden rule rows** + +Create one checkbox and one rule row for each fixed definition: + +```ts +const SPREAD_FILTER_DEFINITIONS = [ + { label: "完播率", metric: "finishRate" }, + { label: "互动率", metric: "interactionRate" } +] as const; +``` + +Use these stable selectors: + +```text +data-plugin-spread-metric="finishRate|interactionRate" +data-plugin-spread-rule="finishRate|interactionRate" +data-plugin-spread-filter="type|onlyAssign|flowType|range" +data-plugin-spread-threshold="finishRate|interactionRate" +``` + +Keep both rule rows in the DOM but set `hidden=true` until their checkboxes are enabled. Layout the selection controls before the rule container and remove the old global “视频口径” group. + +- [ ] **Step 6: Implement independent state synchronization and reset behavior** + +On each metric checkbox change: + +```ts +function syncSpreadMetricRuleState(rule: SpreadMetricRuleDom): void { + rule.root.hidden = !rule.enabledInput.checked; + if (!rule.enabledInput.checked) { + rule.thresholdInput.value = ""; + rule.typeSelect.value = "1"; + rule.onlyAssignSelect.value = "false"; + rule.flowTypeSelect.value = "0"; + rule.rangeSelect.value = "2"; + clearSpreadMetricRuleValidation(rule); + } + syncSpreadMetricVideoConstraints(rule); +} +``` + +`syncSpreadMetricVideoConstraints` must only touch the changed rule. For personal video it fixes and disables `onlyAssign` and `flowType`; for Xingtu video it enables them. + +- [ ] **Step 7: Parse selected controls into validated rule arrays** + +Rework `readToolbarSpreadFilter` to iterate in finish-rate then interaction-rate order: + +```ts +const rules: SpreadMetricFilterRule[] = []; +for (const metric of ["finishRate", "interactionRate"] as const) { + const ruleDom = toolbar.spreadMetricRules[metric]; + clearSpreadMetricRuleValidation(ruleDom); + if (!ruleDom.enabledInput.checked) continue; + + const threshold = Number(ruleDom.thresholdInput.value.trim()); + if (!ruleDom.thresholdInput.value.trim() || !Number.isFinite(threshold) || threshold < 0) { + markSpreadMetricRuleInvalid(ruleDom); + return { error: `请输入有效的${metric === "finishRate" ? "完播率" : "互动率"}筛选阈值` }; + } + + rules.push({ metric, threshold, config: readSpreadMetricConfig(ruleDom) }); +} +return { filter: { rules } }; +``` + +Update busy-state handling and native-control styling to walk every checkbox, threshold input, and select in `spreadMetricRules`. + +- [ ] **Step 8: Run toolbar-focused tests** + +Run: `npx vitest run tests/market-content-entry.test.ts -t "toolbar|spread"` + +Expected: PASS. + +- [ ] **Step 9: Commit the dynamic toolbar** + +```bash +git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts +git commit -m "feat: add independent spread metric controls" +``` + +### Task 3: Config-Deduplicated AND Filtering for Export and Batch Submission + +**Files:** +- Modify: `src/content/market/index.ts:30-95, 801-828` +- Test: `tests/market-content-entry.test.ts:1440-1496, 2436-2492` + +- [ ] **Step 1: Write a failing export test for different configs and AND semantics** + +Configure completion rate as Xingtu/assigned/30-day and interaction rate as personal/90-day. Mock snapshots by config: + +```ts +const loadSpreadFilterMetrics = vi.fn(async ( + spreadAuthorId: string, + config: SpreadInfoConfig +) => { + if (config.type === 2) { + return { finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%" }; + } + return { interactionRate: spreadAuthorId === "spread-a" ? "6%" : "4%" }; +}); +``` + +Configure the controls and assert the exact result and calls: + +```ts +enableSpreadMetric("finishRate"); +enableSpreadMetric("interactionRate"); +setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); +setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); +setSpreadRuleSelect("finishRate", "type", "2"); +setSpreadRuleSelect("finishRate", "onlyAssign", "true"); +setSpreadRuleSelect("interactionRate", "range", "3"); +click('[data-plugin-export="button"]'); +await waitForMockCall(buildCsv, 80, 50); + +expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual(["a"]); +expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { + flowType: 0, + onlyAssign: true, + range: 2, + type: 2 +}); +expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { + flowType: 0, + onlyAssign: false, + range: 3, + type: 1 +}); +``` + +- [ ] **Step 2: Write a failing export test for same-config request reuse** + +Configure both rules as personal/30-day, return both metrics in one snapshot, and assert: + +```ts +enableSpreadMetric("finishRate"); +enableSpreadMetric("interactionRate"); +setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); +setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); +click('[data-plugin-export="button"]'); +await waitForMockCall(buildCsv, 80, 50); + +expect(loadSpreadFilterMetrics).toHaveBeenCalledTimes(1); +expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", { + flowType: 0, + onlyAssign: false, + range: 2, + type: 1 +}); +``` + +- [ ] **Step 3: Update the batch-submit integration test to enable and apply a rule** + +Enable both rules before submitting and preserve the exact payload assertion: + +```ts +enableSpreadMetric("finishRate"); +enableSpreadMetric("interactionRate"); +setInputValue('[data-plugin-spread-threshold="finishRate"]', "30"); +setInputValue('[data-plugin-spread-threshold="interactionRate"]', "5"); +setSpreadRuleSelect("finishRate", "type", "2"); +setSpreadRuleSelect("interactionRate", "range", "3"); +click('[data-plugin-batch-submit="button"]'); +await waitForMockCall(submitBatch, 80, 50); + +expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([ + expect.objectContaining({ authorId: "a" }) +]); +``` + +- [ ] **Step 4: Run the new integration tests and verify failure** + +Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"` + +Expected: FAIL because the controller still reads one global config and threshold object. + +- [ ] **Step 5: Update the controller dependency type** + +Change `loadSpreadFilterMetrics` to accept `SpreadInfoConfig` and return `MappedSpreadInfoResponse`: + +```ts +loadSpreadFilterMetrics?: ( + spreadAuthorId: string, + config: SpreadInfoConfig +) => Promise; +``` + +- [ ] **Step 6: Group normalized rules and reuse each snapshot** + +Replace the one-request filter body with config groups: + +```ts +const normalizedRules = filter.rules.map((rule) => ({ + ...rule, + config: normalizeSpreadInfoConfig(rule.config) +})); +const groups = new Map(); + +for (const rule of normalizedRules) { + const key = buildSpreadInfoConfigKey(rule.config); + const group = groups.get(key) ?? { config: rule.config, rules: [] }; + group.rules.push(rule); + groups.set(key, group); +} +``` + +For each record, load all unique groups, catch a group failure as an empty snapshot, and preserve the existing per-author isolation: + +```ts +const snapshots = new Map(); +await Promise.all(Array.from(groups.entries()).map(async ([key, group]) => { + try { + snapshots.set(key, await loadSpreadFilterMetrics(spreadAuthorId, group.config)); + } catch { + snapshots.set(key, {}); + } +})); + +const matchesAll = normalizedRules.every((rule) => + matchesSpreadMetricRule( + snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {}, + rule + ) +); +``` + +Return records unchanged when `filter` is missing or `filter.rules.length === 0`. + +- [ ] **Step 7: Run focused integration tests** + +Run: `npx vitest run tests/market-content-entry.test.ts -t "spread threshold|independent spread|reuses"` + +Expected: PASS. + +- [ ] **Step 8: Run all market content-entry tests** + +Run: `npx vitest run tests/market-content-entry.test.ts` + +Expected: PASS. + +- [ ] **Step 9: Commit config-deduplicated filtering** + +```bash +git add src/content/market/index.ts tests/market-content-entry.test.ts +git commit -m "feat: filter spread metrics by independent configs" +``` + +### Task 4: Process Documentation and Full Verification + +**Files:** +- Modify: `docs/项目流程说明文档.md:251-269, 323, 363, 426, 490, 509, 593` + +- [ ] **Step 1: Update the maintained process description** + +Rewrite section `3.8 传播指标阈值筛选` to state: + +```markdown +- 输入内容:可选的完播率、互动率规则;每条已选规则包含非负阈值和独立的视频类别、是否只看指派、是否排除营销流量、时间范围。 +- 处理规则: + - 默认不选择任何指标,不启用二次筛选; + - 每个已选指标必须填写阈值,并使用自己的视频口径请求传播指标; + - 同时选择完播率和互动率时,两条规则必须全部满足; + - 两条规则口径相同时,同一达人复用一次接口响应;口径不同时分别请求; + - 个人视频固定为不限指派、不排除营销流量; + - 请求失败、缺少 ID 或缺少已选指标的达人视为不满足筛选。 +``` + +Update the interface table's concurrency note to mention unique-config reuse, and change the configuration table row from one generic “传播指标阈值” to “传播指标规则”,defaulting to no selected metric. + +- [ ] **Step 2: Run documentation consistency checks** + +Run: `rg -n "七个指标阈值|没有填写任何阈值|平均时长按秒|播放量、评论、点赞、转发" docs/项目流程说明文档.md` + +Expected: no output. + +Run: `rg -n "每个已选指标|口径相同|传播指标规则" docs/项目流程说明文档.md` + +Expected: matches in section 3.8, the `get_author_spread_info` interface row, and the configuration table. + +- [ ] **Step 3: Run focused feature tests** + +Run: `npx vitest run tests/spread-info.test.ts tests/market-content-entry.test.ts` + +Expected: PASS. + +- [ ] **Step 4: Run the full test suite** + +Run: `npm test` + +Expected: PASS with no failed test files. + +- [ ] **Step 5: Build the extension** + +Run: `npm run build` + +Expected: exit code `0` and refreshed development bundle in `dist/`. + +- [ ] **Step 6: Review the final diff and workspace scope** + +Run: `git diff --check` + +Expected: no whitespace errors. + +Run: `git status --short` + +Expected: only the planned source, test, and process-document files are modified; `.superpowers/` remains untracked and must not be staged. + +- [ ] **Step 7: Commit documentation and verification-complete state** + +```bash +git add docs/项目流程说明文档.md +git commit -m "docs: update independent spread filter flow" +```