feat: add ase api client

This commit is contained in:
admin123 2026-04-20 20:05:02 +08:00
parent 065b6380bf
commit 649de1608c
3 changed files with 233 additions and 0 deletions

View File

@ -0,0 +1,136 @@
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import type { MarketApiResult } from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface MarketApiClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
timeoutMs?: number;
}
export function createMarketApiClient(options: MarketApiClientOptions = {}) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadAuthorAseInfo(authorId: string): Promise<MarketApiResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(
buildAuthorAseInfoUrl(authorId, baseUrl),
{
credentials: "include",
method: "GET",
signal: controller.signal
}
);
if (!response.ok) {
return {
success: false,
reason: "request-failed"
};
}
return mapAuthorAseInfoResponse(await response.json());
} catch (error) {
if (isAbortError(error) || controller.signal.aborted) {
return {
success: false,
reason: "timeout"
};
}
return {
success: false,
reason: "request-failed"
};
} finally {
clearTimeout(timeoutId);
}
}
};
}
export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string {
const url = new URL("/gw/api/aggregator/get_author_ase_info", baseUrl);
url.searchParams.set("author_id", authorId);
url.searchParams.set("range", "30");
return url.toString();
}
export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
const data = getPayloadData(payload);
if (!data) {
return {
success: false,
reason: "bad-response"
};
}
const singleVideoAfterSearchRate = readNormalizedRate(
data.avg_search_after_view_rate
);
const personalVideoAfterSearchRate = readNormalizedRate(
data.personal_avg_search_after_view_rate
);
if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) {
return {
success: false,
reason: "missing-rate"
};
}
return {
success: true,
rates: {
singleVideoAfterSearchRate,
personalVideoAfterSearchRate
}
};
}
function getPayloadData(payload: unknown): Record<string, unknown> | null {
if (!isRecord(payload)) {
return null;
}
return isRecord(payload.data) ? payload.data : payload;
}
function readNormalizedRate(value: unknown): string | null {
return typeof value === "string" ? normalizeRateDisplay(value) : null;
}
function resolveBaseUrl(): string {
if (typeof location !== "undefined" && location.origin) {
return location.origin;
}
return "https://xingtu.cn";
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@ -0,0 +1,22 @@
export interface AfterSearchRates {
personalVideoAfterSearchRate?: string;
singleVideoAfterSearchRate?: string;
}
export type MarketApiFailureReason =
| "bad-response"
| "missing-rate"
| "request-failed"
| "timeout";
export type MarketApiSuccessResult = {
success: true;
rates: Required<AfterSearchRates>;
};
export type MarketApiFailureResult = {
success: false;
reason: MarketApiFailureReason;
};
export type MarketApiResult = MarketApiSuccessResult | MarketApiFailureResult;

View File

@ -0,0 +1,75 @@
import { describe, expect, test } from "vitest";
import {
buildAuthorAseInfoUrl,
createMarketApiClient,
mapAuthorAseInfoResponse
} from "../src/content/market/api-client";
describe("market-api-client", () => {
test("builds the author ase info url with author id and range", () => {
expect(
buildAuthorAseInfoUrl("123", "https://xingtu.cn")
).toBe(
"https://xingtu.cn/gw/api/aggregator/get_author_ase_info?author_id=123&range=30"
);
});
test("maps a valid ASE payload into normalized rates", () => {
expect(
mapAuthorAseInfoResponse({
data: {
avg_search_after_view_rate: "<0.02%",
personal_avg_search_after_view_rate: "0.02 - 0.1%"
}
})
).toMatchObject({
success: true,
rates: {
singleVideoAfterSearchRate: "<0.02%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
});
});
test("returns a missing-rate failure when the payload omits a required field", () => {
expect(
mapAuthorAseInfoResponse({
data: {
avg_search_after_view_rate: "<0.02%"
}
})
).toMatchObject({
success: false,
reason: "missing-rate"
});
});
test("returns a request-failed result for non-ok responses", async () => {
const client = createMarketApiClient({
fetchImpl: async () => ({
ok: false,
json: async () => ({})
})
});
await expect(client.loadAuthorAseInfo("123")).resolves.toMatchObject({
success: false,
reason: "request-failed"
});
});
test("returns a timeout result when the request aborts", async () => {
const client = createMarketApiClient({
fetchImpl: async () => {
throw new DOMException("Timed out", "AbortError");
},
timeoutMs: 1
});
await expect(client.loadAuthorAseInfo("123")).resolves.toMatchObject({
success: false,
reason: "timeout"
});
});
});