feat: filter exports by spread thresholds

This commit is contained in:
wxs
2026-06-29 16:11:52 +08:00
parent 121977fd0d
commit 9eb1fe43cc
8 changed files with 803 additions and 24 deletions
+68 -12
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 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<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 [];
+345 -10
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(
+32 -2
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;
+20
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>;