feat: make metric filters field-driven

This commit is contained in:
wxs
2026-07-27 17:58:10 +08:00
parent e03dedd91e
commit 53db9b0777
7 changed files with 783 additions and 1576 deletions
@@ -127,6 +127,20 @@ export function listAudienceProfileCsvHeaders(
];
}
export function buildAudienceProfileFieldValues(
row: AudienceProfileExportRow
): Record<string, string> {
const columns = deduplicateCsvColumns([
...buildMarketCsvColumns([row.record]).map(toMarketColumn),
...buildBusinessEstimateColumns(),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
]);
return Object.fromEntries(
columns.map((column) => [column.header, column.readValue(row)])
);
}
export function listAudienceProfileSelectableFieldGroups(
marketListHeaders: string[] = []
): AudienceProfileCsvFieldGroup[] {
+64 -63
View File
@@ -4,6 +4,7 @@ import {
listRateCsvHeaders
} from "./csv-exporter";
import {
buildAudienceProfileFieldValues,
buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups,
type AudienceProfileCsvOptions
@@ -52,16 +53,16 @@ import { applyFilterAndSort } from "./filter-sort-controller";
import { createMarketApiClient } from "./api-client";
import { createExportRangeController } from "./export-range-controller";
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
import {
listNumericMetricFilterDefinitions,
matchesMetricFilterRule
} from "./metric-filter";
import { createSilentExportController } from "./silent-export-controller";
import {
buildSpreadInfoConfigKey,
createSpreadInfoClient,
DEFAULT_SPREAD_INFO_CONFIGS,
filterUnloadedSpreadInfoConfigs,
matchesSpreadMetricRule,
normalizeSpreadInfoConfig,
selectSpreadInfoConfigsForHeaders,
type MappedSpreadInfoResponse
selectSpreadInfoConfigsForHeaders
} from "./spread-info";
import {
readToolbarExportTarget,
@@ -88,9 +89,8 @@ import type {
MarketRecord,
MarketRowSnapshot,
MarketSortState,
MetricThresholdFilter,
SpreadInfoConfig,
SpreadMetricFilterRule,
SpreadThresholdFilter
} from "./types";
interface MutationObserverLike {
@@ -127,10 +127,6 @@ export interface CreateMarketControllerOptions {
target: AudienceProfileRequestTarget
) => Promise<AudienceProfileResult>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
loadSpreadFilterMetrics?: (
spreadAuthorId: string,
config: SpreadInfoConfig
) => Promise<MappedSpreadInfoResponse>;
loadSpreadMetrics?: (
spreadAuthorId: string,
configs?: SpreadInfoConfig[]
@@ -169,9 +165,6 @@ 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 =
@@ -520,7 +513,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarBusyState(toolbar, true);
try {
const hasSelectedAuthors = selectedAuthorIds.size > 0;
const resolvedRecords = await applySpreadThresholdFilter(
const resolvedRecords = await applyMetricThresholdFilter(
await exportRecords(
exportTarget.target,
hasSelectedAuthors ? "提交已选达人中" : "提交中",
@@ -566,7 +559,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
}
};
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
toolbar = ensurePluginToolbar(
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
const ready = (async () => {
await runSyncCycle();
@@ -739,7 +736,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
function applyCurrentView(): void {
runWithoutMutationSync(() => {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
toolbar = ensurePluginToolbar(
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
const table = syncMarketTable(options.document);
if (!table) {
return;
@@ -1125,60 +1126,49 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return selectedRecords.length > 0 ? selectedRecords : records;
}
async function applySpreadThresholdFilter(
async function applyMetricThresholdFilter(
records: MarketRecord[],
filter: SpreadThresholdFilter | undefined
filter: MetricThresholdFilter | undefined
): Promise<MarketRecord[]> {
if (!filter || filter.rules.length === 0) {
return records;
}
const normalizedRules = filter.rules.map((rule) => ({
...rule,
config: normalizeSpreadInfoConfig(rule.config)
}));
const configsByKey = new Map<string, SpreadInfoConfig>();
normalizedRules.forEach((rule) => {
configsByKey.set(buildSpreadInfoConfigKey(rule.config), rule.config);
const filterSelection = resolveAudienceProfileExportSelection(
filter.rules.map((rule) => rule.field)
);
const hydratedRecords = await hydrateExportRecords(records, {
includeBackendMetrics: filterSelection.includeBackendMetrics,
includeRates: filterSelection.includeRates,
spreadInfoConfigs: filterSelection.spreadInfoConfigs
});
const matchedRecords: MarketRecord[] = [];
const matchedAuthorIds = new Set<string>();
await Promise.all(
records.map(async (record) => {
const spreadAuthorId = record.spreadAuthorId;
if (!spreadAuthorId) {
return;
}
for (let index = 0; index < hydratedRecords.length; index += 1) {
const record = hydratedRecords[index];
setToolbarExportStatus(
toolbar,
`指标筛选 ${index + 1}/${hydratedRecords.length}...`
);
const [profiles, businessAbility] = await Promise.all([
loadAudienceProfileSet(record, filterSelection),
filterSelection.businessAbility
? loadBusinessAbilitySafe(record)
: Promise.resolve(undefined)
]);
const values = buildAudienceProfileFieldValues(
finalizeAudienceProfileExportRow({
businessAbility,
profiles,
record
})
);
if (filter.rules.every((rule) => matchesMetricFilterRule(values, rule))) {
matchedRecords.push(record);
}
}
const snapshots = new Map<string, MappedSpreadInfoResponse>();
await Promise.all(
Array.from(configsByKey.entries()).map(async ([key, config]) => {
snapshots.set(
key,
await loadSpreadFilterMetrics(spreadAuthorId, config)
);
})
);
if (matchesAllSpreadMetricRules(normalizedRules, snapshots)) {
matchedAuthorIds.add(record.authorId);
}
})
);
return records.filter((record) => matchedAuthorIds.has(record.authorId));
}
function matchesAllSpreadMetricRules(
rules: SpreadMetricFilterRule[],
snapshots: Map<string, MappedSpreadInfoResponse>
): boolean {
return rules.every((rule) =>
matchesSpreadMetricRule(
snapshots.get(buildSpreadInfoConfigKey(rule.config)) ?? {},
rule
)
);
return matchedRecords;
}
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
@@ -1461,6 +1451,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
);
}
function readMetricFilterDefinitions() {
const marketListHeaders = buildBaseColumns(
readCurrentPageRecords(syncMarketTable(options.document))
).map((column) => column.header);
return listNumericMetricFilterDefinitions(marketListHeaders);
}
function readAudienceProfileSelectedHeaders(): string[] | undefined {
try {
const rawValue = options.window.localStorage?.getItem(
@@ -2049,7 +2046,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
async function runSingleSyncCycle(): Promise<void> {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
toolbar = ensurePluginToolbar(
options.document,
toolbarHandlers,
readMetricFilterDefinitions()
);
if (isPluginToolbarMounted(toolbar.root, options.document)) {
toolbarRemountScheduled = false;
}
+80
View File
@@ -0,0 +1,80 @@
import {
listAudienceProfileSelectableFieldGroups,
type AudienceProfileCsvFieldGroup
} from "./audience-profile-csv";
import type { MetricFilterRule } from "./types";
export interface MetricFilterDefinition {
field: string;
group: string;
}
const NUMERIC_MARKET_LIST_FIELDS = new Set([
"连接用户数",
"粉丝数",
"预期CPM",
"预期播放量",
"互动率",
"完播率",
"爆文率",
"21-60s报价"
]);
export function listNumericMetricFilterGroups(
marketListHeaders: string[] = []
): AudienceProfileCsvFieldGroup[] {
return listAudienceProfileSelectableFieldGroups(marketListHeaders)
.map((group) => ({
...group,
headers: group.headers.filter((header) =>
group.label === "列表字段"
? NUMERIC_MARKET_LIST_FIELDS.has(header)
: true
)
}))
.filter((group) => group.headers.length > 0);
}
export function listNumericMetricFilterDefinitions(
marketListHeaders: string[] = []
): MetricFilterDefinition[] {
return listNumericMetricFilterGroups(marketListHeaders).flatMap((group) =>
group.headers.map((field) => ({ field, group: group.label }))
);
}
export function matchesMetricFilterRule(
values: Record<string, string>,
rule: MetricFilterRule
): boolean {
const value = parseDisplayNumber(values[rule.field]);
if (value === null) {
return false;
}
return rule.operator === "gte"
? value >= rule.threshold
: value <= rule.threshold;
}
export function parseDisplayNumber(value: string | undefined): number | null {
if (!value) {
return null;
}
const normalized = value.trim().replace(/[\s,¥¥]/g, "");
if (!normalized || normalized === "缺失") {
return null;
}
const suffix = normalized.endsWith("w") || normalized.endsWith("万")
? 10_000
: normalized.endsWith("亿")
? 100_000_000
: 1;
const numericText = normalized
.replace(/[%w万亿]/g, "")
.replace(/^\+/, "");
const numericValue = Number(numericText);
return Number.isFinite(numericValue) ? numericValue * suffix : null;
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -33,6 +33,18 @@ export interface SpreadThresholdFilter {
rules: SpreadMetricFilterRule[];
}
export type MetricFilterOperator = "gte" | "lte";
export interface MetricFilterRule {
field: string;
operator: MetricFilterOperator;
threshold: number;
}
export interface MetricThresholdFilter {
rules: MetricFilterRule[];
}
export type MarketSortField =
| keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>;