feat: export author spread metrics
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
||||
import { escapeCsvCell } from "../../shared/csv";
|
||||
import { buildSpreadInfoColumns } from "./spread-info";
|
||||
import type { MarketRecord } from "./types";
|
||||
|
||||
export type CsvColumn = {
|
||||
@@ -74,6 +75,11 @@ const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
||||
}
|
||||
];
|
||||
|
||||
const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) => ({
|
||||
header,
|
||||
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
|
||||
}));
|
||||
|
||||
export function listRateCsvHeaders(): string[] {
|
||||
return RATE_COLUMNS.map((column) => column.header);
|
||||
}
|
||||
@@ -94,7 +100,12 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
||||
|
||||
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
const baseColumns = buildBaseColumns(records);
|
||||
return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
||||
return [
|
||||
...baseColumns,
|
||||
...RATE_COLUMNS,
|
||||
...BACKEND_METRIC_COLUMNS,
|
||||
...SPREAD_INFO_COLUMNS
|
||||
];
|
||||
}
|
||||
|
||||
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||
|
||||
@@ -76,6 +76,7 @@ type MarketDataRow = {
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
};
|
||||
|
||||
export interface MarketRowDom {
|
||||
@@ -88,6 +89,7 @@ export interface MarketRowDom {
|
||||
personalCell: HTMLElement;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
row: HTMLElement;
|
||||
selectionCheckbox: HTMLInputElement;
|
||||
singleCell: HTMLElement;
|
||||
@@ -679,6 +681,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
||||
row: authorCell,
|
||||
selectionCheckbox,
|
||||
singleCell,
|
||||
spreadAuthorId: fallbackMarketRow?.spreadAuthorId,
|
||||
visibilityTargets: rowCells
|
||||
} satisfies MarketRowDom
|
||||
];
|
||||
@@ -1351,7 +1354,8 @@ function readSerializedMarketRows(
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
spreadAuthorId: readString(record.spreadAuthorId) ?? undefined
|
||||
};
|
||||
})
|
||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||
@@ -1673,7 +1677,11 @@ function mergeMarketDataRows(
|
||||
baseRow.price21To60s,
|
||||
preferredRow.price21To60s
|
||||
),
|
||||
rates: mergeRates(baseRow.rates, preferredRow.rates)
|
||||
rates: mergeRates(baseRow.rates, preferredRow.rates),
|
||||
spreadAuthorId: mergeNonEmptyString(
|
||||
baseRow.spreadAuthorId,
|
||||
preferredRow.spreadAuthorId
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ 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 {
|
||||
readToolbarExportTarget,
|
||||
setToolbarBusyState,
|
||||
@@ -79,6 +80,7 @@ export interface CreateMarketControllerOptions {
|
||||
target: AudienceProfileRequestTarget
|
||||
) => Promise<AudienceProfileResult>;
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||
Array<BackendMetrics & { starId: string }>
|
||||
>;
|
||||
@@ -101,10 +103,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const audienceProfileClient = createAudienceProfileClient();
|
||||
const authorBaseClient = createAuthorBaseClient();
|
||||
const businessAbilityClient = createBusinessAbilityClient();
|
||||
const spreadInfoClient = createSpreadInfoClient();
|
||||
const sendRuntimeMessage = createRuntimeMessageSender();
|
||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||
const loadAuthorMetrics =
|
||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||
const loadSpreadMetrics =
|
||||
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
||||
const searchBackendMetrics =
|
||||
options.searchBackendMetrics ??
|
||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||
@@ -211,6 +216,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
try {
|
||||
const records = filterRecordsBySelection(
|
||||
await exportRecords(exportTarget.target, "导出中", {
|
||||
includeSpreadMetrics: true,
|
||||
showDetailedProgress: selectedAuthorIds.size === 0
|
||||
})
|
||||
);
|
||||
@@ -707,6 +713,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
target: MarketExportTarget,
|
||||
inProgressLabel = "导出中",
|
||||
progressOptions: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
showDetailedProgress?: boolean;
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
@@ -716,7 +723,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
|
||||
if (target.mode === "count" && target.pageCount <= 1) {
|
||||
await prepareCurrentPageForExport();
|
||||
return getVisibleOrderedRecords();
|
||||
return hydrateExportRecords(getVisibleOrderedRecords(), {
|
||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
||||
});
|
||||
}
|
||||
|
||||
const silentExportRecords = await silentExportController.exportRecords(target);
|
||||
@@ -725,7 +734,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
silentExportRecords.map((record) => ({
|
||||
...record,
|
||||
status: record.status ?? "idle"
|
||||
}))
|
||||
})),
|
||||
{
|
||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1000,7 +1012,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
await runSyncCycle();
|
||||
}
|
||||
|
||||
async function hydrateExportRecords(records: MarketRecord[]): Promise<MarketRecord[]> {
|
||||
async function hydrateExportRecords(
|
||||
records: MarketRecord[],
|
||||
options: {
|
||||
includeSpreadMetrics?: boolean;
|
||||
} = {}
|
||||
): Promise<MarketRecord[]> {
|
||||
for (const record of records) {
|
||||
resultStore.upsertMarketRow(record);
|
||||
const existingRecord = resultStore.getRecord(record.authorId);
|
||||
@@ -1066,9 +1083,32 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeSpreadMetrics) {
|
||||
await hydrateSpreadMetricsForRecords(records);
|
||||
}
|
||||
|
||||
return records.map((record) => toMarketRecord(record));
|
||||
}
|
||||
|
||||
async function hydrateSpreadMetricsForRecords(
|
||||
records: MarketRecord[]
|
||||
): 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spreadMetrics = await loadSpreadMetrics(spreadAuthorId);
|
||||
if (Object.keys(spreadMetrics).length > 0) {
|
||||
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function harvestCurrentPageForExport(): Promise<void> {
|
||||
let hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
|
||||
if (
|
||||
@@ -1148,6 +1188,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
existingRecord?.price21To60s,
|
||||
rowSnapshot.price21To60s
|
||||
);
|
||||
const spreadAuthorId = mergeStringValue(
|
||||
existingRecord?.spreadAuthorId,
|
||||
rowSnapshot.spreadAuthorId
|
||||
);
|
||||
return {
|
||||
...existingRecord,
|
||||
...rowSnapshot,
|
||||
@@ -1169,6 +1213,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
location,
|
||||
price21To60s,
|
||||
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
||||
spreadAuthorId,
|
||||
spreadMetrics: mergeFieldMap(
|
||||
existingRecord?.spreadMetrics,
|
||||
rowSnapshot.spreadMetrics
|
||||
),
|
||||
status: existingRecord?.status ?? "idle"
|
||||
} satisfies MarketRecord;
|
||||
}
|
||||
@@ -1477,7 +1526,8 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
||||
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
||||
location: rowDom.location,
|
||||
price21To60s: rowDom.price21To60s,
|
||||
rates: rowDom.rates
|
||||
rates: rowDom.rates,
|
||||
spreadAuthorId: rowDom.spreadAuthorId
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ export function mapMarketListRow(
|
||||
? {
|
||||
singleVideoAfterSearchRate
|
||||
}
|
||||
: undefined
|
||||
: undefined,
|
||||
spreadAuthorId:
|
||||
readString(readMarketFieldValue(row, attributeDatas, "id")) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,8 @@ function readSerializedMarketRows() {
|
||||
authorName:
|
||||
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
|
||||
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
|
||||
singleVideoAfterSearchRate
|
||||
singleVideoAfterSearchRate,
|
||||
spreadAuthorId: readString(attributeDatas.id) ?? undefined
|
||||
};
|
||||
})
|
||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
BackendMetrics,
|
||||
MarketApiFailureReason,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot
|
||||
MarketRowSnapshot,
|
||||
SpreadInfoMetrics
|
||||
} from "./types";
|
||||
import type { AfterSearchRates } from "./types";
|
||||
|
||||
@@ -46,6 +47,13 @@ export function createMarketResultStore() {
|
||||
...backendMetrics
|
||||
};
|
||||
},
|
||||
setSpreadMetricsSuccess(authorId: string, spreadMetrics: SpreadInfoMetrics) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.spreadMetrics = {
|
||||
...existingRecord.spreadMetrics,
|
||||
...spreadMetrics
|
||||
};
|
||||
},
|
||||
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
|
||||
const existingRecord = ensureRecord(authorId);
|
||||
existingRecord.status = "success";
|
||||
@@ -73,6 +81,10 @@ export function createMarketResultStore() {
|
||||
existingRecord.price21To60s,
|
||||
row.price21To60s
|
||||
);
|
||||
existingRecord.spreadAuthorId = mergeStringValue(
|
||||
existingRecord.spreadAuthorId,
|
||||
row.spreadAuthorId
|
||||
);
|
||||
existingRecord.exportFields = mergeFieldMap(
|
||||
existingRecord.exportFields,
|
||||
row.exportFields
|
||||
@@ -81,6 +93,10 @@ export function createMarketResultStore() {
|
||||
existingRecord.backendMetrics,
|
||||
row.backendMetrics
|
||||
);
|
||||
existingRecord.spreadMetrics = mergeFieldMap(
|
||||
existingRecord.spreadMetrics,
|
||||
row.spreadMetrics
|
||||
);
|
||||
existingRecord.hasDirectRatesSource =
|
||||
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
||||
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { SpreadInfoMetrics } from "./types";
|
||||
|
||||
interface FetchResponseLike {
|
||||
json(): Promise<unknown>;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit
|
||||
) => Promise<FetchResponseLike>;
|
||||
|
||||
export interface SpreadInfoConfig {
|
||||
flowType: 0 | 1;
|
||||
onlyAssign: boolean;
|
||||
range: 2 | 3;
|
||||
type: 1 | 2;
|
||||
}
|
||||
|
||||
interface SpreadInfoClientOptions {
|
||||
baseUrl?: string;
|
||||
configs?: SpreadInfoConfig[];
|
||||
fetchImpl?: FetchLike;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface SpreadInfoMetricDefinition {
|
||||
key: keyof MappedSpreadInfoResponse;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MappedSpreadInfoResponse {
|
||||
averageCommentCount?: string;
|
||||
averageDuration?: string;
|
||||
averageLikeCount?: string;
|
||||
averageShareCount?: string;
|
||||
finishRate?: string;
|
||||
interactionRate?: string;
|
||||
playMedian?: string;
|
||||
}
|
||||
|
||||
const SPREAD_INFO_METRICS: SpreadInfoMetricDefinition[] = [
|
||||
{
|
||||
key: "finishRate",
|
||||
label: "完播率"
|
||||
},
|
||||
{
|
||||
key: "playMedian",
|
||||
label: "播放量中位数"
|
||||
},
|
||||
{
|
||||
key: "interactionRate",
|
||||
label: "互动率"
|
||||
},
|
||||
{
|
||||
key: "averageDuration",
|
||||
label: "作品平均时长"
|
||||
},
|
||||
{
|
||||
key: "averageCommentCount",
|
||||
label: "作品平均评论数"
|
||||
},
|
||||
{
|
||||
key: "averageLikeCount",
|
||||
label: "作品平均点赞数"
|
||||
},
|
||||
{
|
||||
key: "averageShareCount",
|
||||
label: "作品平均转发数"
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_SPREAD_INFO_CONFIGS: SpreadInfoConfig[] = [
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 2,
|
||||
type: 1
|
||||
},
|
||||
{
|
||||
flowType: 0,
|
||||
onlyAssign: false,
|
||||
range: 3,
|
||||
type: 1
|
||||
},
|
||||
...buildXingtuVideoConfigs()
|
||||
];
|
||||
|
||||
export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||
const configs = options.configs ?? DEFAULT_SPREAD_INFO_CONFIGS;
|
||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||
const timeoutMs = options.timeoutMs ?? 8000;
|
||||
|
||||
return {
|
||||
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
|
||||
const metrics: SpreadInfoMetrics = {};
|
||||
|
||||
for (const config of configs) {
|
||||
const mappedResponse = await loadSpreadInfoFromUrl(
|
||||
buildSpreadInfoUrl(authorId, config, baseUrl)
|
||||
);
|
||||
Object.entries(buildSpreadInfoMetricMap(config, mappedResponse)).forEach(
|
||||
([header, value]) => {
|
||||
metrics[header] = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
};
|
||||
|
||||
async function loadSpreadInfoFromUrl(
|
||||
url: string
|
||||
): Promise<MappedSpreadInfoResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
credentials: "include",
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return mapSpreadInfoResponse(await response.json());
|
||||
} catch {
|
||||
return {};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpreadInfoUrl(
|
||||
authorId: string,
|
||||
config: SpreadInfoConfig,
|
||||
baseUrl: string
|
||||
): string {
|
||||
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
||||
url.searchParams.set("o_author_id", authorId);
|
||||
url.searchParams.set("platform_source", "1");
|
||||
url.searchParams.set("platform_channel", "1");
|
||||
url.searchParams.set("type", String(config.type));
|
||||
url.searchParams.set("flow_type", String(config.flowType));
|
||||
url.searchParams.set("only_assign", String(config.onlyAssign));
|
||||
url.searchParams.set("range", String(config.range));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildSpreadInfoColumns(
|
||||
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
|
||||
): string[] {
|
||||
return configs.flatMap((config) =>
|
||||
SPREAD_INFO_METRICS.map((metric) => buildSpreadInfoColumnHeader(config, metric))
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSpreadInfoMetricMap(
|
||||
config: SpreadInfoConfig,
|
||||
metrics: MappedSpreadInfoResponse
|
||||
): SpreadInfoMetrics {
|
||||
const values: SpreadInfoMetrics = {};
|
||||
|
||||
SPREAD_INFO_METRICS.forEach((metric) => {
|
||||
const value = metrics[metric.key];
|
||||
if (hasTextValue(value)) {
|
||||
values[buildSpreadInfoColumnHeader(config, metric)] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export function mapSpreadInfoResponse(
|
||||
payload: unknown
|
||||
): MappedSpreadInfoResponse {
|
||||
const data = getPayloadData(payload);
|
||||
if (!data) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
averageCommentCount: readStringLike(data.comment_avg),
|
||||
averageDuration: formatMillisecondsAsSeconds(readNumberLike(data.avg_duration)),
|
||||
averageLikeCount: readStringLike(data.like_avg),
|
||||
averageShareCount: readStringLike(data.share_avg),
|
||||
finishRate: formatBasisPointPercent(
|
||||
readNumberLike(readNestedValue(data.play_over_rate, "value"))
|
||||
),
|
||||
interactionRate: formatBasisPointPercent(
|
||||
readNumberLike(readNestedValue(data.interact_rate, "value"))
|
||||
),
|
||||
playMedian:
|
||||
readStringLike(data.play_mid) ??
|
||||
readStringLike(readNestedValue(readNestedValue(data.item_rate, "play_mid"), "value"))
|
||||
};
|
||||
}
|
||||
|
||||
function buildSpreadInfoColumnHeader(
|
||||
config: SpreadInfoConfig,
|
||||
metric: SpreadInfoMetricDefinition
|
||||
): string {
|
||||
return [...buildConfigPrefixParts(config), metric.label].join("_");
|
||||
}
|
||||
|
||||
function buildConfigPrefixParts(config: SpreadInfoConfig): string[] {
|
||||
const typeLabel = config.type === 1 ? "个人视频" : "星图视频";
|
||||
const rangeLabel = config.range === 2 ? "近30天" : "近90天";
|
||||
|
||||
if (config.type === 1) {
|
||||
return [typeLabel, rangeLabel];
|
||||
}
|
||||
|
||||
return [
|
||||
config.onlyAssign ? "只看指派" : "不限指派",
|
||||
config.flowType === 1 ? "排除营销流量" : "不排除营销流量",
|
||||
typeLabel,
|
||||
rangeLabel
|
||||
];
|
||||
}
|
||||
|
||||
function buildXingtuVideoConfigs(): SpreadInfoConfig[] {
|
||||
const configs: SpreadInfoConfig[] = [];
|
||||
[false, true].forEach((onlyAssign) => {
|
||||
([0, 1] as const).forEach((flowType) => {
|
||||
([2, 3] as const).forEach((range) => {
|
||||
configs.push({
|
||||
flowType,
|
||||
onlyAssign,
|
||||
range,
|
||||
type: 2
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return configs;
|
||||
}
|
||||
|
||||
function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isRecord(payload.data) ? payload.data : payload;
|
||||
}
|
||||
|
||||
function readNestedValue(value: unknown, key: string): unknown {
|
||||
return isRecord(value) ? value[key] : undefined;
|
||||
}
|
||||
|
||||
function readStringLike(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatBasisPointPercent(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${formatDecimal(value / 100)}%`;
|
||||
}
|
||||
|
||||
function formatMillisecondsAsSeconds(value: number | null): string | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatDecimal(value / 100);
|
||||
}
|
||||
|
||||
function formatDecimal(value: number): string {
|
||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
if (typeof location !== "undefined" && location.origin) {
|
||||
return location.origin;
|
||||
}
|
||||
|
||||
return "https://www.xingtu.cn";
|
||||
}
|
||||
|
||||
async function defaultFetch(input: string, init?: RequestInit) {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
function hasTextValue(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface BackendMetrics {
|
||||
newA3Rate?: string;
|
||||
}
|
||||
|
||||
export type SpreadInfoMetrics = Record<string, string>;
|
||||
|
||||
export type MarketSortField =
|
||||
| keyof Required<AfterSearchRates>
|
||||
| keyof Required<BackendMetrics>;
|
||||
@@ -28,6 +30,8 @@ export interface MarketRowSnapshot {
|
||||
location?: string;
|
||||
price21To60s?: string;
|
||||
rates?: AfterSearchRates;
|
||||
spreadAuthorId?: string;
|
||||
spreadMetrics?: SpreadInfoMetrics;
|
||||
}
|
||||
|
||||
export interface MarketRecord extends MarketRowSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user