样式修改

This commit is contained in:
谢宇宁
2026-10-16 14:10:10 +07:00
parent d2ddb1c321
commit 4afd5f9143
42 changed files with 89 additions and 1765 deletions
@@ -1,178 +0,0 @@
"use client";
import { useEffect, type ReactNode } from "react";
import type { Data } from "@/server/services/model";
import {
initializeTracker,
navigation,
pageClick,
pageView,
externalAd,
tracker,
track,
type TrackerBoot,
} from "./tracker";
function loadScript(src: string): Promise<void> {
return new Promise((resolve) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${src}"]`,
);
if (existing) {
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => resolve();
document.head.append(script);
});
}
export function TrackerProvider({
data,
children,
boot: explicitBoot,
}: {
data?: Data;
boot?: TrackerBoot;
children?: ReactNode;
}) {
useEffect(() => {
let disposed = false;
const boot: TrackerBoot = explicitBoot || {
appId: String(data?.trackerAppId || ""),
encryptedConfig:
data?.reportSdkConfig ??
data?.appConfig?.reportSdkConfig ??
data?.appConfig?.ReportSDKConfig ??
data?.appConfig?.reportSDKConfig,
appVersion: "1.0.0",
pageKey: data?.pageKey,
pageName: data?.pageName,
userType: data?.isVip ? "vip" : "normal",
uid: String(data?.userID || data?.userId || ""),
channel: data?.channel,
};
void loadScript("/static/common/web-sdk-v1.2.0.js?v=huakui-20261013-system")
.then(() => initializeTracker(boot))
.then(() => {
if (!disposed) pageView();
});
const google = "G-J58C18CTS0";
const yandex =
data?.isNavPage || data?.requestPath === "/navigation/" ? 112952789 : 112951899;
window.dataLayer ||= [];
window.gtag ||= (...args) => {
window.dataLayer!.push(args);
};
window.gtag("js", new Date());
window.gtag("config", google);
void loadScript(`https://www.googletagmanager.com/gtag/js?id=${google}`);
window.ym ||= Object.assign(
(...args: unknown[]) => {
window.ym!.a!.push(args);
},
{ a: [] as unknown[][], l: Date.now() },
);
window.ym(yandex, "init", {
ssr: true,
webvisor: true,
clickmap: true,
ecommerce: "dataLayer",
referrer: document.referrer,
url: location.href,
accurateTrackBounce: true,
trackLinks: true,
});
void loadScript(`https://mc.yandex.ru/metrika/tag.js?id=${yandex}`);
const click = (event: MouseEvent) => {
if (!(event.target instanceof Element)) return;
const target = event.target;
if (
!target.closest(
"[data-ad-close],[data-dialog-ad-close],[data-download-close],[data-login-close],[data-register-close],[data-recharge-close],[data-play-coin-close],[data-setting-password-close],[data-drawer-close]",
)
)
pageClick(event);
const anchor = target.closest<HTMLAnchorElement>(
"a[data-navigation-key],a[data-nav-key]",
);
if (anchor) navigation(anchor);
const ad = target.closest<HTMLElement>(
'[data-track-ad="external"][data-ad-id]',
);
if (ad) externalAd(ad, "click");
};
document.addEventListener("click", click, true);
const observer =
typeof IntersectionObserver === "undefined"
? null
: new IntersectionObserver(
(entries) => {
for (const entry of entries)
if (entry.isIntersecting) {
externalAd(entry.target as HTMLElement, "show");
observer?.unobserve(entry.target);
}
},
{ threshold: 0.1 },
);
const observed = new WeakSet<Element>();
const observe = () =>
document
.querySelectorAll(
'[data-track-ad="external"][data-ad-id]:not([data-ad-clone])',
)
.forEach((node) => {
if (!observed.has(node)) {
observed.add(node);
if (observer) observer.observe(node);
else externalAd(node as HTMLElement, "show");
}
});
observe();
const mutations = new MutationObserver(observe);
mutations.observe(document.body, { childList: true, subtree: true });
const external = (event: Event) => {
const detail = (
event as CustomEvent<{ element: HTMLElement; eventType: string }>
).detail;
if (detail?.element) externalAd(detail.element, detail.eventType);
};
const advertising = (event: Event) => {
const detail = (
event as CustomEvent<{ element: HTMLElement; eventType: string }>
).detail;
const node = detail?.element;
if (node)
void track("advertising", {
event_type: detail.eventType,
advertising_key: node.dataset.advertisingKey || "dialog_ad",
advertising_name: node.dataset.advertisingName || "弹窗广告",
advertising_id:
node.dataset.advertisingId || node.dataset.dialogAdId || "",
});
};
document.addEventListener("external-ad:track", external);
document.addEventListener("dialog-ad:track", advertising);
const flush = () => {
void tracker.flush();
};
window.addEventListener("pagehide", flush);
return () => {
disposed = true;
observer?.disconnect();
mutations.disconnect();
document.removeEventListener("click", click, true);
document.removeEventListener("external-ad:track", external);
document.removeEventListener("dialog-ad:track", advertising);
window.removeEventListener("pagehide", flush);
};
}, [data, explicitBoot]);
return children;
}
export function useTracker() {
return tracker;
}
export default TrackerProvider;
-479
View File
@@ -1,479 +0,0 @@
import { toLegacyPath } from "@/lib/routes";
export type Fields = Record<string, unknown>;
export interface TrackerBoot {
appId?: string;
encryptedConfig?: unknown;
appVersion?: string;
uid?: string;
channel?: string;
pageKey?: string;
pageName?: string;
userType?: string;
}
interface WebSDK {
init(options: Fields): unknown;
track: Record<string, (payload: Fields) => unknown>;
getCommonFields?(keys: string[]): Promise<Fields>;
flush?(): unknown;
setUid?(uid: string): void;
setChannel?(channel: string): void;
setContext?(fields: Fields): void;
}
declare global {
interface Window {
WebSDK?: WebSDK;
__TRACKER_BOOT__?: TrackerBoot;
__TRACKER__?: typeof tracker;
tracker?: typeof tracker;
__trackerCommonFields?: Record<string, string>;
__CHANNEL_CODE__?: string;
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
ym?: ((...args: unknown[]) => void) & { a?: unknown[][]; l?: number };
}
}
const str = (value: unknown, fallback = "") =>
String(value ?? "").trim() || fallback;
const number = (value: unknown, fallback = 0) =>
Number.isFinite(Number(value)) ? Number(value) : fallback;
const commonKeys = [
"sid",
"device_id",
"device",
"device_fingerprint",
"fp_version",
];
const serverKeys = [
"app_id",
"uid",
"channel",
"sid",
"client_ts",
"device",
"device_id",
"device_brand",
"device_model",
"device_fingerprint",
"fp_version",
"user_agent",
"sdk_version",
"app_version",
];
let activeBoot: TrackerBoot = {};
let finishReady: () => void;
const ready = new Promise<void>((resolve) => {
finishReady = resolve;
});
let initializing = false;
let common: Record<string, string> = Object.fromEntries(
commonKeys.map((key) => [key, ""]),
);
let commonReady: Promise<void> = Promise.resolve();
let page = { key: "internal_other", name: "站内其他页面" };
let userType = "normal";
let drama: Fields | null = null;
const impressions = new Set<string>();
export function normalizeUID(value: unknown): string {
const uid = str(value);
return uid.length >= 4 &&
uid.length <= 32 &&
/^[A-Za-z0-9_]+$/.test(uid) &&
!["u_1001", "stress_user", "u_lab"].includes(uid.toLowerCase())
? uid
: "";
}
export function normalizeChannel(value: unknown): string {
const channel = str(value);
return channel.length <= 50 &&
/^[A-Za-z0-9_.-]+$/.test(channel) &&
!["0", "self", "natural", "official", "channel", "unknown", "-"].includes(
channel.toLowerCase(),
)
? channel
: "organic";
}
export function inferPage(path: string) {
path = toLegacyPath(path);
if (path === "/") return { key: "home", name: "首页" };
const rules = [
["series-play", "series_play", "AI成人剧集播放页"],
["series", "series", "AI成人剧集"],
["play", "play", "短剧播放页"],
["detail", "detail", "短剧详情"],
["list", "list", "短剧列表"],
["rank", "rank", "排行榜"],
["topic-detail", "topic_detail", "专题详情"],
["topic", "topic", "专题列表"],
["tags", "tags", "标签详情"],
["search-mobile", "search", "搜索结果页"],
["search", "search", "搜索结果页"],
["history", "history", "观看历史"],
["favorites", "favorites", "我的收藏"],
["profile", "profile", "个人中心"],
["home-road", "home_road", "回家的路"],
];
const rule = rules.find(([prefix]) =>
new RegExp(`^/${prefix}(?:/|$)`).test(path),
);
return rule
? { key: rule[1], name: rule[2] }
: { key: "internal_other", name: "站内其他页面" };
}
export function initializeTracker(boot: TrackerBoot): Promise<void> {
const inferred = inferPage(location.pathname);
page = {
key: str(boot.pageKey, inferred.key),
name: str(boot.pageName, inferred.name),
};
userType = boot.userType === "vip" ? "vip" : "normal";
activeBoot = boot;
window.__TRACKER_BOOT__ = boot;
window.__TRACKER__ = tracker;
window.tracker = tracker;
if (!initializing) {
initializing = true;
const resolve = () => finishReady();
let attempts = 0;
const timer = setInterval(() => {
if (!window.WebSDK?.track && ++attempts < 100) return;
clearInterval(timer);
if (!window.WebSDK) {
resolve();
return;
}
Promise.resolve()
.then(() =>
window.WebSDK?.init({
appId: str(boot.appId),
uid: normalizeUID(boot.uid),
channel: normalizeChannel(boot.channel || window.__CHANNEL_CODE__),
encryptedConfig: str(boot.encryptedConfig),
appVersion: str(boot.appVersion, "1.0.0"),
disableAutoEvents: ["app_page_view", "page_click"],
}),
)
.catch(() => undefined)
.then(() => {
// Core events need only SDK initialization; device fingerprint loading must not stall them.
commonReady = (async () => {
try {
const fields = await window.WebSDK?.getCommonFields?.(
commonKeys.slice(),
);
common = Object.fromEntries(
commonKeys.map((key) => [key, str(fields?.[key])]),
);
} catch {
/* Empty common fields preserve the original degraded SDK contract. */
}
window.__trackerCommonFields = common;
window.dispatchEvent(
new CustomEvent("tracker:common-fields", { detail: common }),
);
})();
window.dispatchEvent(
new CustomEvent("tracker:ready", { detail: page }),
);
resolve();
});
}, 50);
}
return ready;
}
export async function track(method: string, payload: Fields): Promise<unknown> {
await ready;
try {
return await window.WebSDK?.track[method]?.(payload);
} catch {
return { sent: false, ok: false, reason: "exception" };
}
}
export function pageView(extra: Fields = {}) {
let referrer = { key: "direct", name: "直接访问" };
try {
const saved = sessionStorage.getItem("__tracker_last_page");
if (saved) referrer = JSON.parse(saved);
else if (document.referrer) {
const url = new URL(document.referrer);
referrer =
url.host === location.host
? inferPage(url.pathname)
: { key: "external", name: url.host };
}
} catch {
/* Storage is optional. */
}
const navigation = performance.getEntriesByType("navigation")[0] as
| PerformanceNavigationTiming
| undefined;
void track("appPageView", {
user_type: userType,
page_key: page.key,
page_name: page.name,
referrer_page_key: referrer.key,
referrer_page_name: referrer.name,
current_page_key: page.key,
current_page_name: page.name,
page_load_time: Math.max(
0,
Math.round(
navigation?.loadEventEnd ||
navigation?.domContentLoadedEventEnd ||
performance.now(),
),
),
recommend_trace_id: "",
...extra,
});
try {
sessionStorage.setItem("__tracker_last_page", JSON.stringify(page));
} catch {
/* Storage is optional. */
}
}
export function navigation(anchor: HTMLAnchorElement) {
const href = anchor.getAttribute("href");
if (!href || /^(#|javascript:)/i.test(href)) return;
let key = str(anchor.dataset.navigationKey || anchor.dataset.navKey);
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) {
const url = new URL(href, location.href);
const target = inferPage(url.pathname);
key =
url.origin !== location.origin
? "nav_external"
: target.key !== "internal_other"
? "nav_" + target.key
: "nav_" +
(url.pathname.split("/").filter(Boolean)[0] || "home")
.replace(/[^a-z0-9]+/gi, "_")
.slice(0, 48);
}
void track("navigation", {
navigation_key: key.toLowerCase(),
navigation_name: str(
anchor.dataset.navigationName || anchor.dataset.navName,
anchor.getAttribute("aria-label") ||
anchor.textContent?.replace(/\s+/g, " ") ||
key,
).slice(0, 255),
});
}
export function pageClick(event: MouseEvent) {
void track("pageClick", {
page_key: page.key,
page_name: page.name,
click_page_x: Math.trunc(event.clientX),
click_page_y: Math.trunc(event.clientY),
click_x_percent: innerWidth
? Math.round(
Math.max(0, Math.min(100, (event.clientX / innerWidth) * 100)),
)
: 0,
click_y_percent: innerHeight
? Math.round(
Math.max(0, Math.min(100, (event.clientY / innerHeight) * 100)),
)
: 0,
screen_width: screen.width,
screen_height: screen.height,
recommend_trace_id: "",
});
}
export function externalAd(node: HTMLElement, type: string) {
if (!["show", "click"].includes(type) || !node.dataset.adId) return;
const slots = Array.from(
document.querySelectorAll(
'[data-track-ad="external"][data-ad-id]:not([data-ad-clone])',
),
);
const slot = node.hasAttribute("data-ad-clone")
? slots.find(
(slot) => slot.getAttribute("data-ad-id") === node.dataset.adId,
)
: node;
const position = Math.max(1, slots.indexOf(slot as Element) + 1);
const interstitial = node.dataset.adType === "interstitial";
const payload = {
page_key: page.key,
page_name: page.name,
ad_slot_key: str(
node.dataset.adSlotKey,
`${page.key}_${interstitial ? "popup" : "content_banner"}_${position}`,
),
ad_slot_name: str(
node.dataset.adSlotName,
page.name + (interstitial ? "弹窗广告位" : `内容横幅第${position}位`),
),
ad_id: node.dataset.adId,
creative_id: str(node.dataset.creativeId),
ad_type: str(node.dataset.adType, "banner"),
};
const key = payload.ad_slot_key + "|" + payload.ad_id;
if (!impressions.has(key)) {
impressions.add(key);
void track("adImpression", payload);
}
if (type === "click" && node.dataset.adClickable === "1")
void track("adClick", payload);
}
export function setDramaContext(value: Fields | null) {
drama = value;
}
const behaviorNames: Record<string, string> = {
ep_view: "展示",
ep_play: "播放",
ep_pause: "暂停",
ep_forward: "快进",
ep_rewind: "快退",
ep_complete: "播放完成",
ep_share: "分享",
};
export function dramaEvent(
behavior: string,
video?: HTMLVideoElement | null,
options: { played?: number; forceProgress?: number } = {},
) {
if (!drama) return Promise.resolve();
const duration =
video && Number.isFinite(video.duration)
? Math.round(video.duration)
: number(drama.drama_duration);
const current = Math.round(video?.currentTime || 0);
return track("dramaEvent", {
...drama,
drama_duration: Math.max(0, duration),
play_duration: Math.max(0, Math.round(options.played || 0)),
play_progress:
options.forceProgress ??
(behavior === "ep_complete"
? 100
: duration > 0
? Math.round(Math.min(100, (current / duration) * 100))
: 0),
play_speed: Math.max(1, Math.round((video?.playbackRate || 1) * 100)),
play_behavior_key: behavior,
play_behavior_name: behaviorNames[behavior] || behavior,
});
}
export function dramaStatusChange(
episode: number,
videoID: string | number,
override?: string,
) {
if (!drama) return Promise.resolve();
const previous = number(drama.episode, 1);
const behavior =
override ||
(episode === previous + 1
? "ep_next"
: episode === previous - 1
? "ep_previous"
: "ep_switch");
if (!override && episode === previous && str(videoID) === str(drama.video_id))
return Promise.resolve();
const names: Record<string, string> = {
ep_auto_play: "自动播放",
ep_previous: "上一集",
ep_next: "下一集",
ep_switch: "切换剧集",
ep_unlock: "解锁",
};
return track("dramaStatusChange", {
media_id: drama.media_id,
drama_id: drama.drama_id,
drama_title: drama.drama_title,
episode: Math.max(1, episode),
video_id: str(videoID, str(drama.video_id)),
pre_episode: previous,
drama_behavior_key: behavior,
drama_behavior_name: names[behavior] || behavior,
});
}
/** Full per-request SDK snapshot for server-side drama events; header compatibility remains five fields. */
export async function serverContext(keys = serverKeys): Promise<Fields> {
await ready;
let fields: Fields = {};
try {
fields = (await window.WebSDK?.getCommonFields?.(serverKeys.slice())) || {};
} catch {
/* Cached device identifiers remain usable. */
}
const defaults: Fields = {
...common,
app_id: str(activeBoot.appId),
uid: normalizeUID(activeBoot.uid),
channel: normalizeChannel(activeBoot.channel || window.__CHANNEL_CODE__),
client_ts: Math.floor(Date.now() / 1000),
device_brand: "",
device_model: "",
user_agent: navigator.userAgent,
sdk_version: "1.2.0",
app_version: str(activeBoot.appVersion, "1.0.0"),
};
return Object.fromEntries(
keys.map((key) => [
key,
key === "client_ts"
? Math.floor(number(fields[key], number(defaults[key])))
: str(fields[key] ?? defaults[key]),
]),
);
}
export const tracker = {
init: initializeTracker,
pageView,
dramaEvent,
dramaStatusChange,
dramaUnlock: () =>
dramaStatusChange(
number(drama?.episode, 1),
str(drama?.video_id),
"ep_unlock",
),
externalAd,
advertising: (payload: Fields) => track("advertising", payload),
keywordSearch: (payload: Fields) => track("keywordSearch", payload),
keywordClick: (payload: Fields) => track("keywordClick", payload),
getDramaContext: () => ({ ...drama }),
getCommonFieldsSync: () => common,
getCommonFields: serverContext,
serverContext,
getCommonField: async (key: string) => {
await ready;
await commonReady;
return common[key] || "";
},
encodeSDKCommonHeader: (fields = common) =>
btoa(
unescape(
encodeURIComponent(
JSON.stringify(
Object.fromEntries(
commonKeys.map((key) => [key, str(fields[key])]),
),
),
),
),
),
getSDKCommonHeader: async () => {
await ready;
await commonReady;
return tracker.encodeSDKCommonHeader();
},
flush: async () => {
try {
await window.WebSDK?.flush?.();
} catch {
/* Analytics must never interrupt playback. */
}
},
setUid: (uid: unknown) => {
activeBoot.uid = normalizeUID(uid);
window.WebSDK?.setUid?.(activeBoot.uid);
},
setChannel: (channel: unknown) => {
activeBoot.channel = normalizeChannel(channel);
window.WebSDK?.setChannel?.(activeBoot.channel);
},
setContext: (context: Fields) => window.WebSDK?.setContext?.(context),
};
+4 -18
View File
@@ -8,23 +8,10 @@ import {
str,
type CatalogRecord,
} from "@/features/catalog/model";
function adProps(item: CatalogRecord, type: "banner" | "icon") {
const location = str(item, "Location"),
link = str(item, "Link");
function adProps(item: CatalogRecord) {
const link = str(item, "Link");
return {
href: safeHref(link),
"data-track-ad": "external",
"data-ad-clickable": link ? "1" : undefined,
"data-ad-id": first(get(item, "AdvertiseCode"), get(item, "ID")),
"data-creative-id": "",
"data-ad-location": location,
"data-ad-slot-key": first(get(item, "AdvertiseLocationCode"), location),
"data-ad-slot-name": first(
get(item, "AdvertiseLocationName"),
location ? `广告位${location}` : "",
),
"data-ad-title": first(get(item, "Title"), "推荐"),
"data-ad-type": type,
...(link ? { target: "_blank", rel: "sponsored nofollow noopener" } : {}),
};
}
@@ -37,7 +24,7 @@ export function AdBanners({ items }: { items: unknown }) {
<a
key={`${str(item, "ID")}-${index}`}
className="hk-promotion-card"
{...adProps(item, "banner")}
{...adProps(item)}
>
<EncryptedImage
loading="eager"
@@ -73,9 +60,8 @@ export function PromoGrid({ items }: { items: unknown }) {
<a
key={`${str(item, "ID")}-${index}`}
className="hk-promotion-grid__item"
{...adProps(item, "icon")}
{...adProps(item)}
tabIndex={clone ? -1 : undefined}
data-ad-clone={clone ? "1" : undefined}
>
<span className="hk-promotion-grid__thumb">
<EncryptedImage
+3 -18
View File
@@ -1,7 +1,6 @@
import type { ReactNode } from "react";
import type { Data } from "@/server/services/model";
import { SiteProvider } from "@/components/providers/SiteProvider";
import { TrackerProvider } from "@/components/analytics/TrackerProvider";
import { presentations } from "@/server/react/presentation";
import { SiteHeader } from "./SiteHeader";
import { SiteFooter } from "./SiteFooter";
@@ -20,23 +19,9 @@ export function SiteShell({
return (
<SiteProvider data={data} dialogs={presentation.header}>
<PagePresentation view={view} />
<TrackerProvider data={data}>
{presentation.header && <SiteHeader data={data} />} {children}
{presentation.footer && <SiteFooter data={data} />}{" "}
{presentation.header && <MobileTabbar data={data} />}
</TrackerProvider>
<noscript>
<div>
<img
src={
"https://mc.yandex.ru/watch/" +
(data.isNavPage ? "112952789" : "112951899")
}
style={{ position: "absolute", left: -9999 }}
alt=""
/>
</div>
</noscript>
{presentation.header && <SiteHeader data={data} />} {children}
{presentation.footer && <SiteFooter data={data} />}{" "}
{presentation.header && <MobileTabbar data={data} />}
</SiteProvider>
);
}
+3 -8
View File
@@ -13,7 +13,6 @@ import {
resolvePlayURL,
SHELL_BROWSER,
} from "@/lib/media/playback";
import { bindPlaybackAnalytics } from "./usePlaybackAnalytics";
import "xgplayer/dist/index.min.css";
import "@/styles/components/video-player.css";
export interface PlayerHandle {
@@ -23,14 +22,13 @@ export interface PlayerHandle {
}
export interface VideoPlayerProps {
url: string;
trackDrama?: boolean;
onPlaying?: () => void;
onEnded?: () => void;
autoPlay?: boolean;
}
export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
function VideoPlayer(
{ url, trackDrama = false, onPlaying, onEnded, autoPlay = false },
{ url, onPlaying, onEnded, autoPlay = false },
ref,
) {
const host = useRef<HTMLDivElement>(null),
@@ -58,8 +56,7 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
);
useEffect(() => {
let disposed = false,
instance: Player | null = null,
unbind = () => undefined as void;
instance: Player | null = null;
setError("");
void Promise.all([import("xgplayer"), import("xgplayer-hls")])
.then(([{ default: XGPlayer }, { default: HlsPlugin }]) => {
@@ -112,7 +109,6 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
void instance.play()?.catch(() => undefined);
}
const video = instance.video as HTMLVideoElement;
unbind = bindPlaybackAnalytics(video, trackDrama);
video.addEventListener("playing", () => {
setError("");
callbacks.current.onPlaying?.();
@@ -125,11 +121,10 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
});
return () => {
disposed = true;
unbind();
instance?.destroy();
if (player.current === instance) player.current = null;
};
}, [url, trackDrama, autoPlay, retry]);
}, [url, autoPlay, retry]);
return (
<>
<div
@@ -1,123 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import {
dramaEvent,
setDramaContext,
tracker,
type Fields,
} from "@/components/analytics/tracker";
export function useDramaContext(context: Fields | null) {
const viewed = useRef("");
useEffect(() => {
setDramaContext(context);
const key = context
? String(context.drama_id) + ":" + String(context.video_id)
: "";
if (context && viewed.current !== key) {
viewed.current = key;
void dramaEvent("ep_view");
}
return () => setDramaContext(null);
}, [context]);
}
const reporters = new WeakMap<HTMLVideoElement, (behavior: string) => void>();
export function reportPlaybackAction(
behavior: string,
video?: HTMLVideoElement | null,
) {
const report = video && reporters.get(video);
if (report) report(behavior);
else void dramaEvent(behavior, video);
}
/** Count wall-clock playing time, excluding buffering and seeking, as the original SDK expects. */
export function bindPlaybackAnalytics(
video: HTMLVideoElement,
enabled: boolean,
): () => void {
if (!enabled) return () => undefined;
let started = 0,
elapsed = 0,
position = 0,
seekFrom: number | null = null;
const settle = () => {
if (started) elapsed += Math.max(0, Date.now() - started);
started = 0;
};
const begin = () => {
if (!started) started = Date.now();
};
const emit = (behavior: string, keep = false, forceProgress?: number) => {
const played =
(elapsed + (started ? Math.max(0, Date.now() - started) : 0)) / 1000;
void dramaEvent(behavior, video, { played, forceProgress });
if (!keep) {
elapsed = 0;
if (started) started = Date.now();
}
};
reporters.set(video, emit);
const events: Record<string, () => void> = {
play: () => emit("ep_play"),
playing: begin,
waiting: settle,
pause: () => {
const pending = started || elapsed > 0;
settle();
if (!video.ended && !video.seeking && pending) emit("ep_pause");
},
timeupdate: () => {
if (!video.seeking) position = video.currentTime;
},
seeking: () => {
if (seekFrom == null) {
settle();
seekFrom = position;
}
},
seeked: () => {
if (seekFrom == null) return;
const from = seekFrom;
seekFrom = null;
position = video.currentTime;
if (Math.abs(position - from) >= 0.5)
emit(position > from ? "ep_forward" : "ep_rewind");
if (!video.paused && !video.ended) begin();
},
ended: () => {
settle();
emit("ep_complete", true);
emit("ep_play", false, 100);
},
};
Object.entries(events).forEach(([name, handler]) =>
video.addEventListener(name, handler),
);
const report = () => {
if (elapsed + (started ? Date.now() - started : 0) >= 500) {
settle();
emit("ep_play");
void tracker.flush();
setTimeout(() => void tracker.flush(), 50);
setTimeout(() => void tracker.flush(), 200);
}
};
const visibility = () => {
if (document.visibilityState === "hidden") report();
if (!video.paused && !video.ended) begin();
};
document.addEventListener("visibilitychange", visibility);
window.addEventListener("pagehide", report);
window.addEventListener("beforeunload", report);
return () => {
reporters.delete(video);
const pending = started || elapsed;
settle();
if (pending && !video.ended) emit("ep_pause");
Object.entries(events).forEach(([name, handler]) =>
video.removeEventListener(name, handler),
);
document.removeEventListener("visibilitychange", visibility);
window.removeEventListener("pagehide", report);
window.removeEventListener("beforeunload", report);
};
}
-32
View File
@@ -439,17 +439,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
const timer = setTimeout(onClose, 12000);
return () => clearTimeout(timer);
}, [onClose]);
useEffect(() => {
if (ready && ref.current)
document.dispatchEvent(
new CustomEvent("external-ad:track", {
detail: {
element: ref.current.querySelector("[data-track-ad]"),
eventType: "show",
},
}),
);
}, [ready, ref]);
const image = (
<EncryptedImage
eager
@@ -461,15 +450,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
onError={onClose}
/>
);
const click = () =>
document.dispatchEvent(
new CustomEvent("external-ad:track", {
detail: {
element: ref.current?.querySelector("[data-track-ad]"),
eventType: "click",
},
}),
);
return (
<div
ref={ref}
@@ -488,16 +468,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
className="hk-advertisement-dialog-panel"
data-dialog-ad-item=""
data-dialog-ad-id={id}
data-track-ad="external-dialog"
data-ad-id={id}
data-creative-id=""
data-ad-location={ad.location}
data-ad-slot-key={ad.advertiseLocationCode || ad.location || ""}
data-ad-slot-name={
ad.advertiseLocationName || "弹窗广告位" + (ad.location || "")
}
data-ad-type="interstitial"
data-ad-clickable={ad.link ? "1" : undefined}
role="dialog"
aria-modal="true"
aria-label={ad.title || "广告"}
@@ -511,7 +481,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
target="_blank"
rel="sponsored nofollow noopener"
data-dialog-ad-link=""
onClick={click}
>
{image}
</a>
@@ -541,7 +510,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
target="_blank"
rel="sponsored nofollow noopener"
data-dialog-ad-link=""
onClick={click}
>
<Icon
name="play_hollow"
-25
View File
@@ -5,7 +5,6 @@ import { EncryptedImage } from "@/components/media/EncryptedImage";
import { BackLink } from "@/components/catalog/Interactions";
import { RecommendationCard } from "@/components/catalog/Cards";
import { Pagination } from "@/components/catalog/Pagination";
import { track } from "@/lib/tracker";
import {
array,
first,
@@ -192,16 +191,6 @@ export function SearchPage({ data }: CatalogPageProps) {
keyword = str(data, "query"),
works = array(data.works);
useEffect(() => setQuery(keyword), [keyword]);
useEffect(() => {
if (keyword && num(data, "pageNum") <= 1)
void track("keywordSearch", {
keyword,
search_result_count: num(data, "resultCount"),
search_content_type: "",
search_trace_id: "",
search_id: "",
});
}, [keyword, data.pageNum, data.resultCount]);
return (
<>
<SearchHeader
@@ -245,20 +234,6 @@ export function SearchPage({ data }: CatalogPageProps) {
<RecommendationCard
key={`${str(item, "ID")}-${index}`}
item={item}
onClick={() => {
void track("keywordClick", {
keyword,
click_item_id: str(item, "ID"),
click_item_type_key: "short_drama",
click_item_type_name: "短剧",
click_position:
(Math.max(1, num(data, "pageNum")) - 1) *
Math.max(1, num(data, "pageSize")) +
index +
1,
search_trace_id: "",
});
}}
/>
))}
</div>
+1 -1
View File
@@ -9,7 +9,7 @@ export function CompliancePage({ data }: PageProps) {
value(config, "TGGroup") ||
value(config, "Telegram") ||
value(config, "TelegramAdd") ||
"https://t.me/huangguosan";
"https://t.me/jbd125";
const ref = useLegalNavigation();
return (
<div ref={ref}>
+1 -1
View File
@@ -9,7 +9,7 @@ export function DMCAPage({ data }: PageProps) {
value(config, "TGGroup") ||
value(config, "Telegram") ||
value(config, "TelegramAdd") ||
"https://t.me/huangguosan";
"https://t.me/jbd125";
const ref = useLegalNavigation();
return (
<div ref={ref}>
+4 -19
View File
@@ -1,5 +1,5 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { apiRequest } from "@/lib/api";
import { useSite } from "@/components/providers/SiteProvider";
import { Icon } from "@/components/ui/Icon";
@@ -7,7 +7,6 @@ import { BackLink } from "@/components/catalog/Interactions";
import { AdBanners } from "@/components/catalog/Promotions";
import { PlayLinks } from "@/components/catalog/PlayLinks";
import type { PlayerHandle } from "@/components/player/VideoPlayer";
import { useDramaContext } from "@/components/player/usePlaybackAnalytics";
import {
array,
chapterPlayURL,
@@ -23,8 +22,7 @@ import {
externalProps,
type Data,
} from "@/features/catalog/model";
import { dramaStatusChange } from "@/lib/tracker";
import { trackingContext, coinGate } from "./model";
import { coinGate } from "./model";
import { PlaybackStage } from "./PlaybackStage";
import { AppOnlyDialog } from "./AppOnlyDialog";
import { GuessLike, HotList, PlaybackShare, Related, Synopsis } from "./Shared";
@@ -47,12 +45,7 @@ export function DramaWatchPage({ data }: { data: Data }) {
watchSent = useRef(false);
const site = useSite(),
loggedIn = !!site.user,
name = displayName(p),
context = useMemo(
() => trackingContext(data.trackerDrama),
[data.trackerDrama],
);
useDramaContext(context);
name = displayName(p);
const themes = namedThemeTags(
get(p, "ThemeIDs"),
get(p, "ThemeNames"),
@@ -103,11 +96,7 @@ export function DramaWatchPage({ data }: { data: Data }) {
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="播放页顶栏">
<BackLink />
<div className="hk-page-layout-subheader__title">{name}</div>
<PlaybackShare
getVideo={() =>
mode === "main" ? player.current?.video() || null : null
}
/>
<PlaybackShare />
</header>
<div className="hk-page-layout hk-playback-page">
<main className="hk-page-main hk-playback-main">
@@ -208,9 +197,6 @@ export function DramaWatchPage({ data }: { data: Data }) {
<PlaybackShare
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
label
getVideo={() =>
mode === "main" ? player.current?.video() || null : null
}
/>
<a
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
@@ -272,7 +258,6 @@ export function DramaWatchPage({ data }: { data: Data }) {
key={chid}
className={`hk-playback-ep${chid === chapterID ? " hk-is-active" : ""}${coinGate(chapter) ? " hk-playback-ep--coin" : ""}`}
href={chapterPlayURL(get(p, "Slug"), id, chid)}
onClick={() => void dramaStatusChange(ep, chid)}
>
{ep}
</a>
-5
View File
@@ -18,7 +18,6 @@ import {
type Data,
} from "@/features/catalog/model";
import { mediaTime } from "./model";
import { dramaStatusChange } from "@/lib/tracker";
export interface PlaybackStageProps {
data: Data;
series?: boolean;
@@ -84,7 +83,6 @@ export function PlaybackStage({
<VideoPlayer
ref={playerRef}
url={url}
trackDrama={!series && !preview}
onPlaying={onPlaying}
autoPlay={autoPlay}
/>
@@ -183,9 +181,6 @@ export function PlaybackStage({
className="hk-playback-player__icon"
href={nextURL}
aria-label="下一集"
onClick={() =>
void dramaStatusChange(ep + 1, Number(data.nextChapterID))
}
>
<Icon name="chevron-right" />
</a>
@@ -5,7 +5,6 @@ import { BackLink } from "@/components/catalog/Interactions";
import { AdBanners } from "@/components/catalog/Promotions";
import { PlayLinks } from "@/components/catalog/PlayLinks";
import type { PlayerHandle } from "@/components/player/VideoPlayer";
import { useDramaContext } from "@/components/player/usePlaybackAnalytics";
import {
array,
first,
@@ -24,7 +23,6 @@ export function SeriesWatchPage({ data }: { data: Data }) {
title = str(video, "Title") || "视频",
player = useRef<PlayerHandle>(null),
[playing, setPlaying] = useState(false);
useDramaContext(null);
const play = () => player.current?.play(),
actions = "btn btn--ghost play-actions__btn play-actions__btn--icon";
return (
-4
View File
@@ -20,15 +20,12 @@ import {
type Data,
type NamedTag,
} from "@/features/catalog/model";
import { reportPlaybackAction } from "@/components/player/usePlaybackAnalytics";
export function PlaybackShare({
className = "page-subheader__btn",
label = false,
getVideo,
}: {
className?: string;
label?: boolean;
getVideo?: () => HTMLVideoElement | null;
}) {
const site = useSite();
return (
@@ -37,7 +34,6 @@ export function PlaybackShare({
type="button"
aria-label="分享"
onClick={async () => {
reportPlaybackAction("ep_share", getVideo?.());
try {
if (navigator.share)
await navigator.share({
-21
View File
@@ -1,26 +1,5 @@
import { get, number, type CatalogRecord } from "@/features/catalog/model";
import type { Fields } from "@/components/analytics/tracker";
export const integer = (value: unknown) => Math.trunc(number(value));
export function trackingContext(input: unknown): Fields | null {
if (!input || !number(get(input, "DramaID"))) return null;
const string = (key: string, fallback = "") =>
String(get(input, key) ?? "").trim() || fallback;
return {
media_id: string("MediaID"),
drama_id: string("DramaID"),
drama_title: string("DramaTitle", "未命名短剧"),
episode: Math.max(1, integer(get(input, "Episode"))),
total_episode: Math.max(1, integer(get(input, "TotalEpisode"))),
video_id: string("VideoID"),
drama_type_id: string("DramaTypeID", "default"),
drama_type_name: string("DramaTypeName", "默认分类"),
drama_content_type: "short_drama",
recommend_trace_id: string("RecommendTraceID"),
drama_tag_key: string("DramaTagKey", "default"),
drama_tag_name: string("DramaTagName", "默认标签"),
drama_duration: Math.max(0, number(get(input, "DramaDuration"))),
};
}
export function coinGate(chapter: CatalogRecord): boolean {
return (
!chapter.canWatch &&
+4 -126
View File
@@ -10,35 +10,14 @@ export class ApiError extends Error {
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 =
@@ -103,7 +82,6 @@ export function setAuthSession(user: Record<string, unknown>) {
} catch {
/* storage disabled */
}
if (user.userId != null) tracker()?.setUid?.(String(user.userId));
document.dispatchEvent(new CustomEvent("auth:login", { detail: { user } }));
}
export function getDeviceID(): string {
@@ -149,111 +127,11 @@ export function getChannel(): string {
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({
function requestHeaders(options: RequestOptions): Headers {
return 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> {
@@ -294,7 +172,7 @@ async function recoverSession(): Promise<boolean> {
});
return recovery;
}
/** Same-origin BFF request; prepared tracking headers and request_id survive one guest retry. */
/** Same-origin BFF request; retries once after guest session recovery. */
export async function apiRequest<T = Record<string, unknown>>(
path: string,
body?: unknown,
@@ -304,7 +182,7 @@ export async function apiRequest<T = Record<string, unknown>>(
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 headers = requestHeaders(options);
const payload =
body == null
? undefined
+1 -1
View File
@@ -79,7 +79,7 @@ const navigationFields = new Set([
// 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",
"installGuide", "deposit", "payment", "payMethods",
"jsonLD", "seoInfo", "canonicalURL", "paginationPrevURL", "paginationNextURL",
]);
-10
View File
@@ -1,10 +0,0 @@
"use client";
export {
track,
tracker,
dramaEvent,
dramaStatusChange,
setDramaContext,
externalAd,
} from "@/components/analytics/tracker";
export type { Fields, TrackerBoot } from "@/components/analytics/tracker";
+3 -3
View File
@@ -15,7 +15,7 @@ export function installationURL(value: string | undefined, deepLink = false): st
export function serverConfig() {
const environment = (process.env.APP_ENV || 'development') as AppEnvironment;
if (!['development', 'test', 'production'].includes(environment)) throw new Error('Invalid APP_ENV');
const upstreamURL = (process.env.UPSTREAM_API_URL || (environment === 'development' ? 'http://172.104.181.99:30331' : '')).trim().replace(/\/+$/, '');
const upstreamURL = (process.env.UPSTREAM_API_URL || (environment === 'development' ? 'https://webhuakuiapi.mfcg.me' : '')).trim().replace(/\/+$/, '');
if (upstreamURL && !/^https?:\/\//.test(upstreamURL)) throw new Error('UPSTREAM_API_URL must be an HTTP(S) URL');
return {
environment, upstreamURL,
@@ -23,10 +23,10 @@ export function serverConfig() {
siteURL: (process.env.SITE_URL || '').trim().replace(/\/+$/, ''),
imageCDNURL: (process.env.IMAGE_CDN_URL || '').trim(),
timeout: Math.max(100, Number(process.env.UPSTREAM_TIMEOUT_MS) || 20000),
trackerAppID: process.env.TRACKER_APP_ID || 'JHA-4001',
trackerEncryptedConfig: process.env.TRACKER_ENCRYPTED_CONFIG || '',
m3u8KeyFile: process.env.M3U8_KEY_FILE || './config/secrets/m3u8.key',
navigationOnly: process.env.NAVIGATION_ONLY === 'true',
/** Every Telegram contact entry points here, overriding the upstream tgGroup/telegram/telegramAdd. */
contactTelegramURL: installationURL(process.env.CONTACT_TELEGRAM_URL) || 'https://t.me/jbd125',
installation: {
iosStoreURL: installationURL(process.env.APP_IOS_STORE_URL),
iosProfileURL: installationURL(process.env.APP_IOS_PROFILE_URL),
+1 -8
View File
@@ -12,13 +12,6 @@ async function guessData(ctx:RequestContext,id:number,page:number):Promise<Data>
if(!arr(raw.list).length&&page>1)raw=await apiData(ctx,'/api/app/playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:6},{public:true});
raw.pageNum=num(raw.pageNum)||page;raw.pageSize=num(raw.pageSize)||6;return raw;
}
export function dramaTracking(playlet:Data,chapter:Data,chapterID:number,episode:number):Data{
const data:Data={MediaID:'',DramaID:num(playlet.id),DramaTitle:first(playlet.playName,playlet.title),Episode:Math.max(1,episode),TotalEpisode:num(playlet.totalEpisode)>0?num(playlet.totalEpisode):arr(playlet.playletChapterInfo).length||1,VideoID:num(chapter.id)>0?num(chapter.id):chapterID,DramaTypeID:'default',DramaTypeName:'默认分类',DramaTagKey:'default',DramaTagName:'默认标签',DramaDuration:Math.max(0,num(chapter.mediaTime)),RecommendTraceID:''};
const ids=Array.isArray(playlet.themeIds)?playlet.themeIds:[],names=Array.isArray(playlet.themeNames)?playlet.themeNames:[];
for(let i=0;i<ids.length;i++)if(num(ids[i])>0&&text(names[i])){data.DramaTypeID=String(ids[i]);data.DramaTypeName=text(names[i]);break;}
const tags=Array.isArray(playlet.tagIds)?playlet.tagIds:[],tagNames=Array.isArray(playlet.tagNames)?playlet.tagNames:[],good=tags.map((id:number,i:number)=>({id,name:text(tagNames[i])})).filter((tag:Data)=>num(tag.id)>0&&tag.name);
if(good.length){data.DramaTagKey=good.map((tag:Data)=>tag.id).join(',');data.DramaTagName=good.map((tag:Data)=>tag.name).join(',');}return data;
}
export async function play(ctx:RequestContext,p:Params):Promise<Data|null>{
if(p.ep&&integer(p.ep)<=0)return null;
await ensureGuestOnEnter(ctx);
@@ -32,7 +25,7 @@ export async function play(ctx:RequestContext,p:Params):Promise<Data|null>{
let unlockCost=num(chapter.costCoins)>0?num(chapter.costCoins):Math.max(0,num(chapter.singleCoins));if(unlockCost<=0){const paid=chapters.find(item=>!item.AllowWatch&&(item.needUnlock||num(item.payType)===1));unlockCost=num(paid?.costCoins)>0?num(paid?.costCoins):num(paid?.singleCoins);}
const [hot,guess]=await Promise.all([hotListData(ctx),guessData(ctx,num(playlet.id),positive(p.guessPage))]);
const seo=resolvePlayPageSEO(chapter,playlet,episode);
const hotList=arr(hot.list),data:Data={title:seo.title,navActive:'list',seoInfo:seo,playlet,chapter,chapterID,episode,canWatch,mainURL,previewURL,hasPreview:!!previewURL,unlockCost,balance:num(chapter.balance),balanceText:count(chapter.balance),nextChapterID:num(chapters[index+1]?.id),advitise:playlet.advitise,related:arr(playlet.relatedRecommendData),guessLike:arr(guess.list),guessPage:num(guess.pageNum),guessPageSize:num(guess.pageSize),guessTotal:num(guess.totalCount),hotList,hotMoreURL:'/list/all/0/2/',trackerDrama:dramaTracking(playlet,chapter,chapterID,episode),canonicalPath:playURL(playlet.slug,playlet.id)};
const hotList=arr(hot.list),data:Data={title:seo.title,navActive:'list',seoInfo:seo,playlet,chapter,chapterID,episode,canWatch,mainURL,previewURL,hasPreview:!!previewURL,unlockCost,balance:num(chapter.balance),balanceText:count(chapter.balance),nextChapterID:num(chapters[index+1]?.id),advitise:playlet.advitise,related:arr(playlet.relatedRecommendData),guessLike:arr(guess.list),guessPage:num(guess.pageNum),guessPageSize:num(guess.pageSize),guessTotal:num(guess.totalCount),hotList,hotMoreURL:'/list/all/0/2/',canonicalPath:playURL(playlet.slug,playlet.id)};
data.playResponse={playlet,chapter,chapterID,canWatch,mainURL,previewURL,hasPreview:!!previewURL,guessLike:guess,hotList};
return model(data);
}
+9 -5
View File
@@ -14,12 +14,16 @@ export function searchTrends(items: unknown): Data[] {
}
export async function appConfigData(ctx:RequestContext):Promise<Data>{
const raw=await apiData(ctx,'/api/app/config/info',{}, {public:true,ttl:60000,stale:true,fallback:{config:{},newAddressInfo:{},advitise:{},seoInfo:{},seoNavInfo:{},navPageList:[]}});
const clean=(v:unknown)=>text(v).replace(/\\+$/,'').trim(), cfg:Data={...raw.config},addr=raw.newAddressInfo||{};
const clean=(v:unknown)=>text(v).replace(/\\+$/,'').trim(), cfg:Data={...raw.config},addr:Data={...raw.newAddressInfo};
for(const [key,v] of Object.entries(cfg))if(typeof v==='string')cfg[key]=clean(v);
const email=(v:unknown)=>{let value=text(v);while(/^(?:mailto:|https?:\/\/)/i.test(value))value=value.replace(/^(?:mailto:|https?:\/\/)/i,'').trim();return value;};
cfg.qq=text(raw.config?.qq);cfg.weiChart=text(raw.config?.weiChart);
cfg.gmailAddr=email(cfg.gmailAddr);
const vm:Data={config:cfg,seoInfo:normalizeSEO(raw.seoInfo),seoNavInfo:raw.seoNavInfo||{},navPageList:arr(raw.navPageList).map(item=>({line:num(item.line),url:clean(item.url)})).filter(item=>item.url),reportSdkConfig:first(raw.reportSdkConfig,process.env.TRACKER_ENCRYPTED_CONFIG),advitise:raw.advitise||{}};
// All Telegram contact entries, including a Telegram customer-service link, use one configured account.
const telegram=serverConfig().contactTelegramURL,isTelegram=(v:unknown)=>/^https?:\/\/(?:www\.)?(?:t|telegram)\.me\//i.test(clean(v));
for(const key of ['tgGroup','telegram','telegramAdd'])cfg[key]=addr[key]=telegram;
if(isTelegram(cfg.customerServiceHost)||isTelegram(addr.customerServiceHost))cfg.customerServiceHost=addr.customerServiceHost=telegram;
const vm:Data={config:cfg,seoInfo:normalizeSEO(raw.seoInfo),seoNavInfo:raw.seoNavInfo||{},navPageList:arr(raw.navPageList).map(item=>({line:num(item.line),url:clean(item.url)})).filter(item=>item.url),advitise:raw.advitise||{}};
for(const key of ['foreverUrlAddr','newUrlAddr','newUrlAddr1','newUrlAddr2','gmailAddr','qq','tgGroup','telegram','potaoGroup','customerServiceHost','appAddr','officialAppAddr','pdfAddr','twitterAddr'])vm[key]=first(cfg[key],key==='gmailAddr'?email(addr[key]):clean(addr[key]));
const siteURL=serverConfig().siteURL;
if(siteURL)cfg.foreverUrlAddr=vm.foreverUrlAddr=siteURL;
@@ -47,7 +51,7 @@ export async function loadSharedData(ctx:RequestContext,data:Data={}):Promise<Da
if(!data.categoryList.length)data.categoryList=model(defaultCategories.map(item=>({...item})));
data.themeList??=themes;data.homeConfigPlaylets??=activeConfigs(home).filter(config=>num(config.type)===1);
data.searchTrends??=model(searchTrends(hot.hotSearch).slice(0,8));
data.requestPath??=ctx.pathname;data.trackerAppId=serverConfig().trackerAppID;
data.requestPath??=ctx.pathname;
const user=ctx.user as Data|null;
data.isLoggedIn=!!ctx.token;
data.isRegistered=isRegistered(ctx);
@@ -64,10 +68,10 @@ export async function loadSharedData(ctx:RequestContext,data:Data={}):Promise<Da
data.recharge??=num(user?.recharge, num(ctx.cookies.get('recharge')));
data.rechargeText??=count(data.recharge);data.vipType??=num(user?.vipType,num(ctx.getCookie('vipType')));
}else{for(const key of ['userName','avatar','email','userId','recharge','rechargeText','vipType'])delete data[key];}
const rule=trackerPage(ctx.pathname);data.pageKey??=rule[0];data.pageName??=rule[1];
const rule=pageIdentity(ctx.pathname);data.pageKey??=rule[0];data.pageName??=rule[1];
return model(applyBrandCopy(data));
}
export function trackerPage(path:string):[string,string]{
export function pageIdentity(path:string):[string,string]{
path=toLegacyPath(path);
const routes:[string,string,string][]=[['series-play','series_play','AI成人剧集播放页'],['series','series','AI成人剧集'],['play','play','短剧播放页'],['list','list','短剧列表'],['recently-added','recently_added','最新短剧'],['popular-recommend','popular_recommend','热门推荐'],['rank','rank','排行榜'],['topic-detail','topic_detail','专题详情'],['topic','topic','专题列表'],['tags','tags','标签详情'],['more-series-list','more_series_list','成人剧集列表'],['more-list','more_list','短剧扩展列表'],['search-mobile','search','搜索结果页'],['search','search','搜索结果页'],['history','history','观看历史'],['favorites','favorites','我的收藏'],['purchased','purchased','已购剧集'],['profile','profile','个人中心'],['setting','setting','设置'],['recharge','recharge','充值中心'],['feedback','feedback','问题反馈'],['home-road','home_road','回家的路'],['help','help','帮助中心'],['about','about','关于我们'],['terms','terms','用户协议'],['privacy','privacy','隐私政策'],['2257','compliance_2257','2257合规声明'],['dmca','dmca','DMCA版权声明'],['login','login','登录'],['register','register','注册']];
if(path==='/')return ['home','首页'];const rule=routes.find(([prefix])=>path==='/'+prefix||path.startsWith('/'+prefix+'/'));return rule?[rule[1],rule[2]]:['internal_other','站内其他页面'];
-156
View File
@@ -31,160 +31,6 @@ const excluded = new Set([
"x-tracker-request-id",
"x-tracker-event-name",
]);
const trackerEvents: 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 commonKeys = [
"app_id",
"uid",
"channel",
"sid",
"client_ts",
"device",
"device_id",
"device_brand",
"device_model",
"device_fingerprint",
"fp_version",
"user_agent",
"sdk_version",
"app_version",
];
const dramaKeys = [
"media_id",
"drama_id",
"episode",
"video_id",
"recommend_trace_id",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function exactKeys(
value: unknown,
keys: string[],
): value is Record<string, unknown> {
return (
isRecord(value) &&
Object.keys(value).length === keys.length &&
keys.every((k) => Object.hasOwn(value, k))
);
}
export function canonicalTrackerContext(
ctx: RequestContext,
): Record<string, string> {
const id = ctx.headers.get("x-tracker-request-id") || "";
const event = ctx.headers.get("x-tracker-event-name") || "";
const packed = ctx.headers.get("x-dc-request-context") || "";
if (
ctx.request.method !== "POST" ||
trackerEvents[ctx.pathname] !== event ||
!/^[a-f\d]{32}$/i.test(id) ||
!/^[A-Za-z\d_-]{1,8192}$/.test(packed)
)
return {};
try {
const raw = Buffer.from(packed, "base64url");
if (raw.length > 6144) return {};
const data: unknown = JSON.parse(raw.toString());
if (
!isRecord(data) ||
data.v !== 1 ||
data.request_id !== id ||
data.event_name !== event ||
!exactKeys(data.common, commonKeys) ||
!exactKeys(data.drama, dramaKeys)
)
return {};
const c = data.common;
const d = data.drama;
const stringField = (
o: Record<string, unknown>,
k: string,
max: number,
required = false,
) =>
typeof o[k] === "string" &&
[...(o[k] as string)].length <= max &&
(!required || o[k] !== "");
if (
!stringField(c, "app_id", 50, true) ||
!/^[A-Z]{1,6}-\d{1,6}$/.test(String(c.app_id)) ||
c.app_id !== serverConfig().trackerAppID
)
return {};
if (
!stringField(c, "uid", 32) ||
(c.uid &&
(!/^[A-Za-z\d_]{4,32}$/.test(String(c.uid)) ||
["u_1001", "stress_user", "u_lab"].includes(String(c.uid))))
)
return {};
if (
!stringField(c, "channel", 50) ||
(c.channel &&
(!/^[A-Za-z\d_.-]+$/.test(String(c.channel)) ||
["{}", "0", "-", "unknown"].includes(
String(c.channel).toLowerCase(),
)))
)
return {};
if (
!Number.isInteger(c.client_ts) ||
Number(c.client_ts) < 1767196800 ||
Number(c.client_ts) > 9999999999
)
return {};
if (
!stringField(c, "device", 30) ||
(c.device &&
!["Android", "iOS", "PC", "OTHER"].includes(String(c.device)))
)
return {};
for (const [key, max, required] of [
["sid", 50, false],
["device_id", 50, true],
["device_brand", 100, false],
["device_model", 100, false],
["device_fingerprint", 128, false],
["fp_version", 128, false],
["user_agent", 4096, false],
["sdk_version", 128, false],
["app_version", 128, false],
] as const)
if (!stringField(c, key, max, required)) return {};
for (const [key, max, required] of [
["media_id", 512, false],
["drama_id", 512, true],
["video_id", 512, false],
["recommend_trace_id", 2048, false],
] as const)
if (!stringField(d, key, max, required)) return {};
if (!Number.isSafeInteger(d.episode) || Number(d.episode) <= 0) return {};
const canonical = Buffer.from(
JSON.stringify({
v: 1,
request_id: id,
event_name: event,
common: c,
drama: d,
}),
);
if (canonical.length > 6144) return {};
return {
"X-DC-Request-Context": canonical.toString("base64url"),
"X-Tracker-Request-Id": id,
"X-Tracker-Event-Name": event,
};
} catch {
return {};
}
}
export function buildXUserAgent(ctx: RequestContext): string {
const supplied = ctx.headers.get("x-user-agent")?.trim();
if (supplied) return supplied;
@@ -289,8 +135,6 @@ export function buildUpstreamHeaders(
result.delete("Cookie");
}
if (options.forwardCookies === false) result.delete("Cookie");
for (const [name, value] of Object.entries(canonicalTrackerContext(ctx)))
result.set(name, value);
return result;
}
export async function upstreamFetch(