样式修改
This commit is contained in:
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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','站内其他页面'];
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user