docs: plan independent spread metric filters

This commit is contained in:
wxs
2026-07-10 17:09:09 +08:00
parent 720f5983dc
commit 8eebb44551
@@ -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<SpreadFilterMetric, SpreadMetricRuleDom>;
```
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<MappedSpreadInfoResponse>;
```
- [ ] **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<string, {
config: SpreadInfoConfig;
rules: SpreadMetricFilterRule[];
}>();
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<string, MappedSpreadInfoResponse>();
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"
```