diff --git a/src/content/market/api-client.ts b/src/content/market/api-client.ts new file mode 100644 index 0000000..f070e76 --- /dev/null +++ b/src/content/market/api-client.ts @@ -0,0 +1,136 @@ +import { normalizeRateDisplay } from "../../shared/rate-normalizer"; +import type { MarketApiResult } from "./types"; + +interface FetchResponseLike { + json(): Promise; + ok: boolean; +} + +type FetchLike = ( + input: string, + init?: RequestInit +) => Promise; + +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 { + 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 | 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 { + return typeof value === "object" && value !== null; +} diff --git a/src/content/market/types.ts b/src/content/market/types.ts new file mode 100644 index 0000000..c73989e --- /dev/null +++ b/src/content/market/types.ts @@ -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; +}; + +export type MarketApiFailureResult = { + success: false; + reason: MarketApiFailureReason; +}; + +export type MarketApiResult = MarketApiSuccessResult | MarketApiFailureResult; diff --git a/tests/market-api-client.test.ts b/tests/market-api-client.test.ts new file mode 100644 index 0000000..cfb0f7e --- /dev/null +++ b/tests/market-api-client.test.ts @@ -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" + }); + }); +});