[Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+366
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
public status = 200,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
export interface DramaContext {
|
||||
media_id?: string | number;
|
||||
drama_id?: string | number;
|
||||
episode?: number;
|
||||
video_id?: string | number;
|
||||
recommend_trace_id?: string;
|
||||
}
|
||||
export interface RequestOptions {
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
trackerContext?: DramaContext;
|
||||
trackerEvent?: string | false;
|
||||
signal?: AbortSignal;
|
||||
skipRecovery?: boolean;
|
||||
}
|
||||
type Tracker = {
|
||||
getSDKCommonHeader?: () => string | Promise<string>;
|
||||
getCommonFields?: () =>
|
||||
| Record<string, unknown>
|
||||
| Promise<Record<string, unknown>>;
|
||||
getDramaContext?: () => DramaContext;
|
||||
setUid?: (uid: string) => void;
|
||||
};
|
||||
const tracker = () =>
|
||||
typeof window === "undefined"
|
||||
? undefined
|
||||
: (window as unknown as { tracker?: Tracker }).tracker;
|
||||
export function getCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const value =
|
||||
document.cookie
|
||||
.split(";")
|
||||
.map((p) => p.trim())
|
||||
.find((p) => p.startsWith(name + "="))
|
||||
?.slice(name.length + 1) || "";
|
||||
try {
|
||||
return decodeURIComponent(value.replace(/\+/g, " "));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
function setCookie(name: string, value: unknown, days = 7) {
|
||||
document.cookie =
|
||||
name +
|
||||
"=" +
|
||||
encodeURIComponent(String(value ?? "")) +
|
||||
"; Path=/; SameSite=Lax; Max-Age=" +
|
||||
Math.floor(days * 86400) +
|
||||
(location.protocol === "https:" ? "; Secure" : "");
|
||||
}
|
||||
const authKeys = [
|
||||
"token",
|
||||
"_token_",
|
||||
"sid",
|
||||
"_s_token_",
|
||||
"userId",
|
||||
"accountNo",
|
||||
"nickName",
|
||||
"introduction",
|
||||
"avatar",
|
||||
"email",
|
||||
"inviteCode",
|
||||
"recharge",
|
||||
"vipType",
|
||||
"currentVipExpire",
|
||||
"showCardId",
|
||||
"showCardName",
|
||||
"userType",
|
||||
"isRegister",
|
||||
];
|
||||
export function clearAuthSession() {
|
||||
authKeys.forEach((key) => setCookie(key, "", 0));
|
||||
document.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
export function setAuthSession(user: Record<string, unknown>) {
|
||||
const values = {
|
||||
...user,
|
||||
userType: user.type,
|
||||
isRegister:
|
||||
user.isRegister === true || user.isRegister === 1 || user.type === 1
|
||||
? "1"
|
||||
: "0",
|
||||
};
|
||||
for (const [key, value] of Object.entries(values))
|
||||
if (authKeys.includes(key) && value != null) setCookie(key, value);
|
||||
setCookie("guest_skip_auto", "", 0);
|
||||
try {
|
||||
sessionStorage.removeItem("guest_logout_manual");
|
||||
} catch {
|
||||
/* storage disabled */
|
||||
}
|
||||
if (user.userId != null) tracker()?.setUid?.(String(user.userId));
|
||||
document.dispatchEvent(new CustomEvent("auth:login", { detail: { user } }));
|
||||
}
|
||||
export function getDeviceID(): string {
|
||||
let id = getCookie("guest_dev_id");
|
||||
try {
|
||||
id =
|
||||
localStorage.getItem("uuid") ||
|
||||
id ||
|
||||
localStorage.getItem("guest_dev_id") ||
|
||||
"";
|
||||
} catch {
|
||||
/* cookie fallback */
|
||||
}
|
||||
if (!id)
|
||||
id =
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(16)), (n) =>
|
||||
(n % 16).toString(16).toUpperCase(),
|
||||
).join("") + Date.now();
|
||||
try {
|
||||
localStorage.setItem("uuid", id);
|
||||
localStorage.setItem("guest_dev_id", id);
|
||||
} catch {
|
||||
/* cookie still usable */
|
||||
}
|
||||
setCookie("guest_dev_id", id, 365);
|
||||
return id;
|
||||
}
|
||||
export function getChannel(): string {
|
||||
const params = new URLSearchParams(location.search);
|
||||
let value = getCookie("channel");
|
||||
try {
|
||||
value = localStorage.getItem("channel") || value;
|
||||
} catch {
|
||||
/* cookie fallback */
|
||||
}
|
||||
value ||= params.get("dc") || params.get("ch") || params.get("pc") || "";
|
||||
if (!/^[A-Za-z0-9_.-]{1,50}$/.test(value)) return "";
|
||||
try {
|
||||
localStorage.setItem("channel", value);
|
||||
} catch {
|
||||
/* cookie fallback */
|
||||
}
|
||||
setCookie("channel", value, 365);
|
||||
return value;
|
||||
}
|
||||
const events: Record<string, string> = {
|
||||
"/api/playlet/chapter/like/set": "drama_like",
|
||||
"/api/playlet/like/set": "drama_like",
|
||||
"/api/playlet/chapter/collect/set": "drama_collect",
|
||||
"/api/playlet/collect/set": "drama_collect",
|
||||
"/api/comment/send": "drama_comment",
|
||||
"/api/playlet/chapter/unlock": "drama_purchase",
|
||||
};
|
||||
const base64 = (value: unknown) =>
|
||||
btoa(
|
||||
Array.from(new TextEncoder().encode(JSON.stringify(value)), (n) =>
|
||||
String.fromCharCode(n),
|
||||
).join(""),
|
||||
)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const clean = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^\x20-\x7e]|[;=]/g, " ")
|
||||
.trim();
|
||||
async function deadline<T>(
|
||||
promise: Promise<T>,
|
||||
fallback: T,
|
||||
ms = 1500,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise.catch(() => fallback),
|
||||
new Promise<T>((resolve) => {
|
||||
timer = setTimeout(() => resolve(fallback), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
async function requestHeaders(
|
||||
path: string,
|
||||
method: string,
|
||||
options: RequestOptions,
|
||||
): Promise<Headers> {
|
||||
const headers = new Headers({
|
||||
Accept: "application/json",
|
||||
...options.headers,
|
||||
});
|
||||
if (new URL(path, location.href).origin !== location.origin) return headers;
|
||||
const sdk = tracker();
|
||||
const commonHeader = deadline(
|
||||
Promise.resolve().then(() => sdk?.getSDKCommonHeader?.() || ""),
|
||||
"",
|
||||
);
|
||||
const event =
|
||||
options.trackerEvent === false
|
||||
? ""
|
||||
: options.trackerEvent || events[new URL(path, location.href).pathname];
|
||||
const context = options.trackerContext || sdk?.getDramaContext?.();
|
||||
const common =
|
||||
event && !["GET", "HEAD"].includes(method) && context?.drama_id
|
||||
? await deadline(
|
||||
Promise.resolve().then(() => sdk?.getCommonFields?.() || {}),
|
||||
{},
|
||||
)
|
||||
: null;
|
||||
if (common && context) {
|
||||
const requestID = Array.from(
|
||||
crypto.getRandomValues(new Uint8Array(16)),
|
||||
(n) => n.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const drama = {
|
||||
media_id: String(context.media_id ?? "").trim(),
|
||||
drama_id: String(context.drama_id ?? "").trim(),
|
||||
episode: Math.max(1, context.episode || 1),
|
||||
video_id: String(context.video_id ?? "").trim(),
|
||||
recommend_trace_id: String(context.recommend_trace_id ?? "").trim(),
|
||||
};
|
||||
headers.set("X-Tracker-Request-Id", requestID);
|
||||
headers.set("X-Tracker-Event-Name", event);
|
||||
headers.set(
|
||||
"X-DC-Request-Context",
|
||||
base64({ v: 1, request_id: requestID, event_name: event, common, drama }),
|
||||
);
|
||||
headers.set(
|
||||
"X-User-Agent",
|
||||
[
|
||||
"DevID=" + clean(common.device_id),
|
||||
"DevType=" + clean(common.device || "OTHER"),
|
||||
"SysType=" +
|
||||
(common.device === "Android"
|
||||
? "h5_android"
|
||||
: common.device === "iOS"
|
||||
? "h5_ios"
|
||||
: "h5_pc"),
|
||||
"Ver=" + clean(common.app_version || "1.0.0"),
|
||||
"BuildID=web",
|
||||
"device_brand=" + clean(common.device_brand),
|
||||
"device_model=" + clean(common.device_model),
|
||||
"sid=" + clean(common.sid),
|
||||
].join(";"),
|
||||
);
|
||||
}
|
||||
const value = await commonHeader;
|
||||
if (value && !headers.has("X-SDK-Common")) headers.set("X-SDK-Common", value);
|
||||
return headers;
|
||||
}
|
||||
let guestPromise: Promise<boolean> | null = null;
|
||||
export async function ensureGuestSession(): Promise<boolean> {
|
||||
if (getCookie("token") || getCookie("_token_")) return true;
|
||||
if (!guestPromise)
|
||||
guestPromise = (async () => {
|
||||
const devID = getDeviceID();
|
||||
try {
|
||||
const user = await apiRequest<Record<string, unknown>>(
|
||||
"/api/auth/guest",
|
||||
{ devID, deviceId: devID, channel: getChannel(), mac: "" },
|
||||
{ skipRecovery: true },
|
||||
);
|
||||
setAuthSession(user);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})().finally(() => {
|
||||
guestPromise = null;
|
||||
});
|
||||
return guestPromise;
|
||||
}
|
||||
let recovery: Promise<boolean> | null = null;
|
||||
async function recoverSession(): Promise<boolean> {
|
||||
if (!recovery)
|
||||
recovery = (async () => {
|
||||
const registered =
|
||||
getCookie("isRegister") === "1" || getCookie("userType") === "1";
|
||||
clearAuthSession();
|
||||
if (registered) {
|
||||
document.dispatchEvent(new CustomEvent("auth:required"));
|
||||
return false;
|
||||
}
|
||||
return ensureGuestSession();
|
||||
})().finally(() => {
|
||||
recovery = null;
|
||||
});
|
||||
return recovery;
|
||||
}
|
||||
/** Same-origin BFF request; prepared tracking headers and request_id survive one guest retry. */
|
||||
export async function apiRequest<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const method = options.method || (body === undefined ? "GET" : "POST");
|
||||
const url = new URL(path, location.href);
|
||||
for (const [key, value] of Object.entries(options.params || {}))
|
||||
if (value != null) url.searchParams.set(key, String(value));
|
||||
const headers = await requestHeaders(url.href, method, options);
|
||||
const payload =
|
||||
body == null
|
||||
? undefined
|
||||
: body instanceof FormData
|
||||
? body
|
||||
: JSON.stringify(body);
|
||||
if (payload && !(body instanceof FormData))
|
||||
headers.set("Content-Type", "application/json");
|
||||
const run = async () => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.timeout ?? 30000,
|
||||
);
|
||||
const abort = () => controller.abort();
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
if (options.signal?.aborted) controller.abort();
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: payload,
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
code?: number;
|
||||
data: T;
|
||||
msg?: string;
|
||||
tip?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
Number(result.code || response.status),
|
||||
result.tip || result.msg || result.message || "请求失败",
|
||||
response.status,
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
};
|
||||
let result = await run();
|
||||
if (
|
||||
[1000, 1002, 1012, 6001].includes(Number(result.code)) &&
|
||||
!options.skipRecovery &&
|
||||
!/\/api\/auth\/(guest|login|register|resetPassword)/.test(path) &&
|
||||
(await recoverSession())
|
||||
)
|
||||
result = await run();
|
||||
if (result.code !== undefined && result.code !== 200)
|
||||
throw new ApiError(
|
||||
Number(result.code),
|
||||
result.tip || result.msg || result.message || "请求失败",
|
||||
);
|
||||
return (result.data === undefined ? result : result.data) as T;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
type Fields = Record<string, unknown>;
|
||||
|
||||
function fields(value: unknown): Fields {
|
||||
return value && typeof value === "object" ? value as Fields : {};
|
||||
}
|
||||
|
||||
/** Resolve both normalized page data and the upstream config without empty-field overrides. */
|
||||
export function appDownloadURL(value: unknown): string {
|
||||
const config = fields(value);
|
||||
const sources = [config, fields(config.config), fields(config.newAddressInfo)];
|
||||
for (const keys of [["appAddr", "AppAddr"], ["officialAppAddr", "OfficialAppAddr"]]) {
|
||||
for (const source of sources) {
|
||||
for (const key of keys) {
|
||||
const candidate = source[key];
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Versioned filenames avoid the former site's static files and cached brand images.
|
||||
const root = "/static/brand/huakui-20261013";
|
||||
|
||||
export const brandAssets = {
|
||||
headerPng: `${root}/header.png`,
|
||||
navigationLogo: `${root}/footer.png`,
|
||||
authLogo: `${root}/auth.png`,
|
||||
iconSvg: `${root}/icon.svg`,
|
||||
iconPng: `${root}/icon.png`,
|
||||
faviconIco: `${root}/favicon.ico`,
|
||||
} as const;
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Public image locations only; this module never receives API credentials. */
|
||||
function httpURL(value: string): URL | null {
|
||||
if (!/^https?:\/\//i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value)) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.hostname && !url.username && !url.password ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function imageCDNBases(...values: unknown[]): string[] {
|
||||
const result = new Set<string>();
|
||||
for (const value of values) {
|
||||
const url = httpURL(String(value || "").trim());
|
||||
if (!url || url.search || url.hash) continue;
|
||||
url.pathname = url.pathname.replace(/\/+$/, "") + "/";
|
||||
result.add(url.href);
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
|
||||
/** Preserve absolute/signed URLs, and resolve API object keys independently of the page URL. */
|
||||
export function imageSourceURLs(source: string, bases: readonly string[] = []): string[] {
|
||||
const value = source.trim();
|
||||
if (!value || /[\u0000-\u0020\u007f\\]/.test(value)) return [];
|
||||
if (/^(?:\/static\/|data:image\/|blob:)/i.test(value)) return [value];
|
||||
const cdns = imageCDNBases(...bases);
|
||||
const absolute = httpURL(value);
|
||||
if (absolute) {
|
||||
const original = cdns.find((base) => {
|
||||
const url = new URL(base);
|
||||
return url.origin === absolute.origin && absolute.pathname.startsWith(url.pathname);
|
||||
});
|
||||
if (!original) return [value];
|
||||
const path = absolute.pathname.slice(new URL(original).pathname.length) + absolute.search + absolute.hash;
|
||||
return [...new Set([value, ...cdns.map((base) => new URL(path, base).href)])];
|
||||
}
|
||||
// No scheme, network-path reference or traversal may turn an object key into another host.
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(value) || value.startsWith("//")) return [];
|
||||
const path = value.replace(/^\/+/, "").replace(/^(?:\.\/)+/, "");
|
||||
if (!path || path.split(/[/?#]/).some((part) => part === "..")) return [];
|
||||
return cdns.length ? cdns.map((base) => new URL(path, base).href) : ["/" + path];
|
||||
}
|
||||
|
||||
/** Account APIs now also return the same relative object keys as catalog APIs. */
|
||||
export function validProfileImage(value: unknown): string {
|
||||
const source = String(value || "").trim();
|
||||
return (httpURL(source) || /^(?:\/static\/|\/?v3\/image\/)/.test(source)) &&
|
||||
imageSourceURLs(source).length ? source : "";
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { imageSourceURLs } from "./image-source";
|
||||
|
||||
export const IMAGE_PLACEHOLDER =
|
||||
"/static/brand/placeholder.svg?v=huakui-20261010";
|
||||
const key = new TextEncoder().encode("2019ysapp7527");
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
const queue: (() => void)[] = [];
|
||||
let active = 0;
|
||||
|
||||
export function shouldDecryptImage(url: string): boolean {
|
||||
return (
|
||||
!!url &&
|
||||
!/^(data:|blob:|\/static\/)/i.test(url) &&
|
||||
!url.includes("placehold.co")
|
||||
);
|
||||
}
|
||||
export function decryptImageBytes(buffer: ArrayBuffer): Uint8Array {
|
||||
const bytes = new Uint8Array(buffer.slice(0));
|
||||
for (let index = 0; index < Math.min(100, bytes.length); index++)
|
||||
bytes[index] ^= key[index % key.length];
|
||||
return bytes;
|
||||
}
|
||||
export function imageMime(bytes: Uint8Array): string {
|
||||
if (
|
||||
bytes[0] === 0x47 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x38
|
||||
)
|
||||
return "image/gif";
|
||||
if (
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
)
|
||||
return "image/png";
|
||||
if (
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
)
|
||||
return "image/webp";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
function hasImageSignature(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 12 && (
|
||||
(bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) ||
|
||||
(bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) ||
|
||||
(bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) ||
|
||||
(bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50)
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeImageBytes(buffer: ArrayBuffer): Uint8Array {
|
||||
const plain = new Uint8Array(buffer);
|
||||
if (hasImageSignature(plain)) return plain;
|
||||
const decrypted = decryptImageBytes(buffer);
|
||||
if (hasImageSignature(decrypted)) return decrypted;
|
||||
throw new Error("Invalid image payload");
|
||||
}
|
||||
/** Shared bounded network queue and in-flight cache; no business scripts or DOM mutations. */
|
||||
export function resolveImage(url: string, priority = false, bases: readonly string[] = []): Promise<string> {
|
||||
if (!url) return Promise.resolve(IMAGE_PLACEHOLDER);
|
||||
const sources = imageSourceURLs(url, bases);
|
||||
if (!sources.length) return Promise.reject(new Error("Invalid image URL"));
|
||||
if (!shouldDecryptImage(sources[0])) return Promise.resolve(sources[0]);
|
||||
const cacheKey = JSON.stringify(sources);
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
const pending = inflight.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
const limit =
|
||||
typeof matchMedia !== "undefined" && matchMedia("(max-width:767px)").matches
|
||||
? 2
|
||||
: 4;
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
const run = () => {
|
||||
active++;
|
||||
(async () => {
|
||||
let failure: unknown;
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await fetch(source, {
|
||||
credentials: "same-origin",
|
||||
signal: AbortSignal.timeout(12000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Image HTTP ${response.status}`);
|
||||
const bytes = decodeImageBytes(await response.arrayBuffer());
|
||||
const blob = URL.createObjectURL(new Blob([bytes as BlobPart], { type: imageMime(bytes) }));
|
||||
cache.set(cacheKey, blob);
|
||||
if (cache.size > 256) {
|
||||
const oldest = cache.keys().next().value!;
|
||||
URL.revokeObjectURL(cache.get(oldest)!);
|
||||
cache.delete(oldest);
|
||||
}
|
||||
return blob;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
})()
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => {
|
||||
active--;
|
||||
inflight.delete(cacheKey);
|
||||
queue.shift()?.();
|
||||
});
|
||||
};
|
||||
if (active < limit) run();
|
||||
else if (priority) queue.unshift(run);
|
||||
else queue.push(run);
|
||||
});
|
||||
inflight.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Preserve signed media queries and relative encryption-key routes used by the BFF. */
|
||||
export function resolvePlayURL(value: unknown): string {
|
||||
const url = String(value ?? "").trim();
|
||||
if (!url) return "";
|
||||
if (/^(https?:\/\/|blob:|data:)/i.test(url)) return url;
|
||||
const path = url.replace(/^\/+/, "");
|
||||
return "/" + (path.startsWith("h5/m3u8/") ? path : "h5/m3u8/" + path);
|
||||
}
|
||||
export const SHELL_BROWSER =
|
||||
/Quark|MQQBrowser|QQ\/|UCBrowser|UCWEB|MicroMessenger|baidubrowser|bdbrowser|BaiduHD|MiuiBrowser|HuaweiBrowser|HONORBrowser|HBPC|SogouMobileBrowser|VivoBrowser|HeyTapBrowser|OPPOBrowser|HeyTap|QihooBrowser|360Browser|360SE|360EE/i;
|
||||
export function nativeHLS(
|
||||
video: HTMLVideoElement,
|
||||
userAgent = navigator.userAgent,
|
||||
): boolean {
|
||||
return (
|
||||
!SHELL_BROWSER.test(userAgent) &&
|
||||
/Safari/i.test(userAgent) &&
|
||||
!/Chrome|Chromium|CriOS|Edg|OPR/i.test(userAgent) &&
|
||||
!!video.canPlayType("application/vnd.apple.mpegurl")
|
||||
);
|
||||
}
|
||||
export function isHLSURL(url: string): boolean {
|
||||
return /\.m3u8(?:[?#]|$)|\/h5\/m3u8\//i.test(url);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
{
|
||||
"version": 1,
|
||||
"routes": [
|
||||
{
|
||||
"id": "Index:/",
|
||||
"label": "首页",
|
||||
"path": "/",
|
||||
"legacyPaths": [
|
||||
"/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "SearchMobile:/discover/search/",
|
||||
"label": "移动端搜索",
|
||||
"path": "/discover/search/",
|
||||
"legacyPaths": [
|
||||
"/search-mobile/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Search:/search/",
|
||||
"label": "搜索结果",
|
||||
"path": "/search/",
|
||||
"legacyPaths": [
|
||||
"/search/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Search:/search/:q/",
|
||||
"label": "搜索结果",
|
||||
"path": "/search/:q/",
|
||||
"legacyPaths": [
|
||||
"/search/:q/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Search:/search/:q/page/:page/",
|
||||
"label": "搜索结果",
|
||||
"path": "/search/:q/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/search/:q/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "About:/about/",
|
||||
"label": "关于我们",
|
||||
"path": "/about/",
|
||||
"legacyPaths": [
|
||||
"/about/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Help:/help/",
|
||||
"label": "帮助中心",
|
||||
"path": "/help/",
|
||||
"legacyPaths": [
|
||||
"/help/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Terms:/terms/",
|
||||
"label": "服务条款",
|
||||
"path": "/terms/",
|
||||
"legacyPaths": [
|
||||
"/terms/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Privacy:/privacy/",
|
||||
"label": "隐私政策",
|
||||
"path": "/privacy/",
|
||||
"legacyPaths": [
|
||||
"/privacy/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Compliance2257:/2257/",
|
||||
"label": "2257 声明",
|
||||
"path": "/2257/",
|
||||
"legacyPaths": [
|
||||
"/2257/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "DMCA:/dmca/",
|
||||
"label": "版权声明",
|
||||
"path": "/dmca/",
|
||||
"legacyPaths": [
|
||||
"/dmca/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "DownloadApp:/app/download/",
|
||||
"label": "下载 App",
|
||||
"path": "/app/download/",
|
||||
"legacyPaths": [
|
||||
"/download-app/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "HomeRoad:/return-home/",
|
||||
"label": "回家的路",
|
||||
"path": "/return-home/",
|
||||
"legacyPaths": [
|
||||
"/home-road/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Nav:/navigation/",
|
||||
"label": "官方导航",
|
||||
"path": "/navigation/",
|
||||
"legacyPaths": [
|
||||
"/nav/",
|
||||
"/dh/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "List:/dramas/",
|
||||
"label": "短剧列表",
|
||||
"path": "/dramas/",
|
||||
"legacyPaths": [
|
||||
"/list/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "List:/dramas/:theme/",
|
||||
"label": "短剧列表",
|
||||
"path": "/dramas/:theme/",
|
||||
"legacyPaths": [
|
||||
"/list/:theme/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "List:/dramas/:theme/:updateStatus/:sortType/",
|
||||
"label": "短剧列表",
|
||||
"path": "/dramas/:theme/:updateStatus/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/list/:theme/:updateStatus/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "List:/dramas/:theme/:updateStatus/:sortType/page/:page/",
|
||||
"label": "短剧列表",
|
||||
"path": "/dramas/:theme/:updateStatus/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/list/:theme/:updateStatus/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreList:/curated/dramas/:configId/:timeRange/:theme/page/:page/",
|
||||
"label": "短剧推荐栏目",
|
||||
"path": "/curated/dramas/:configId/:timeRange/:theme/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/more-list/:configId/:timeRange/:theme/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreList:/curated/dramas/:configId/:timeRange/:theme/",
|
||||
"label": "短剧推荐栏目",
|
||||
"path": "/curated/dramas/:configId/:timeRange/:theme/",
|
||||
"legacyPaths": [
|
||||
"/more-list/:configId/:timeRange/:theme/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreList:/curated/dramas/:configId/page/:page/",
|
||||
"label": "短剧推荐栏目",
|
||||
"path": "/curated/dramas/:configId/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/more-list/:configId/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreList:/curated/dramas/:configId/",
|
||||
"label": "短剧推荐栏目",
|
||||
"path": "/curated/dramas/:configId/",
|
||||
"legacyPaths": [
|
||||
"/more-list/:configId/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreSeriesList:/curated/series/:configId/:timeRange/:theme/page/:page/",
|
||||
"label": "剧集推荐栏目",
|
||||
"path": "/curated/series/:configId/:timeRange/:theme/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/more-series-list/:configId/:timeRange/:theme/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreSeriesList:/curated/series/:configId/:timeRange/:theme/",
|
||||
"label": "剧集推荐栏目",
|
||||
"path": "/curated/series/:configId/:timeRange/:theme/",
|
||||
"legacyPaths": [
|
||||
"/more-series-list/:configId/:timeRange/:theme/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreSeriesList:/curated/series/:configId/page/:page/",
|
||||
"label": "剧集推荐栏目",
|
||||
"path": "/curated/series/:configId/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/more-series-list/:configId/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "MoreSeriesList:/curated/series/:configId/",
|
||||
"label": "剧集推荐栏目",
|
||||
"path": "/curated/series/:configId/",
|
||||
"legacyPaths": [
|
||||
"/more-series-list/:configId/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Series:/series/",
|
||||
"label": "剧集列表",
|
||||
"path": "/series/",
|
||||
"legacyPaths": [
|
||||
"/series/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Series:/series/:media/:topic/:sortType/",
|
||||
"label": "剧集列表",
|
||||
"path": "/series/:media/:topic/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/series/:media/:topic/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Series:/series/:media/:topic/:sortType/page/:page/",
|
||||
"label": "剧集列表",
|
||||
"path": "/series/:media/:topic/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/series/:media/:topic/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Topic:/collections/",
|
||||
"label": "专题列表",
|
||||
"path": "/collections/",
|
||||
"legacyPaths": [
|
||||
"/topic/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Topic:/collections/page/:page/",
|
||||
"label": "专题列表",
|
||||
"path": "/collections/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/topic/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Topic:/collections/sort/:sortType/",
|
||||
"label": "专题列表",
|
||||
"path": "/collections/sort/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/topic/sort/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Topic:/collections/sort/:sortType/page/:page/",
|
||||
"label": "专题列表",
|
||||
"path": "/collections/sort/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/topic/sort/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "TopicDetail:/collection/:slug/sort/:sortType/page/:page/",
|
||||
"label": "专题详情",
|
||||
"path": "/collection/:slug/sort/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/topic-detail/:slug/sort/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "TopicDetail:/collection/:slug/sort/:sortType/",
|
||||
"label": "专题详情",
|
||||
"path": "/collection/:slug/sort/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/topic-detail/:slug/sort/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "TopicDetail:/collection/:slug/page/:page/",
|
||||
"label": "专题详情",
|
||||
"path": "/collection/:slug/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/topic-detail/:slug/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "TopicDetail:/collection/:slug/",
|
||||
"label": "专题详情",
|
||||
"path": "/collection/:slug/",
|
||||
"legacyPaths": [
|
||||
"/topic-detail/:slug/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Rank:/rankings/",
|
||||
"label": "排行榜",
|
||||
"path": "/rankings/",
|
||||
"legacyPaths": [
|
||||
"/rank/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "Rank:/rankings/page/:page/",
|
||||
"label": "排行榜",
|
||||
"path": "/rankings/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/rank/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Rank:/rankings/:sortOrder/",
|
||||
"label": "排行榜",
|
||||
"path": "/rankings/:sortOrder/",
|
||||
"legacyPaths": [
|
||||
"/rank/:sortOrder/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Rank:/rankings/:sortOrder/page/:page/",
|
||||
"label": "排行榜",
|
||||
"path": "/rankings/:sortOrder/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/rank/:sortOrder/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Tags:/tags/:slug/sort/:sortType/page/:page/",
|
||||
"label": "标签详情",
|
||||
"path": "/tags/:slug/sort/:sortType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/tags/:slug/sort/:sortType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Tags:/tags/:slug/sort/:sortType/",
|
||||
"label": "标签详情",
|
||||
"path": "/tags/:slug/sort/:sortType/",
|
||||
"legacyPaths": [
|
||||
"/tags/:slug/sort/:sortType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Tags:/tags/:slug/page/:page/",
|
||||
"label": "标签详情",
|
||||
"path": "/tags/:slug/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/tags/:slug/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Tags:/tags/:slug/",
|
||||
"label": "标签详情",
|
||||
"path": "/tags/:slug/",
|
||||
"legacyPaths": [
|
||||
"/tags/:slug/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "RecentlyAdded:/latest/",
|
||||
"label": "最新更新",
|
||||
"path": "/latest/",
|
||||
"legacyPaths": [
|
||||
"/recently-added/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "RecentlyAdded:/latest/page/:page/",
|
||||
"label": "最新更新",
|
||||
"path": "/latest/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/recently-added/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "PopularRecommend:/featured/",
|
||||
"label": "热门推荐",
|
||||
"path": "/featured/",
|
||||
"legacyPaths": [
|
||||
"/popular-recommend/"
|
||||
],
|
||||
"selectable": true
|
||||
},
|
||||
{
|
||||
"id": "PopularRecommend:/featured/page/:page/",
|
||||
"label": "热门推荐",
|
||||
"path": "/featured/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/popular-recommend/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "PopularRecommend:/featured/:rankType/",
|
||||
"label": "热门推荐",
|
||||
"path": "/featured/:rankType/",
|
||||
"legacyPaths": [
|
||||
"/popular-recommend/:rankType/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "PopularRecommend:/featured/:rankType/page/:page/",
|
||||
"label": "热门推荐",
|
||||
"path": "/featured/:rankType/page/:page/",
|
||||
"legacyPaths": [
|
||||
"/popular-recommend/:rankType/page/:page/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Play:/drama/:slug/",
|
||||
"label": "短剧播放",
|
||||
"path": "/drama/:slug/",
|
||||
"legacyPaths": [
|
||||
"/play/:slug/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Play:/drama/:slug/:ep/",
|
||||
"label": "短剧播放",
|
||||
"path": "/drama/:slug/:ep/",
|
||||
"legacyPaths": [
|
||||
"/play/:slug/:ep/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Play:/drama/:slug/recommendations/page/:guessPage/",
|
||||
"label": "短剧播放",
|
||||
"path": "/drama/:slug/recommendations/page/:guessPage/",
|
||||
"legacyPaths": [
|
||||
"/play/:slug/recommendations/page/:guessPage/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Play:/drama/:slug/:ep/recommendations/page/:guessPage/",
|
||||
"label": "短剧播放",
|
||||
"path": "/drama/:slug/:ep/recommendations/page/:guessPage/",
|
||||
"legacyPaths": [
|
||||
"/play/:slug/:ep/recommendations/page/:guessPage/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "SeriesPlay:/series/watch/:slug/",
|
||||
"label": "剧集播放",
|
||||
"path": "/series/watch/:slug/",
|
||||
"legacyPaths": [
|
||||
"/series-play/:slug/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "SeriesPlay:/series/watch/:slug/recommendations/page/:guessPage/",
|
||||
"label": "剧集播放",
|
||||
"path": "/series/watch/:slug/recommendations/page/:guessPage/",
|
||||
"legacyPaths": [
|
||||
"/series-play/:slug/recommendations/page/:guessPage/"
|
||||
],
|
||||
"selectable": false
|
||||
},
|
||||
{
|
||||
"id": "Feedback:/feedback/",
|
||||
"label": "意见反馈",
|
||||
"path": "/feedback/",
|
||||
"legacyPaths": [
|
||||
"/feedback/"
|
||||
],
|
||||
"selectable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Viewport } from "next";
|
||||
|
||||
/** Public installation settings shared by Metadata, manifest and the client. */
|
||||
export const pwaConfig = {
|
||||
name: "花魁短剧",
|
||||
shortName: "花魁短剧",
|
||||
themeColor: "#141414",
|
||||
manifest: "/manifest.webmanifest",
|
||||
worker: "/sw.js",
|
||||
scope: "/",
|
||||
icons: {
|
||||
small: "/static/pwa/icon-192.png",
|
||||
large: "/static/pwa/icon-512.png",
|
||||
maskable: "/static/pwa/icon-maskable-512.png",
|
||||
apple: "/static/pwa/apple-touch-icon.png",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const pwaViewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: pwaConfig.themeColor,
|
||||
colorScheme: "dark",
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
export {};
|
||||
const worker = self as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
worker.addEventListener("install", (event) => {
|
||||
event.waitUntil(worker.skipWaiting());
|
||||
});
|
||||
worker.addEventListener("activate", (event) => {
|
||||
event.waitUntil(worker.clients.claim());
|
||||
});
|
||||
|
||||
// Intentionally no fetch handler or Cache Storage: HTML/RSC, authenticated API,
|
||||
// signed media and encryption keys retain the server's network/no-store policy.
|
||||
@@ -0,0 +1,105 @@
|
||||
/** Public Huakui page paths. API, signed media and static asset contracts are independent. */
|
||||
export const ROUTE_MIGRATIONS = [
|
||||
{ legacy: "/more-series-list", public: "/curated/series" },
|
||||
{ legacy: "/popular-recommend", public: "/featured" },
|
||||
{ legacy: "/recently-added", public: "/latest" },
|
||||
{ legacy: "/search-mobile", public: "/discover/search" },
|
||||
{ legacy: "/topic-detail", public: "/collection" },
|
||||
{ legacy: "/series-play", public: "/series/watch" },
|
||||
{ legacy: "/download-app", public: "/app/download" },
|
||||
{ legacy: "/home-road", public: "/return-home" },
|
||||
{ legacy: "/more-list", public: "/curated/dramas" },
|
||||
{ legacy: "/topic", public: "/collections" },
|
||||
{ legacy: "/rank", public: "/rankings" },
|
||||
{ legacy: "/list", public: "/dramas" },
|
||||
{ legacy: "/play", public: "/drama" },
|
||||
{ legacy: "/nav", public: "/navigation" },
|
||||
{ legacy: "/dh", public: "/navigation" },
|
||||
] as const;
|
||||
|
||||
const publicRules = [...ROUTE_MIGRATIONS].sort((a, b) => b.legacy.length - a.legacy.length);
|
||||
// Aliases converge to one internal route: /dh -> /nav.
|
||||
const legacyRules = ROUTE_MIGRATIONS.filter(({ legacy }) => legacy !== "/dh")
|
||||
.map(({ legacy, public: path }) => ({ legacy, public: path }))
|
||||
.sort((a, b) => b.public.length - a.public.length);
|
||||
|
||||
/** Removed account surfaces: legacy and public page paths permanently return home. */
|
||||
export const REMOVED_ACCOUNT_PATHS = [
|
||||
"/account", "/login", "/register", "/profile", "/history",
|
||||
"/favorites", "/purchased", "/setting", "/settings", "/recharge",
|
||||
] as const;
|
||||
|
||||
export function isRemovedAccountPath(path: string): boolean {
|
||||
return REMOVED_ACCOUNT_PATHS.some((prefix) => path === prefix || path.startsWith(prefix + "/"));
|
||||
}
|
||||
|
||||
function replacePrefix(raw: string, rules: readonly { from: string; to: string }[]): string {
|
||||
if (!raw.startsWith("/") || raw.startsWith("//")) return raw;
|
||||
const boundary = raw.search(/[?#]/);
|
||||
const path = boundary < 0 ? raw : raw.slice(0, boundary);
|
||||
const suffix = boundary < 0 ? "" : raw.slice(boundary);
|
||||
for (const { from, to } of rules) {
|
||||
if (path === from || path.startsWith(from + "/"))
|
||||
return to + path.slice(from.length) + suffix;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Swap only literal route prefixes; never decode/re-encode slugs, query strings or fragments. */
|
||||
export function toPublicPath(raw: string): string {
|
||||
return replacePrefix(raw, publicRules.map(({ legacy, public: path }) => ({ from: legacy, to: path })));
|
||||
}
|
||||
|
||||
/** Preserve legacy loader/SEO semantics while public pages use the new directory structure. */
|
||||
export function toLegacyPath(raw: string): string {
|
||||
return replacePrefix(raw, legacyRules.map(({ legacy, public: path }) => ({ from: path, to: legacy })));
|
||||
}
|
||||
|
||||
/** Only same-site absolute URLs are page links. External destinations remain byte-for-byte intact. */
|
||||
export function toPublicURL(raw: string, siteURL?: string): string {
|
||||
if (raw.startsWith("/") && !raw.startsWith("//")) return toPublicPath(raw);
|
||||
if (!siteURL || !/^https?:\/\//i.test(raw)) return raw;
|
||||
try {
|
||||
if (new URL(raw).origin !== new URL(siteURL).origin) return raw;
|
||||
const match = raw.match(/^(https?:\/\/[^/?#]+)([^?#]*)([\s\S]*)$/i);
|
||||
if (!match) return raw;
|
||||
const path = match[2] || "/";
|
||||
const mapped = toPublicPath(path);
|
||||
return mapped === path ? raw : match[1] + mapped + match[3];
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
const navigationFields = new Set([
|
||||
"url", "URL", "href", "Href", "urlValue", "URLValue",
|
||||
"canonicalPath", "requestPath", "hotMoreURL", "moreURL", "shareURL",
|
||||
"prevURL", "PrevURL", "nextURL", "NextURL",
|
||||
]);
|
||||
// Raw API/SDK/payment/media objects must retain their request and signed source addresses.
|
||||
const opaqueFields = new Set([
|
||||
"appConfig", "config", "advitise", "advertise", "chapter", "rawMedia", "videoInfo", "video", "media", "images",
|
||||
"reportSdkConfig", "ReportSDKConfig", "installGuide", "deposit", "payment", "payMethods",
|
||||
"jsonLD", "seoInfo", "canonicalURL", "paginationPrevURL", "paginationNextURL",
|
||||
]);
|
||||
|
||||
/** Normalize server-built navigation DTOs before React serialization. SEO owns its own URL graph. */
|
||||
export function canonicalizePageURLs<T>(data: T, siteURL?: string): T {
|
||||
const visited = new WeakSet<object>();
|
||||
const walk = (value: unknown): void => {
|
||||
if (!value || typeof value !== "object" || visited.has(value)) return;
|
||||
visited.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (opaqueFields.has(key)) continue;
|
||||
if (typeof entry === "string" && navigationFields.has(key))
|
||||
(value as Record<string, unknown>)[key] = toPublicURL(entry, siteURL);
|
||||
else walk(entry);
|
||||
}
|
||||
};
|
||||
walk(data);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
export {
|
||||
track,
|
||||
tracker,
|
||||
dramaEvent,
|
||||
dramaStatusChange,
|
||||
setDramaContext,
|
||||
externalAd,
|
||||
} from "@/components/analytics/tracker";
|
||||
export type { Fields, TrackerBoot } from "@/components/analytics/tracker";
|
||||
@@ -0,0 +1,467 @@
|
||||
/** Pure presentation and route helpers shared by React components. */
|
||||
import { toLegacyPath, toPublicPath } from "./routes";
|
||||
const field = (value: unknown, key: string): unknown => {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
obj[key] ??
|
||||
obj[
|
||||
Object.keys(obj).find((k) => k.toLowerCase() === key.toLowerCase()) || ""
|
||||
]
|
||||
);
|
||||
};
|
||||
const text = (value: unknown): string => (value == null ? "" : String(value));
|
||||
const trim = (value: unknown): string => text(value).trim();
|
||||
const number = (value: unknown): number => Number(value) || 0;
|
||||
const array = (value: unknown): unknown[] =>
|
||||
Array.isArray(value) ? value : [];
|
||||
const get = (value: unknown, key: string): unknown => field(value, key);
|
||||
const str = (value: unknown, key: string): string => text(get(value, key));
|
||||
const num = (value: unknown, key: string): number => number(get(value, key));
|
||||
const first = (...values: unknown[]): string =>
|
||||
text(values.find((value) => trim(value) !== "") ?? "");
|
||||
export const IMAGE_PLACEHOLDER = "/static/brand/placeholder.svg";
|
||||
|
||||
export function pathEscape(value: unknown): string {
|
||||
// Go url.PathEscape leaves + : @ & = $ while escaping slashes and UTF-8.
|
||||
return encodeURIComponent(text(value))
|
||||
.replace(
|
||||
/[!'()*]/g,
|
||||
(character) => "%" + character.charCodeAt(0).toString(16).toUpperCase(),
|
||||
)
|
||||
.replace(/%(?:2B|3A|40|26|3D|24)/g, (value) => decodeURIComponent(value));
|
||||
}
|
||||
function encodePath(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
.replace(
|
||||
/[!'()*]/g,
|
||||
(character) => "%" + character.charCodeAt(0).toString(16).toUpperCase(),
|
||||
)
|
||||
.replace(/%(?:2F|2B|3A|40|26|3D|24|2C|3B)/g, (part) =>
|
||||
decodeURIComponent(part),
|
||||
);
|
||||
}
|
||||
export function pageURL(value: unknown): string {
|
||||
const raw = trim(value);
|
||||
if (!raw || raw === "#" || raw === "/") return raw;
|
||||
if (/^(?:[a-z][\w+.-]*:|\/\/)/i.test(raw)) return raw;
|
||||
const match = /^([^?#]*)([?#][\s\S]*)?$/.exec(raw)!;
|
||||
if (!match[1]) return raw;
|
||||
try {
|
||||
decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
// Adding the canonical slash must not turn an escaped slash inside a slug
|
||||
// into a route separator. Encode raw characters while retaining valid RawPath escapes.
|
||||
const normalized = match[1] === "/" ? "/" : match[1].replace(/\/+$/, "") + "/";
|
||||
const escaped = encodePath(normalized).replace(/%25([a-fA-F0-9]{2})/g, "%$1");
|
||||
return toPublicPath(escaped + (match[2] || ""));
|
||||
}
|
||||
export function pathSegment(slug: unknown, id: unknown): string {
|
||||
return trim(slug)
|
||||
? pathEscape(trim(slug))
|
||||
: number(id) > 0
|
||||
? String(number(id))
|
||||
: "";
|
||||
}
|
||||
export function chapterPlayURL(
|
||||
slug: unknown,
|
||||
id: unknown,
|
||||
chapter: unknown = 0,
|
||||
): string {
|
||||
const segment = pathSegment(slug, id);
|
||||
return segment
|
||||
? pageURL(
|
||||
"/drama/" + segment + (number(chapter) > 0 ? "/" + number(chapter) : ""),
|
||||
)
|
||||
: "#";
|
||||
}
|
||||
export function playURL(slug: unknown, id: unknown): string {
|
||||
return chapterPlayURL(slug, id);
|
||||
}
|
||||
export function categoryURL(slug: unknown, id: unknown): string {
|
||||
const segment = pathSegment(slug, id);
|
||||
return pageURL(segment ? "/collection/" + segment : "/collections");
|
||||
}
|
||||
export function videoPlayURL(slug: unknown, id: unknown): string {
|
||||
const segment = pathSegment(slug, id);
|
||||
return pageURL(segment ? "/series/watch/" + segment : "/series");
|
||||
}
|
||||
export function searchResultsURL(query: unknown): string {
|
||||
return (
|
||||
"/search/" + (trim(query) ? encodeURIComponent(trim(query)) + "/" : "")
|
||||
);
|
||||
}
|
||||
const sortType = (sort: unknown): number =>
|
||||
[2, 3].includes(number(sort)) ? number(sort) : 1;
|
||||
const pageSuffix = (page: unknown): string =>
|
||||
number(page) > 1 ? "/page/" + Math.trunc(number(page)) : "";
|
||||
export function allListURL(
|
||||
slug: unknown,
|
||||
id: unknown,
|
||||
status: unknown,
|
||||
sort: unknown,
|
||||
page: unknown,
|
||||
): string {
|
||||
const segment = pathSegment(slug, id) || "all";
|
||||
const update = [1, 2].includes(number(status)) ? number(status) : 0;
|
||||
const order = sortType(sort);
|
||||
if (update === 0 && order === 1 && number(page) <= 1)
|
||||
return pageURL("/dramas" + (segment === "all" ? "" : "/" + segment));
|
||||
return pageURL(
|
||||
"/dramas/" + segment + "/" + update + "/" + order + pageSuffix(page),
|
||||
);
|
||||
}
|
||||
function moreConfigListURL(
|
||||
prefix: string,
|
||||
slug: unknown,
|
||||
id: unknown,
|
||||
range: unknown,
|
||||
themeSlug: unknown,
|
||||
themeId: unknown,
|
||||
page: unknown,
|
||||
): string {
|
||||
const config = pathSegment(slug, id);
|
||||
if (!config) return pageURL(prefix);
|
||||
const period = [1, 2].includes(number(range)) ? number(range) : 0;
|
||||
const theme =
|
||||
trim(themeSlug) === "all"
|
||||
? "all"
|
||||
: pathSegment(themeSlug, themeId) || "all";
|
||||
return pageURL(
|
||||
prefix +
|
||||
"/" +
|
||||
config +
|
||||
(period === 0 && theme === "all" ? "" : "/" + period + "/" + theme) +
|
||||
pageSuffix(page),
|
||||
);
|
||||
}
|
||||
export function moreListURL(
|
||||
slug: unknown,
|
||||
id: unknown,
|
||||
range: unknown,
|
||||
themeSlug: unknown,
|
||||
themeId: unknown,
|
||||
page: unknown,
|
||||
): string {
|
||||
return moreConfigListURL(
|
||||
"/curated/dramas",
|
||||
slug,
|
||||
id,
|
||||
range,
|
||||
themeSlug,
|
||||
themeId,
|
||||
page,
|
||||
);
|
||||
}
|
||||
export function moreSeriesListURL(
|
||||
slug: unknown,
|
||||
id: unknown,
|
||||
range: unknown,
|
||||
themeSlug: unknown,
|
||||
themeId: unknown,
|
||||
page: unknown,
|
||||
): string {
|
||||
return moreConfigListURL(
|
||||
"/curated/series",
|
||||
slug,
|
||||
id,
|
||||
range,
|
||||
themeSlug,
|
||||
themeId,
|
||||
page,
|
||||
);
|
||||
}
|
||||
export function topicListURL(sort: unknown, page: unknown): string {
|
||||
return pageURL(
|
||||
"/collections" + (number(sort) === 2 ? "/sort/2" : "") + pageSuffix(page),
|
||||
);
|
||||
}
|
||||
export function topicDetailURL(
|
||||
slug: unknown,
|
||||
id: unknown,
|
||||
sort: unknown,
|
||||
page: unknown,
|
||||
): string {
|
||||
const segment = pathSegment(slug, id);
|
||||
return segment
|
||||
? pageURL(
|
||||
"/collection/" +
|
||||
segment +
|
||||
(sortType(sort) === 1 ? "" : "/sort/" + sortType(sort)) +
|
||||
pageSuffix(page),
|
||||
)
|
||||
: "/collections/";
|
||||
}
|
||||
export function tagPageURL(
|
||||
slug: unknown,
|
||||
id: unknown,
|
||||
sort: unknown,
|
||||
page: unknown,
|
||||
): string {
|
||||
const segment = pathSegment(slug, id);
|
||||
return pageURL(
|
||||
"/tags" +
|
||||
(segment ? "/" + segment : "") +
|
||||
(sortType(sort) === 1 ? "" : "/sort/" + sortType(sort)) +
|
||||
pageSuffix(page),
|
||||
);
|
||||
}
|
||||
export function hotRankPageURL(sort: unknown, page: unknown): string {
|
||||
return pageURL(
|
||||
"/featured" +
|
||||
(sortType(sort) === 1 ? "" : "/" + sortType(sort)) +
|
||||
pageSuffix(page),
|
||||
);
|
||||
}
|
||||
export function seriesListURL(
|
||||
media: unknown,
|
||||
topic: unknown,
|
||||
sort: unknown,
|
||||
page: unknown,
|
||||
): string {
|
||||
const type = [1, 2].includes(number(media)) ? number(media) : 0;
|
||||
const topicID = Math.max(0, number(topic));
|
||||
const order = sortType(sort);
|
||||
if (type === 0 && topicID === 0 && order === 1 && number(page) <= 1)
|
||||
return "/series/";
|
||||
return pageURL(
|
||||
"/series/" +
|
||||
(type === 1 ? "film" : type === 2 ? "anime" : "all") +
|
||||
"/" +
|
||||
(topicID || "all") +
|
||||
"/" +
|
||||
order +
|
||||
pageSuffix(page),
|
||||
);
|
||||
}
|
||||
export function joinAbs(site: unknown, path: unknown): string {
|
||||
const base = trim(site).replace(/\/+$/, "");
|
||||
const target = trim(path);
|
||||
if (!target) return base ? base + "/" : "/";
|
||||
if (/^https?:\/\//.test(target)) return target;
|
||||
return base + (target.startsWith("/") ? target : "/" + target);
|
||||
}
|
||||
export function canonicalOr(
|
||||
site: unknown,
|
||||
preferred: unknown,
|
||||
fallback: unknown,
|
||||
): string {
|
||||
return joinAbs(site, trim(preferred) || fallback);
|
||||
}
|
||||
|
||||
export interface NamedTag {
|
||||
Name: string;
|
||||
URL: string;
|
||||
}
|
||||
export function namedTags(
|
||||
ids: unknown,
|
||||
names: unknown,
|
||||
slugs: unknown,
|
||||
theme = false,
|
||||
): NamedTag[] {
|
||||
return array(names).flatMap((name, index) => {
|
||||
if (!trim(name)) return [];
|
||||
const segment = pathSegment(array(slugs)[index], array(ids)[index]);
|
||||
return [
|
||||
{
|
||||
Name: trim(name),
|
||||
URL: segment ? pageURL((theme ? "/dramas/" : "/tags/") + segment) : "",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
export function namedThemeTags(
|
||||
ids: unknown,
|
||||
names: unknown,
|
||||
slugs: unknown,
|
||||
): NamedTag[] {
|
||||
return namedTags(ids, names, slugs, true);
|
||||
}
|
||||
export function cardTags(item: unknown): NamedTag[] {
|
||||
const tags = [
|
||||
...namedTags(
|
||||
get(item, "TagIDs"),
|
||||
get(item, "TagNames"),
|
||||
get(item, "TagSlugs"),
|
||||
),
|
||||
...namedThemeTags(
|
||||
get(item, "ThemeIDs"),
|
||||
get(item, "ThemeNames"),
|
||||
get(item, "ThemeSlugs"),
|
||||
),
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return tags
|
||||
.filter((tag) => {
|
||||
const key = tag.Name.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 3);
|
||||
}
|
||||
export const firstName = (names: unknown): string =>
|
||||
first(...array(names).map(trim));
|
||||
export const updateStatusText = (value: unknown): string =>
|
||||
number(value) === 2 ? "已完结" : "连载中";
|
||||
export const playletDisplayName = (playName: unknown, title: unknown): string =>
|
||||
first(playName, title);
|
||||
export const formatLikeCount = (value: unknown): string => {
|
||||
const count = number(value);
|
||||
return count <= 0
|
||||
? "0"
|
||||
: count < 10000
|
||||
? String(count)
|
||||
: (count / 10000).toFixed(count >= 1000000 ? 0 : 1) + " 万";
|
||||
};
|
||||
export function formatMediaTime(value: unknown): string {
|
||||
const total = Math.max(0, Math.trunc(number(value)));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const pad = (part: number): string => String(part).padStart(2, "0");
|
||||
return (
|
||||
(hours > 0 ? pad(hours) + ":" : "") + pad(minutes) + ":" + pad(seconds)
|
||||
);
|
||||
}
|
||||
function formatCount(value: unknown, unit: string): string {
|
||||
const count = number(value);
|
||||
return (
|
||||
(count <= 0
|
||||
? "0"
|
||||
: count < 10000
|
||||
? String(count)
|
||||
: (count / 10000).toFixed(count >= 1000000 ? 0 : 1) + "万") + unit
|
||||
);
|
||||
}
|
||||
export function topicCoverURLs(item: unknown): string[] {
|
||||
const covers: string[] = [];
|
||||
for (const value of array(get(item, "Covers"))) {
|
||||
const cover = trim(value);
|
||||
if (cover) covers.push(cover);
|
||||
if (covers.length === 3) return covers;
|
||||
}
|
||||
for (const value of array(get(item, "List"))) {
|
||||
const cover = trim(get(value, "Cover"));
|
||||
if (cover && !covers.includes(cover)) covers.push(cover);
|
||||
if (covers.length === 3) break;
|
||||
}
|
||||
return covers;
|
||||
}
|
||||
export function adSlice(
|
||||
list: unknown,
|
||||
start: unknown,
|
||||
count: unknown,
|
||||
): unknown[] {
|
||||
const values = array(list).filter((item) => str(item, "Images") !== "");
|
||||
if (!values.length || number(count) <= 0) return [];
|
||||
// Source rotates through configured banners so every inserted slot is filled.
|
||||
const output: unknown[] = [];
|
||||
for (let i = 0; i < number(count); i++)
|
||||
output.push(values[(Math.max(0, number(start)) + i) % values.length]);
|
||||
return output;
|
||||
}
|
||||
|
||||
function normalizeNavPath(value: unknown): string {
|
||||
let path = trim(value);
|
||||
try {
|
||||
path = decodeURIComponent(new URL(path, "https://template.local").pathname);
|
||||
} catch {
|
||||
/* use original */
|
||||
}
|
||||
if (!path.startsWith("/")) path = "/" + path;
|
||||
return toPublicPath(path.replace(/\/+$/, "") || "/");
|
||||
}
|
||||
export function navHref(value: unknown): string {
|
||||
const raw = trim(value);
|
||||
if (!raw) return "#";
|
||||
if (/^(?:https?:|\/\/|#|javascript:|mailto:|tel:)/i.test(raw)) return raw;
|
||||
const aliases: Record<string, string> = {
|
||||
home: "",
|
||||
playlet: "list",
|
||||
theme: "topic",
|
||||
video: "series",
|
||||
"today-update": "recently-added",
|
||||
popular: "popular-recommend",
|
||||
tag: "tags",
|
||||
"tag-list": "tags",
|
||||
"user-center": "profile",
|
||||
"purchased-series": "purchased",
|
||||
"search-results": "search",
|
||||
more: "more-list",
|
||||
};
|
||||
const match = /^([^?#]*)([?#][\s\S]*)?$/.exec(raw)!;
|
||||
const legacyPath = toLegacyPath(match[1].startsWith("/") ? match[1] : "/" + match[1]);
|
||||
try {
|
||||
decodeURIComponent(legacyPath);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
// Decode only the routing markers; a slug's escaped slash must stay inside its segment.
|
||||
const segments = legacyPath.replace(/^\/+|\/+$/g, "").split("/");
|
||||
const root = decodeURIComponent(segments[0]);
|
||||
let rewritten = false;
|
||||
if (
|
||||
root === "topic" &&
|
||||
segments.length > 1 &&
|
||||
!["page", "sort"].includes(decodeURIComponent(segments[1]))
|
||||
) {
|
||||
segments[0] = "topic-detail";
|
||||
rewritten = true;
|
||||
} else if (Object.hasOwn(aliases, root)) {
|
||||
segments[0] = aliases[root];
|
||||
rewritten = true;
|
||||
}
|
||||
return pageURL(
|
||||
(rewritten ? "/" + segments.filter(Boolean).join("/") : match[1]) +
|
||||
(match[2] || ""),
|
||||
);
|
||||
}
|
||||
export function navIsDownload(value: unknown, name: unknown): boolean {
|
||||
return (
|
||||
normalizeNavPath(navHref(value)) === "/app/download" ||
|
||||
["下载app", "downloadapp"].includes(
|
||||
trim(name).replace(/\s/g, "").toLowerCase(),
|
||||
)
|
||||
);
|
||||
}
|
||||
export function navIsActive(value: unknown, requestPath: unknown): boolean {
|
||||
const href = navHref(value);
|
||||
if (href === "#" || /^(?:https?:|javascript:|\/\/)/i.test(href)) return false;
|
||||
// Nested public namespaces must retain each menu's original activation boundary.
|
||||
// For example /account/settings is not the personal-home TabBar page and
|
||||
// /series/watch is not the browsable-series TabBar page.
|
||||
const nav = toLegacyPath(normalizeNavPath(href));
|
||||
const path = toLegacyPath(normalizeNavPath(requestPath));
|
||||
return nav === path || (nav !== "/" && path.startsWith(nav + "/"));
|
||||
}
|
||||
export const navDrawerIcon = (value: unknown): string =>
|
||||
"/static/images/" +
|
||||
({
|
||||
"/": "menu_sy",
|
||||
"/dramas": "menu_dj",
|
||||
"/series": "menu_jj",
|
||||
"/collections": "menu_zt",
|
||||
"/rankings": "menu_phb",
|
||||
}[normalizeNavPath(navHref(value))] || "menu_sy") +
|
||||
".svg";
|
||||
export const navHasMegaMenu = (value: unknown): boolean =>
|
||||
normalizeNavPath(navHref(value)) === "/dramas";
|
||||
export function listThemeSegment(value: unknown): string {
|
||||
const segments = normalizeNavPath(value).split("/").filter(Boolean);
|
||||
return segments[0] === "dramas" &&
|
||||
segments.length > 1 &&
|
||||
!["all", "page"].includes(segments[1])
|
||||
? segments[1]
|
||||
: "";
|
||||
}
|
||||
export function listUpdateStatus(value: unknown): number {
|
||||
const segments = normalizeNavPath(value).split("/").filter(Boolean);
|
||||
return segments[0] === "dramas" &&
|
||||
segments.length >= 4 &&
|
||||
[1, 2].includes(Number(segments[2]))
|
||||
? Number(segments[2])
|
||||
: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user