feat: add ase api client
This commit is contained in:
parent
065b6380bf
commit
649de1608c
136
src/content/market/api-client.ts
Normal file
136
src/content/market/api-client.ts
Normal 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;
|
||||
}
|
||||
22
src/content/market/types.ts
Normal file
22
src/content/market/types.ts
Normal 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;
|
||||
75
tests/market-api-client.test.ts
Normal file
75
tests/market-api-client.test.ts
Normal 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"
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user