feat: make audience export fields selective
This commit is contained in:
@@ -26,6 +26,10 @@ export interface AudienceProfileFailure {
|
||||
status: "failed";
|
||||
}
|
||||
|
||||
export interface AudienceProfileSkipped {
|
||||
status: "skipped";
|
||||
}
|
||||
|
||||
export interface AudienceProfileSet {
|
||||
audience: AudienceProfileResult;
|
||||
fans: AudienceProfileResult;
|
||||
@@ -34,7 +38,8 @@ export interface AudienceProfileSet {
|
||||
|
||||
export type AudienceProfileResult =
|
||||
| AudienceProfileSuccess
|
||||
| AudienceProfileFailure;
|
||||
| AudienceProfileFailure
|
||||
| AudienceProfileSkipped;
|
||||
|
||||
export interface AudienceProfileExportRow {
|
||||
businessAbility?: BusinessAbilityResult;
|
||||
|
||||
@@ -8,6 +8,10 @@ export type CsvColumn = {
|
||||
readValue: (record: MarketRecord) => string;
|
||||
};
|
||||
|
||||
export interface MarketCsvOptions {
|
||||
selectedHeaders?: string[];
|
||||
}
|
||||
|
||||
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
|
||||
{
|
||||
header: "达人ID",
|
||||
@@ -80,6 +84,13 @@ const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) =
|
||||
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
|
||||
}));
|
||||
|
||||
const FIXED_MARKET_CSV_HEADERS = new Set([
|
||||
"达人ID",
|
||||
"达人名称",
|
||||
"导出状态",
|
||||
"失败原因"
|
||||
]);
|
||||
|
||||
export function listRateCsvHeaders(): string[] {
|
||||
return RATE_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
@@ -88,8 +99,20 @@ export function listBackendMetricCsvHeaders(): string[] {
|
||||
return BACKEND_METRIC_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
|
||||
export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
const csvColumns = buildMarketCsvColumns(records);
|
||||
export function buildMarketCsv(
|
||||
records: MarketRecord[],
|
||||
options: MarketCsvOptions = {}
|
||||
): string {
|
||||
const optionalColumns = buildMarketCsvColumns(records);
|
||||
const csvColumns = filterMarketCsvColumns(
|
||||
options.selectedHeaders === undefined
|
||||
? optionalColumns
|
||||
: deduplicateCsvColumns([
|
||||
...buildFixedMarketCsvColumns(),
|
||||
...optionalColumns
|
||||
]),
|
||||
options.selectedHeaders
|
||||
);
|
||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||
const rowLines = records.map((record) =>
|
||||
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
|
||||
@@ -133,3 +156,68 @@ export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
|
||||
}));
|
||||
}
|
||||
|
||||
function filterMarketCsvColumns(
|
||||
columns: CsvColumn[],
|
||||
selectedHeaders: string[] | undefined
|
||||
): CsvColumn[] {
|
||||
if (!selectedHeaders) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const selectedHeaderSet = new Set(selectedHeaders);
|
||||
return columns.filter(
|
||||
(column) =>
|
||||
FIXED_MARKET_CSV_HEADERS.has(column.header) ||
|
||||
selectedHeaderSet.has(column.header)
|
||||
);
|
||||
}
|
||||
|
||||
function buildFixedMarketCsvColumns(): CsvColumn[] {
|
||||
return [
|
||||
{
|
||||
header: "达人ID",
|
||||
readValue: (record) => record.exportFields?.达人ID ?? record.authorId
|
||||
},
|
||||
{
|
||||
header: "达人名称",
|
||||
readValue: (record) => record.exportFields?.达人名称 ?? record.authorName
|
||||
},
|
||||
{
|
||||
header: "导出状态",
|
||||
readValue: (record) => record.exportFields?.导出状态 ?? readExportStatus(record)
|
||||
},
|
||||
{
|
||||
header: "失败原因",
|
||||
readValue: (record) => record.exportFields?.失败原因 ?? record.failureReason ?? ""
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function readExportStatus(record: MarketRecord): string {
|
||||
if (record.status === "failed") {
|
||||
return "失败";
|
||||
}
|
||||
|
||||
if (record.status === "loading") {
|
||||
return "加载中";
|
||||
}
|
||||
|
||||
if (record.status === "idle") {
|
||||
return "待处理";
|
||||
}
|
||||
|
||||
return "成功";
|
||||
}
|
||||
|
||||
function deduplicateCsvColumns(columns: CsvColumn[]): CsvColumn[] {
|
||||
const seenHeaders = new Set<string>();
|
||||
return columns.filter((column) => {
|
||||
if (seenHeaders.has(column.header)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenHeaders.add(column.header);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
+217
-138
@@ -1,4 +1,8 @@
|
||||
import { buildBaseColumns, buildMarketCsv } from "./csv-exporter";
|
||||
import {
|
||||
buildBaseColumns,
|
||||
listBackendMetricCsvHeaders,
|
||||
listRateCsvHeaders
|
||||
} from "./csv-exporter";
|
||||
import {
|
||||
buildAudienceProfileCsv,
|
||||
listAudienceProfileSelectableFieldGroups,
|
||||
@@ -52,8 +56,11 @@ import { createSilentExportController } from "./silent-export-controller";
|
||||
import {
|
||||
buildSpreadInfoConfigKey,
|
||||
createSpreadInfoClient,
|
||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
||||
filterUnloadedSpreadInfoConfigs,
|
||||
matchesSpreadMetricRule,
|
||||
normalizeSpreadInfoConfig,
|
||||
selectSpreadInfoConfigsForHeaders,
|
||||
type MappedSpreadInfoResponse
|
||||
} from "./spread-info";
|
||||
import {
|
||||
@@ -91,12 +98,21 @@ interface MutationObserverLike {
|
||||
observe(target: Node, options?: MutationObserverInit): void;
|
||||
}
|
||||
|
||||
interface AudienceProfileExportSelection {
|
||||
audience: boolean;
|
||||
businessAbility: boolean;
|
||||
fans: boolean;
|
||||
includeBackendMetrics: boolean;
|
||||
includeRates: boolean;
|
||||
longtimeFans: boolean;
|
||||
spreadInfoConfigs: SpreadInfoConfig[];
|
||||
}
|
||||
|
||||
export interface CreateMarketControllerOptions {
|
||||
buildAudienceProfileCsv?: (
|
||||
rows: AudienceProfileExportRow[],
|
||||
options?: AudienceProfileCsvOptions
|
||||
) => string;
|
||||
buildCsv?: (records: MarketRecord[]) => string;
|
||||
confirmFavoriteDelete?: (message: string) => boolean;
|
||||
confirmFavoriteImport?: (message: string) => boolean;
|
||||
document: Document;
|
||||
@@ -115,7 +131,10 @@ export interface CreateMarketControllerOptions {
|
||||
spreadAuthorId: string,
|
||||
config: SpreadInfoConfig
|
||||
) => Promise<MappedSpreadInfoResponse>;
|
||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
||||
loadSpreadMetrics?: (
|
||||
spreadAuthorId: string,
|
||||
configs?: SpreadInfoConfig[]
|
||||
) => Promise<Record<string, string>>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
>;
|
||||
@@ -158,7 +177,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const searchBackendMetrics =
|
||||
options.searchBackendMetrics ??
|
||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
||||
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
|
||||
const loadAudienceProfile =
|
||||
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
|
||||
@@ -319,41 +337,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
startObserving();
|
||||
|
||||
const toolbarHandlers = {
|
||||
onExport: async () => {
|
||||
syncSelectionStateFromDom();
|
||||
const exportTarget = readToolbarExportTarget(toolbar);
|
||||
if (!exportTarget.target) {
|
||||
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 applySpreadThresholdFilter(
|
||||
await exportRecords(exportTarget.target, "导出中", {
|
||||
includeSpreadMetrics: true,
|
||||
showDetailedProgress: selectedAuthorIds.size === 0
|
||||
}),
|
||||
spreadFilter.filter
|
||||
)
|
||||
);
|
||||
options.onCsvReady?.(buildCsv(records));
|
||||
setToolbarExportStatus(toolbar, "");
|
||||
} catch (error) {
|
||||
setToolbarExportStatus(
|
||||
toolbar,
|
||||
error instanceof Error ? error.message : "导出失败,请稍后重试"
|
||||
);
|
||||
} finally {
|
||||
setToolbarBusyState(toolbar, false);
|
||||
}
|
||||
},
|
||||
onExportAudienceProfile: async () => {
|
||||
syncSelectionStateFromDom();
|
||||
if (selectedAuthorIds.size === 0) {
|
||||
@@ -369,10 +352,16 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
setToolbarBusyState(toolbar, true);
|
||||
try {
|
||||
const selectedHeaders = readAudienceProfileSelectedHeaders();
|
||||
const exportSelection = resolveAudienceProfileExportSelection(
|
||||
selectedHeaders
|
||||
);
|
||||
const selectedRecords = filterRecordsBySelectionStrict(
|
||||
await exportRecords(exportTarget.target, "画像导出中", {
|
||||
includeSpreadMetrics: true,
|
||||
showDetailedProgress: false
|
||||
includeBackendMetrics: exportSelection.includeBackendMetrics,
|
||||
includeRates: exportSelection.includeRates,
|
||||
showDetailedProgress: false,
|
||||
spreadInfoConfigs: exportSelection.spreadInfoConfigs
|
||||
})
|
||||
);
|
||||
if (selectedRecords.length === 0) {
|
||||
@@ -388,28 +377,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
`画像导出中 ${index + 1}/${selectedRecords.length}...`
|
||||
);
|
||||
const [profiles, businessAbility] = await Promise.all([
|
||||
loadAudienceProfileSet(record),
|
||||
loadBusinessAbilitySafe(record)
|
||||
loadAudienceProfileSet(record, exportSelection),
|
||||
exportSelection.businessAbility
|
||||
? loadBusinessAbilitySafe(record)
|
||||
: Promise.resolve(undefined)
|
||||
]);
|
||||
rows.push({
|
||||
rows.push(finalizeAudienceProfileExportRow({
|
||||
businessAbility,
|
||||
profiles,
|
||||
record
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
rows.every((row) =>
|
||||
Object.values(row.profiles).every((profile) => profile.status === "failed")
|
||||
)
|
||||
) {
|
||||
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
|
||||
return;
|
||||
}));
|
||||
}
|
||||
|
||||
options.onCsvReady?.(
|
||||
buildAudienceCsv(rows, {
|
||||
selectedHeaders: readAudienceProfileSelectedHeaders()
|
||||
selectedHeaders
|
||||
}),
|
||||
buildAudienceProfileFilename()
|
||||
);
|
||||
@@ -442,7 +424,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
`识别 ${parsed.ids.length + parsed.duplicates.length + parsed.invalidTokens.length} 个,去重后 ${parsed.ids.length} 个,非法 ${parsed.invalidTokens.length} 个`
|
||||
);
|
||||
|
||||
const backendMetricsByAuthorId = await loadBackendMetricsMap(parsed.ids);
|
||||
const selectedHeaders = readAudienceProfileSelectedHeaders();
|
||||
const exportSelection = resolveAudienceProfileExportSelection(
|
||||
selectedHeaders
|
||||
);
|
||||
const backendMetricsByAuthorId = exportSelection.includeBackendMetrics
|
||||
? await loadBackendMetricsMap(parsed.ids)
|
||||
: new Map<string, BackendMetrics>();
|
||||
const rows: AudienceProfileExportRow[] = [];
|
||||
for (let index = 0; index < parsed.ids.length; index += 1) {
|
||||
const authorId = parsed.ids[index];
|
||||
@@ -453,14 +441,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
rows.push(
|
||||
await loadAudienceProfileRowById(
|
||||
authorId,
|
||||
backendMetricsByAuthorId.get(authorId)
|
||||
backendMetricsByAuthorId.get(authorId),
|
||||
exportSelection
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
options.onCsvReady?.(
|
||||
buildAudienceCsv(rows, {
|
||||
selectedHeaders: readAudienceProfileSelectedHeaders()
|
||||
selectedHeaders
|
||||
}),
|
||||
buildAudienceProfileFilename(new Date(), "按ID导出")
|
||||
);
|
||||
@@ -1050,8 +1039,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
target: MarketExportTarget,
|
||||
inProgressLabel = "导出中",
|
||||
progressOptions: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
includeBackendMetrics?: boolean;
|
||||
includeRates?: boolean;
|
||||
showDetailedProgress?: boolean;
|
||||
spreadInfoConfigs?: SpreadInfoConfig[];
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
activeProgressLabel = inProgressLabel;
|
||||
@@ -1061,7 +1052,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
if (target.mode === "count" && target.pageCount <= 1) {
|
||||
await prepareCurrentPageForExport();
|
||||
return hydrateExportRecords(getVisibleOrderedRecords(), {
|
||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
||||
includeBackendMetrics: progressOptions.includeBackendMetrics,
|
||||
includeRates: progressOptions.includeRates,
|
||||
spreadInfoConfigs: progressOptions.spreadInfoConfigs
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1073,7 +1066,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
status: record.status ?? "idle"
|
||||
})),
|
||||
{
|
||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
||||
includeBackendMetrics: progressOptions.includeBackendMetrics,
|
||||
includeRates: progressOptions.includeRates,
|
||||
spreadInfoConfigs: progressOptions.spreadInfoConfigs
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1137,14 +1132,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const snapshots = new Map<string, MappedSpreadInfoResponse>();
|
||||
await Promise.all(
|
||||
Array.from(configsByKey.entries()).map(async ([key, config]) => {
|
||||
try {
|
||||
snapshots.set(
|
||||
key,
|
||||
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
||||
);
|
||||
} catch {
|
||||
snapshots.set(key, {});
|
||||
}
|
||||
snapshots.set(
|
||||
key,
|
||||
await loadSpreadFilterMetrics(spreadAuthorId, config)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1178,11 +1169,17 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
|
||||
async function loadAudienceProfileSet(
|
||||
record: MarketRecord
|
||||
record: MarketRecord,
|
||||
exportSelection: AudienceProfileExportSelection
|
||||
): Promise<AudienceProfileExportRow["profiles"]> {
|
||||
const profiles = {} as AudienceProfileExportRow["profiles"];
|
||||
|
||||
for (const { kind, target } of audienceProfileTargets) {
|
||||
if (!exportSelection[kind]) {
|
||||
profiles[kind] = { status: "skipped" };
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
profiles[kind] = await loadAudienceProfile(record, target);
|
||||
} catch (error) {
|
||||
@@ -1213,54 +1210,45 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
async function loadAudienceProfileRowById(
|
||||
authorId: string,
|
||||
backendMetrics?: BackendMetrics
|
||||
backendMetrics: BackendMetrics | undefined,
|
||||
exportSelection: AudienceProfileExportSelection
|
||||
): Promise<AudienceProfileExportRow> {
|
||||
const [baseRecord, metricsResult] = await Promise.all([
|
||||
loadAuthorBaseInfoSafe(authorId),
|
||||
loadAuthorMetricsSafe(authorId)
|
||||
exportSelection.includeRates
|
||||
? loadAuthorMetricsSafe(authorId)
|
||||
: Promise.resolve(undefined)
|
||||
]);
|
||||
const spreadMetrics = baseRecord.spreadAuthorId
|
||||
? await loadSpreadMetrics(baseRecord.spreadAuthorId)
|
||||
: {};
|
||||
const spreadMetrics =
|
||||
exportSelection.spreadInfoConfigs.length > 0 && baseRecord.spreadAuthorId
|
||||
? await loadSpreadMetrics(
|
||||
baseRecord.spreadAuthorId,
|
||||
exportSelection.spreadInfoConfigs
|
||||
)
|
||||
: {};
|
||||
const recordForRequests = {
|
||||
...baseRecord,
|
||||
authorName: baseRecord.authorName || authorId,
|
||||
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
|
||||
...(metricsResult?.success ? { rates: metricsResult.rates } : {}),
|
||||
...(backendMetrics
|
||||
? { backendMetrics, backendMetricsStatus: "success" as const }
|
||||
: {}),
|
||||
...(Object.keys(spreadMetrics).length > 0 ? { spreadMetrics } : {})
|
||||
};
|
||||
const [profiles, businessAbility] = await Promise.all([
|
||||
loadAudienceProfileSet(recordForRequests),
|
||||
loadBusinessAbilitySafe(recordForRequests)
|
||||
loadAudienceProfileSet(recordForRequests, exportSelection),
|
||||
exportSelection.businessAbility
|
||||
? loadBusinessAbilitySafe(recordForRequests)
|
||||
: Promise.resolve(undefined)
|
||||
]);
|
||||
const failureReasons = collectAudienceProfileRowFailures(
|
||||
baseRecord,
|
||||
profiles,
|
||||
businessAbility
|
||||
return finalizeAudienceProfileExportRow(
|
||||
{
|
||||
businessAbility,
|
||||
profiles,
|
||||
record: recordForRequests
|
||||
},
|
||||
{ includeBaseRecordFailure: true }
|
||||
);
|
||||
const rowStatus =
|
||||
failureReasons.length === 0
|
||||
? "成功"
|
||||
: hasAudienceProfileRowSuccess(baseRecord, profiles, businessAbility)
|
||||
? "部分成功"
|
||||
: "失败";
|
||||
const authorName = baseRecord.authorName || "";
|
||||
|
||||
return {
|
||||
businessAbility,
|
||||
profiles,
|
||||
record: {
|
||||
...recordForRequests,
|
||||
exportFields: {
|
||||
达人ID: authorId,
|
||||
达人名称: authorName,
|
||||
导出状态: rowStatus,
|
||||
失败原因: failureReasons.join("; ")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAuthorBaseInfoSafe(authorId: string): Promise<MarketRecord> {
|
||||
@@ -1314,10 +1302,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
function collectAudienceProfileRowFailures(
|
||||
baseRecord: MarketRecord,
|
||||
profiles: AudienceProfileExportRow["profiles"],
|
||||
businessAbility: BusinessAbilityResult
|
||||
businessAbility: BusinessAbilityResult | undefined,
|
||||
includeBaseRecordFailure: boolean
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
if (baseRecord.status === "failed") {
|
||||
if (includeBaseRecordFailure && baseRecord.status === "failed") {
|
||||
failures.push(`基础信息:${baseRecord.failureReason ?? "request-failed"}`);
|
||||
}
|
||||
|
||||
@@ -1327,8 +1316,8 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
});
|
||||
|
||||
if (businessAbility.status === "failed") {
|
||||
failures.push(`商业能力:${businessAbility.failureReason ?? "request-failed"}`);
|
||||
if (businessAbility?.status === "failed") {
|
||||
failures.push(`效果预估:${businessAbility.failureReason ?? "request-failed"}`);
|
||||
}
|
||||
|
||||
return failures;
|
||||
@@ -1337,15 +1326,55 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
function hasAudienceProfileRowSuccess(
|
||||
baseRecord: MarketRecord,
|
||||
profiles: AudienceProfileExportRow["profiles"],
|
||||
businessAbility: BusinessAbilityResult
|
||||
businessAbility: BusinessAbilityResult | undefined,
|
||||
includeBaseRecordFailure: boolean
|
||||
): boolean {
|
||||
return (
|
||||
!includeBaseRecordFailure ||
|
||||
baseRecord.status === "success" ||
|
||||
businessAbility.status === "success" ||
|
||||
businessAbility?.status === "success" ||
|
||||
Object.values(profiles).some((profile) => profile.status === "success")
|
||||
);
|
||||
}
|
||||
|
||||
function finalizeAudienceProfileExportRow(
|
||||
row: AudienceProfileExportRow,
|
||||
options: { includeBaseRecordFailure?: boolean } = {}
|
||||
): AudienceProfileExportRow {
|
||||
const includeBaseRecordFailure = options.includeBaseRecordFailure ?? false;
|
||||
const failureReasons = collectAudienceProfileRowFailures(
|
||||
row.record,
|
||||
row.profiles,
|
||||
row.businessAbility,
|
||||
includeBaseRecordFailure
|
||||
);
|
||||
const rowStatus =
|
||||
failureReasons.length === 0
|
||||
? "成功"
|
||||
: hasAudienceProfileRowSuccess(
|
||||
row.record,
|
||||
row.profiles,
|
||||
row.businessAbility,
|
||||
includeBaseRecordFailure
|
||||
)
|
||||
? "部分成功"
|
||||
: "失败";
|
||||
|
||||
return {
|
||||
...row,
|
||||
record: {
|
||||
...row.record,
|
||||
exportFields: {
|
||||
...row.record.exportFields,
|
||||
达人ID: row.record.authorId,
|
||||
达人名称: row.record.authorName,
|
||||
导出状态: rowStatus,
|
||||
失败原因: failureReasons.join("; ")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readAudienceProfileKindLabel(kind: AudienceProfileKind): string {
|
||||
if (kind === "audience") {
|
||||
return "观众画像";
|
||||
@@ -1358,6 +1387,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
return "铁粉画像";
|
||||
}
|
||||
|
||||
function resolveAudienceProfileExportSelection(
|
||||
selectedHeaders: string[] | undefined
|
||||
): AudienceProfileExportSelection {
|
||||
if (selectedHeaders === undefined) {
|
||||
return {
|
||||
audience: true,
|
||||
businessAbility: true,
|
||||
fans: true,
|
||||
includeBackendMetrics: true,
|
||||
includeRates: true,
|
||||
longtimeFans: true,
|
||||
spreadInfoConfigs: DEFAULT_SPREAD_INFO_CONFIGS
|
||||
};
|
||||
}
|
||||
|
||||
const selectedHeaderSet = new Set(selectedHeaders);
|
||||
const includesAny = (headers: string[]) =>
|
||||
headers.some((header) => selectedHeaderSet.has(header));
|
||||
const includesPrefix = (prefix: string) =>
|
||||
selectedHeaders.some((header) => header.startsWith(prefix));
|
||||
|
||||
return {
|
||||
audience: includesPrefix("观众画像-"),
|
||||
businessAbility: includesPrefix("效果预估-"),
|
||||
fans: includesPrefix("粉丝画像-"),
|
||||
includeBackendMetrics: includesAny(listBackendMetricCsvHeaders()),
|
||||
includeRates: includesAny(listRateCsvHeaders()),
|
||||
longtimeFans: includesPrefix("铁粉画像-"),
|
||||
spreadInfoConfigs: selectSpreadInfoConfigsForHeaders(selectedHeaders)
|
||||
};
|
||||
}
|
||||
|
||||
function readAudienceProfileSelectableHeaders(marketListHeaders: string[] = []): string[] {
|
||||
return listAudienceProfileSelectableFieldGroups(marketListHeaders).flatMap(
|
||||
(group) => group.headers
|
||||
@@ -1408,39 +1469,44 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
async function hydrateExportRecords(
|
||||
records: MarketRecord[],
|
||||
options: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
includeBackendMetrics?: boolean;
|
||||
includeRates?: boolean;
|
||||
spreadInfoConfigs?: SpreadInfoConfig[];
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
for (const record of records) {
|
||||
resultStore.upsertMarketRow(record);
|
||||
const existingRecord = resultStore.getRecord(record.authorId);
|
||||
if (existingRecord?.status === "success" && existingRecord.rates) {
|
||||
continue;
|
||||
}
|
||||
records.forEach((record) => resultStore.upsertMarketRow(record));
|
||||
|
||||
if (record.hasDirectRatesSource) {
|
||||
const directRates = record.rates ?? {};
|
||||
const hasAllRates =
|
||||
Boolean(directRates.singleVideoAfterSearchRate) &&
|
||||
Boolean(directRates.personalVideoAfterSearchRate);
|
||||
|
||||
resultStore.setAuthorSuccess(record.authorId, directRates);
|
||||
if (hasAllRates) {
|
||||
if (options.includeRates ?? true) {
|
||||
for (const record of records) {
|
||||
const existingRecord = resultStore.getRecord(record.authorId);
|
||||
if (existingRecord?.status === "success" && existingRecord.rates) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
resultStore.setAuthorLoading(record.authorId);
|
||||
}
|
||||
|
||||
const metricsResult = await loadAuthorMetrics(record.authorId);
|
||||
if (metricsResult.success) {
|
||||
resultStore.setAuthorSuccess(record.authorId, metricsResult.rates);
|
||||
} else {
|
||||
resultStore.setAuthorFailed(record.authorId, metricsResult.reason);
|
||||
if (record.hasDirectRatesSource) {
|
||||
const directRates = record.rates ?? {};
|
||||
const hasAllRates =
|
||||
Boolean(directRates.singleVideoAfterSearchRate) &&
|
||||
Boolean(directRates.personalVideoAfterSearchRate);
|
||||
|
||||
resultStore.setAuthorSuccess(record.authorId, directRates);
|
||||
if (hasAllRates) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
resultStore.setAuthorLoading(record.authorId);
|
||||
}
|
||||
|
||||
const metricsResult = await loadAuthorMetrics(record.authorId);
|
||||
if (metricsResult.success) {
|
||||
resultStore.setAuthorSuccess(record.authorId, metricsResult.rates);
|
||||
} else {
|
||||
resultStore.setAuthorFailed(record.authorId, metricsResult.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchBackendMetrics) {
|
||||
if ((options.includeBackendMetrics ?? true) && searchBackendMetrics) {
|
||||
const backendTargetRecords = records.filter((record) => {
|
||||
const existingRecord = resultStore.getRecord(record.authorId);
|
||||
return !(
|
||||
@@ -1476,25 +1542,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeSpreadMetrics) {
|
||||
await hydrateSpreadMetricsForRecords(records);
|
||||
if (options.spreadInfoConfigs?.length) {
|
||||
await hydrateSpreadMetricsForRecords(records, options.spreadInfoConfigs);
|
||||
}
|
||||
|
||||
return records.map((record) => toMarketRecord(record));
|
||||
}
|
||||
|
||||
async function hydrateSpreadMetricsForRecords(
|
||||
records: MarketRecord[]
|
||||
records: MarketRecord[],
|
||||
selectedConfigs: SpreadInfoConfig[]
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
records.map(async (record) => {
|
||||
const storedRecord = resultStore.getRecord(record.authorId) ?? record;
|
||||
const spreadAuthorId = storedRecord.spreadAuthorId ?? record.spreadAuthorId;
|
||||
if (!spreadAuthorId || storedRecord.spreadMetrics) {
|
||||
if (!spreadAuthorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spreadMetrics = await loadSpreadMetrics(spreadAuthorId);
|
||||
const existingMetrics = storedRecord.spreadMetrics ?? {};
|
||||
const missingConfigs = filterUnloadedSpreadInfoConfigs(
|
||||
selectedConfigs,
|
||||
existingMetrics
|
||||
);
|
||||
if (missingConfigs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spreadMetrics = await loadSpreadMetrics(
|
||||
spreadAuthorId,
|
||||
missingConfigs
|
||||
);
|
||||
if (Object.keys(spreadMetrics).length > 0) {
|
||||
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
export interface PluginToolbarHandlers {
|
||||
onExport(): Promise<void> | void;
|
||||
onExportAudienceProfile(): Promise<void> | void;
|
||||
onExportAudienceProfileByIds(): Promise<void> | void;
|
||||
onConfigureAudienceProfileFields(): Promise<void> | void;
|
||||
@@ -65,7 +64,6 @@ export interface PluginToolbarDom {
|
||||
audienceProfileExportButton: HTMLButtonElement;
|
||||
audienceProfileFieldButton: HTMLButtonElement;
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
exportStatusText: HTMLElement;
|
||||
@@ -110,7 +108,7 @@ export function ensurePluginToolbar(
|
||||
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||
) &&
|
||||
existingRoot.querySelector(
|
||||
'[data-plugin-toolbar-action-group="csv-export"]'
|
||||
'[data-plugin-toolbar-action-group="selected-audience-export"]'
|
||||
) &&
|
||||
existingRoot.querySelector('[data-plugin-spread-metric-catalog-trigger="button"]')
|
||||
) {
|
||||
@@ -145,12 +143,6 @@ export function ensurePluginToolbar(
|
||||
exportCustomPagesInput.placeholder = "页数";
|
||||
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
|
||||
|
||||
const exportButton = document.createElement("button");
|
||||
exportButton.type = "button";
|
||||
exportButton.dataset.pluginExport = "button";
|
||||
exportButton.textContent = "导出CSV";
|
||||
exportButton.title = "按所选页数范围导出当前筛选结果";
|
||||
|
||||
const audienceProfileExportButton = document.createElement("button");
|
||||
audienceProfileExportButton.type = "button";
|
||||
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
||||
@@ -204,15 +196,15 @@ export function ensurePluginToolbar(
|
||||
const dataGroup = document.createElement("div");
|
||||
dataGroup.dataset.pluginToolbarGroup = "data";
|
||||
applyToolbarGroupStyles(dataGroup);
|
||||
const csvExportGroup = createToolbarActionGroup(document, "csv-export", [
|
||||
exportButton,
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput
|
||||
]);
|
||||
const selectedAudienceExportGroup = createToolbarActionGroup(
|
||||
document,
|
||||
"selected-audience-export",
|
||||
[audienceProfileExportButton, audienceProfileFieldButton]
|
||||
[
|
||||
audienceProfileExportButton,
|
||||
audienceProfileFieldButton,
|
||||
exportRangeSelect,
|
||||
exportCustomPagesInput
|
||||
]
|
||||
);
|
||||
const idExportGroup = createToolbarActionGroup(document, "id-export", [
|
||||
audienceProfileByIdExportButton
|
||||
@@ -221,8 +213,6 @@ export function ensurePluginToolbar(
|
||||
batchSubmitButton
|
||||
]);
|
||||
dataGroup.append(
|
||||
csvExportGroup,
|
||||
createToolbarActionDivider(document),
|
||||
selectedAudienceExportGroup,
|
||||
createToolbarActionDivider(document),
|
||||
idExportGroup,
|
||||
@@ -253,16 +243,12 @@ export function ensurePluginToolbar(
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
spreadMetricRules
|
||||
});
|
||||
ensureToolbarMounted(root, document);
|
||||
|
||||
exportButton.addEventListener("click", () => {
|
||||
void handlers.onExport();
|
||||
});
|
||||
audienceProfileExportButton.addEventListener("click", () => {
|
||||
void handlers.onExportAudienceProfile();
|
||||
});
|
||||
@@ -281,7 +267,6 @@ export function ensurePluginToolbar(
|
||||
audienceProfileByIdExportButton,
|
||||
audienceProfileFieldButton,
|
||||
batchSubmitButton,
|
||||
exportButton,
|
||||
exportCustomPagesInput,
|
||||
exportRangeSelect,
|
||||
exportStatusText,
|
||||
@@ -352,7 +337,6 @@ function createToolbarActionGroup(
|
||||
document: Document,
|
||||
groupName:
|
||||
| "batch-submit"
|
||||
| "csv-export"
|
||||
| "id-export"
|
||||
| "selected-audience-export",
|
||||
controls: HTMLElement[]
|
||||
@@ -660,9 +644,6 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
batchSubmitButton: root.querySelector(
|
||||
'[data-plugin-batch-submit="button"]'
|
||||
) as HTMLButtonElement,
|
||||
exportButton: root.querySelector(
|
||||
'[data-plugin-export="button"]'
|
||||
) as HTMLButtonElement,
|
||||
exportCustomPagesInput: root.querySelector(
|
||||
'[data-plugin-export-custom-pages="input"]'
|
||||
) as HTMLInputElement,
|
||||
@@ -780,7 +761,6 @@ export function setToolbarBusyState(
|
||||
toolbar.audienceProfileFieldButton,
|
||||
toolbar.audienceProfileByIdExportButton,
|
||||
toolbar.audienceProfileExportButton,
|
||||
toolbar.exportButton,
|
||||
toolbar.exportRangeSelect,
|
||||
toolbar.exportCustomPagesInput,
|
||||
toolbar.metricCatalog.addButton,
|
||||
@@ -1362,7 +1342,6 @@ function applyNativeControlStyles(
|
||||
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||
audienceProfileFieldButton: HTMLButtonElement;
|
||||
batchSubmitButton: HTMLButtonElement;
|
||||
exportButton: HTMLButtonElement;
|
||||
exportCustomPagesInput: HTMLInputElement;
|
||||
exportRangeSelect: HTMLSelectElement;
|
||||
spreadMetricRules: SpreadMetricRuleDomMap;
|
||||
@@ -1377,7 +1356,6 @@ function applyNativeControlStyles(
|
||||
findNativeActionButton(document, "导出");
|
||||
|
||||
if (nativeButton) {
|
||||
controls.exportButton.className = nativeButton.className;
|
||||
controls.audienceProfileExportButton.className = nativeButton.className;
|
||||
controls.audienceProfileByIdExportButton.className = nativeButton.className;
|
||||
controls.audienceProfileFieldButton.className = nativeButton.className;
|
||||
@@ -1385,7 +1363,6 @@ function applyNativeControlStyles(
|
||||
}
|
||||
|
||||
const secondaryButtons = [
|
||||
controls.exportButton,
|
||||
controls.audienceProfileExportButton,
|
||||
controls.audienceProfileByIdExportButton,
|
||||
controls.audienceProfileFieldButton
|
||||
@@ -1515,7 +1492,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
||||
const style = document.createElement("style");
|
||||
style.id = PLUGIN_ACTION_BUTTON_STYLE_ID;
|
||||
style.textContent = `
|
||||
[data-plugin-export="button"]:hover:not(:disabled),
|
||||
[data-plugin-export-audience-profile="button"]:hover:not(:disabled),
|
||||
[data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled),
|
||||
[data-plugin-audience-profile-fields="button"]:hover:not(:disabled) {
|
||||
@@ -1528,7 +1504,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
||||
border-color: #6d1627 !important;
|
||||
}
|
||||
|
||||
[data-plugin-export="button"]:active:not(:disabled),
|
||||
[data-plugin-export-audience-profile="button"]:active:not(:disabled),
|
||||
[data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled),
|
||||
[data-plugin-audience-profile-fields="button"]:active:not(:disabled) {
|
||||
@@ -1543,7 +1518,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
[data-plugin-export="button"]:focus-visible,
|
||||
[data-plugin-export-audience-profile="button"]:focus-visible,
|
||||
[data-plugin-export-audience-profile-by-id="button"]:focus-visible,
|
||||
[data-plugin-audience-profile-fields="button"]:focus-visible {
|
||||
@@ -1556,7 +1530,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
||||
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important;
|
||||
}
|
||||
|
||||
[data-plugin-export="button"]:disabled,
|
||||
[data-plugin-export-audience-profile="button"]:disabled,
|
||||
[data-plugin-export-audience-profile-by-id="button"]:disabled,
|
||||
[data-plugin-audience-profile-fields="button"]:disabled {
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
@@ -90,10 +92,13 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
|
||||
async loadAuthorSpreadMetrics(
|
||||
authorId: string,
|
||||
selectedConfigs: SpreadInfoConfig[] = configs
|
||||
): Promise<SpreadInfoMetrics> {
|
||||
const metrics: SpreadInfoMetrics = {};
|
||||
|
||||
for (const config of configs) {
|
||||
for (const config of selectedConfigs) {
|
||||
const mappedResponse = await loadSpreadInfoFromUrl(
|
||||
buildSpreadInfoUrl(authorId, config, baseUrl)
|
||||
);
|
||||
@@ -127,12 +132,22 @@ export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
throw new Error(
|
||||
`星图传播数据接口请求失败: HTTP ${response.status ?? "unknown"}${
|
||||
response.statusText ? `: ${response.statusText}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return mapSpreadInfoResponse(await response.json());
|
||||
} catch {
|
||||
return {};
|
||||
const payload = await response.json();
|
||||
const rateLimitMessage = readRateLimitMessage(payload);
|
||||
if (rateLimitMessage) {
|
||||
throw new Error(
|
||||
`星图传播数据接口被限流 (status_code=31157): ${rateLimitMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
return mapSpreadInfoResponse(payload);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
@@ -163,6 +178,30 @@ export function buildSpreadInfoColumns(
|
||||
);
|
||||
}
|
||||
|
||||
export function selectSpreadInfoConfigsForHeaders(
|
||||
selectedHeaders: readonly string[],
|
||||
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
|
||||
): SpreadInfoConfig[] {
|
||||
const selectedHeaderSet = new Set(selectedHeaders);
|
||||
|
||||
return configs.filter((config) =>
|
||||
buildSpreadInfoColumns([config]).some((header) =>
|
||||
selectedHeaderSet.has(header)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterUnloadedSpreadInfoConfigs(
|
||||
configs: SpreadInfoConfig[],
|
||||
existingMetrics: SpreadInfoMetrics
|
||||
): SpreadInfoConfig[] {
|
||||
return configs.filter((config) =>
|
||||
buildSpreadInfoColumns([config]).every(
|
||||
(header) => !(header in existingMetrics)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSpreadInfoMetricMap(
|
||||
config: SpreadInfoConfig,
|
||||
metrics: MappedSpreadInfoResponse
|
||||
@@ -278,6 +317,26 @@ function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
||||
return isRecord(payload.data) ? payload.data : payload;
|
||||
}
|
||||
|
||||
function readRateLimitMessage(payload: unknown): string | null {
|
||||
const payloadRecord = isRecord(payload) ? payload : null;
|
||||
const dataRecord = payloadRecord && isRecord(payloadRecord.data)
|
||||
? payloadRecord.data
|
||||
: null;
|
||||
const baseResponse =
|
||||
(payloadRecord && isRecord(payloadRecord.base_resp) && payloadRecord.base_resp) ||
|
||||
(dataRecord && isRecord(dataRecord.base_resp) && dataRecord.base_resp);
|
||||
|
||||
if (!baseResponse || readNumberLike(baseResponse.status_code) !== 31157) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
readStringLike(baseResponse.status_message) ??
|
||||
readStringLike(baseResponse.message) ??
|
||||
"您访问过于频繁,请稍后重试"
|
||||
);
|
||||
}
|
||||
|
||||
function readNestedValue(value: unknown, key: string): unknown {
|
||||
return isRecord(value) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user