[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:
谢宇宁
2026-10-16 11:33:31 +07:00
co-authored by Claude Opus 5.5
commit d2ddb1c321
423 changed files with 52152 additions and 0 deletions
+366
View File
@@ -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;
}
+20
View File
@@ -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 "";
}
+11
View File
@@ -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;
+51
View File
@@ -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 : "";
}
+126
View File
@@ -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;
}
+24
View File
@@ -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);
}
+528
View File
@@ -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
}
]
}
+25
View File
@@ -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",
};
+14
View File
@@ -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.
+105
View File
@@ -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;
}
+10
View File
@@ -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";
+467
View File
@@ -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;
}