样式修改

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
+3 -3
View File
@@ -13,19 +13,19 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
- 本仓库是花魁短剧原生 Next.js App Router + 严格 TypeScript 项目,原站参考目录为 `/Users/mac/Desktop/work/web/jha-1045-hgdjnet-web`。页面必须使用 React/TSX;不要恢复 Go/HTML 模板解析或 `dangerouslySetInnerHTML` 页面适配器。
- 保持原站功能、PC/H5 界面和完整 SEO 行为:metadata、JSON-LD、canonical、分页、robots、llms、sitemap、站点验证、IndexNow,以及重定向/404/503 的状态和索引规则。
- 保留服务端 API 转发、请求头和 Cookie 透传、游客会话恢复、多条 Set-Cookie 和私有响应禁止缓存。不要把鉴权或 SDK 私密配置泄露到公开数据、日志或图片 CDN。
- 页面按 `src/features/{catalog,account,content,playback}` 组织;页头/页尾、卡片、图片解密、播放器、鉴权、支付及埋点优先复用 `src/components`、`src/lib`、`src/server` 中的公共实现。
- 页面按 `src/features/{catalog,account,content,playback}` 组织;页头/页尾、卡片、图片解密、播放器、鉴权及支付优先复用 `src/components`、`src/lib`、`src/server` 中的公共实现。
- 业务 CSS 统一在 `src/styles/{foundations,components,pages,overrides}`,通过 TSX 顶层导入并由 Next 编译为哈希资源;不要恢复 `/static/css` 或手工 stylesheet 链接。业务类名使用 `hk-` 与语义组件名称,xgplayer 第三方类名保留;页面样式使用零特异性 `html[data-hk-page-styles]` 隔离,PWA 最后加载。客户端切页由 `PagePresentation` 同步作用域与页面 body class,不可覆盖滚动锁/PWA/临时状态。
- 2026-10-14 用户明确重新启用语义新路径并同步全部引用,覆盖此前回退要求。代码入口和对外 URL 使用 `/dramas`、`/drama`、`/series/watch`、`/collections`、`/collection`、`/account`、`/navigation` 等;旧有效页面 GET/HEAD 一次 301 到新路径,规则统一在 `src/lib/routes.ts`。页面文件使用 `HomePage`、`BrowsePages`、`DiscoveryPages`、`DramaWatchPage`、`SeriesWatchPage`、`SettingsPage`、`SiteNavigationPage`,服务端播放模块为 `src/server/pages/playback.ts`。站内链接、metadata、分页、JSON-LD、llms、安装默认入口使用新路径,API/媒体签名/外链保持原合同与编码。`npm run routes:export -- --admin ../hkdj-web-admin` 从实际路由生成全部新旧对照及后台路径清单,`routes:check` 核验无漂移;后台导航分类支持选择静态入口或自由输入,参数模板不作为可选链接。后端 `hkdj-web-server` 的 h5path、sitemap 与 SEO 推送源同步新路径,Next 仍原字节服务 XML,不在请求中改写生成结果。
- robots/llms/sitemap 完整遵循参考 Go 合同:robots/llms 读取公共配置并使用原永久域名/代理 Host 规则,robots 保留四个磁盘候选与内嵌文本的优先级及原字符串处理。sitemap 只服务后端生成的文件;开发 `SITEMAP_DIR=./template/pages/sitemaps`,测试/生产 `SITEMAP_DIR=../template/pages/sitemaps`,仅作为部署目录映射。测试 Node 工作目录 `/server/hkdj_net/h5/next`,真实产物在父级 `h5/template/pages/sitemaps`,生产遵循相同目录关系;索引其次读取 `./template/pages/sitemap.xml`。请求不读取远端 sitemap、不生成静态回退或合成索引、不改写 XML;缺失/非法文件走原完整 404 页面及共享数据加载。开发仅可显式 `npm run sitemap:sync` 通过可信 SSH 只读同步索引引用的完整分片,保留原字节/mtime,不修改远端、不删除未知文件;开发 XML 忽略提交,页面 trace 排除;Next 16.4 Node Proxy trace 仍可能带入原始 standalone,必须通过 bundle 脚本在隔离 dist 精确清理 XML,并逐层 lstat 拒绝符号链接父目录,保留 robots/key/未知文件和后端源目录。导航模式与 Go `DHInit` 一致,不注册抓取文件/验证/API/媒体路由。发布保留后端 XML,持续写入的原目录应外置挂载,不能随前端包清空。
- 成功抓取文件通过原生 Route Handler 返回;Proxy 仅选择成功出口或准备并改写完整 TSX 404,不能直接从 Proxy 输出文件,否则 Next 会过滤 Content-Length。保留 Go ServeFile 的 MIME、原字节、Last-Modified、Content-Length、Accept-Ranges、304/412/206/416、If-Range/多段 Range 和 HEAD;Next compress=false 避免框架自动压缩改变文件响应,外层代理可沿用原传输配置。
- 播放器通过 npm 使用 `xgplayer` 与 `xgplayer-hls`;不要恢复 CDN 脚本版 DPlayer/Hls/Swiper 或旧业务脚本。
- `/h5/m3u8/**`、`/enkey`、`/newenkey` 保留服务端媒体转发:签名 query 不重组,UA/IP/Range 保留,上游重定向按原 Go 客户端跟随。签名媒体请求按原合同不附登录 Cookie/Authorization;上游业务 JSON 错误返回 502,不能当播放清单成功响应。
- `config/development.env`、`config/test.env`、`config/production.env` 分别管理三套环境,本地覆盖使用忽略提交的 `*.local.env`;进程环境优先。开发 API 按用户指定保持 `http://172.104.181.99:30331`,不要擅自切回原站 25319。
- `config/development.env`、`config/test.env`、`config/production.env` 分别管理三套环境,本地覆盖使用忽略提交的 `*.local.env`;进程环境优先。2026-10-16 起开发 API 按用户指定使用 `https://webhuakuiapi.mfcg.me`(替代原 `http://172.104.181.99:30331`),不要擅自切回原站 25319 或旧地址。
- SSR 请求准备在 `src/proxy.ts` 完成,通过 `src/server/react/handoff.ts` 向 React 交接;保留同请求 Cookie/页面数据一致性。大快照使用私有临时文件,部署多实例时须处理交接存储共享或实例亲和。
- 所有“黄果短剧”统一为“花魁短剧”。占位图品牌 logo 透明度固定为 `0.2`。Figma 文件为 `1vUq4wf5xOs8ZOlmt26Q3O`,读取设计使用本地 `FIGMA_TOKEN` 调用 REST API,不能打印令牌。
- 本地 `npm run dev` 默认使用 Webpack;该项目已确认 Next 16.4 Turbopack 开发 HMR 存在循环刷新,未经验证不要改回默认。Charles 调试使用 `npm run dev:charles`,不改业务接口地址。
- 验证至少覆盖 `npm run check` 和受影响的浏览器链路;图片必须检查真实接口返回的地址、解密后的自然尺寸、背景图及最终可见性(包含 computed opacity),模拟图片测试通过不能证明 CDN 可用。首页 Hero 封面成功/失败均需结束加载状态,保留淡入和失败占位;不能遗漏 CSS 依赖的就绪标记。
- 图片地址由公共 ImageProvider/解密组件统一解析:优先上游 `imgCdn`、`imgAppUsCdn`,再用环境 `IMAGE_CDN_URL`。当前开发 API 缺 CDN,开发环境单独配置已核验的 CloudFront 来源;不要把 API 地址当作图片 CDN。保留原 XOR 协议并验证图片文件头,错误响应不能缓存为图片。
- 图片地址由公共 ImageProvider/解密组件统一解析:优先上游 `imgCdn`、`imgAppUsCdn`,再用环境 `IMAGE_CDN_URL`。开发 API 已返回 imgCdn/imgAppUsCdn,测试 API 30331 缺 CDN,环境仍配置已核验的 CloudFront 作为备用;不要把 API 地址当作图片 CDN。保留原 XOR 协议并验证图片文件头,错误响应不能缓存为图片。
- 提交遵循用户全局 AGENTS 规则:无前缀请求使用 `[Ai] <type>: <中文主题>`,正文具体说明文件/模块/资源;仅确有 Claude 共创时添加 Claude trailer。
- PWA 安装配置使用 `src/app/manifest.ts` 与 `src/lib/pwa/config.ts`:fullscreen 优先、standalone 回退、orientation any;全局 viewport-fit cover 与 Apple 独立窗口标签不可只放播放器页。Next 16 的 appleWebApp.capable 仅生成非 Apple 前缀标签,需保留 Metadata.other 中的 apple-mobile-web-app-capable。
- `/manifest.webmanifest` 与 `/sw.js` 必须绕过 SSR Proxy/游客初始化,包括 NAVIGATION_ONLY;Worker 源码为 TypeScript,开发/构建自动生成 public/sw.js,不拦截或缓存鉴权 API、HTML/RSC、签名媒体及密钥。PWA 安全区样式最后加载,普通浏览器不改变原布局;验证 `npm run test:pwa` 与实际 SSR 清单/标签/资源,真实设备安装需 HTTPS。
+14 -14
View File
@@ -1,6 +1,6 @@
# 花魁短剧 · Next.js / TypeScript
从 `jha-1045-hgdjnet-web` 迁移的独立 Next.js 16 项目。运行时仅需要 Node.js,不依赖 Go、原项目目录或原 Go 服务。保留内容浏览页面、原 URL、游客会话、播放器和统计交互(登录、账户与充值功能已移除,旧账户地址 301 到首页);PC/H5 界面按花魁短剧 Figma 设计更新。
从 `jha-1045-hgdjnet-web` 迁移的独立 Next.js 16 项目。运行时仅需要 Node.js,不依赖 Go、原项目目录或原 Go 服务。保留内容浏览页面、原 URL、游客会话和播放器(登录、账户与充值功能已移除,旧账户地址 301 到首页);PC/H5 界面按花魁短剧 Figma 设计更新。
## 启动
@@ -68,20 +68,18 @@ Node 原生代理说明:[Node.js HTTP 文档](https://nodejs.org/download/rele
配置优先级:进程环境变量 > `config/<环境>.local.env` > `config/<环境>.env`。本地覆盖文件不会提交。`APP_ENV` 选择业务环境;测试环境的优化构建仍使用 Next.js 要求的 `NODE_ENV=production`。
开发 `UPSTREAM_API_URL` 使用 `http://172.104.181.99:30331`;测试服务与 API 同机,使用 `http://127.0.0.1:30331`,测试应用端口为 `30334`。本地运行测试环境且 API 不在本机时,可在 `config/test.local.env` 覆盖上游地址。正式配置使用同机 API `http://127.0.0.1:26931`、服务端口 `26935`,可由部署环境或 `config/production.local.env` 覆盖。构建不访问上游,启动时检查地址。
开发 `UPSTREAM_API_URL` 使用 `https://webhuakuiapi.mfcg.me`;测试服务与 API 同机,使用 `http://127.0.0.1:30331`,测试应用端口为 `30334`。本地运行测试环境且 API 不在本机时,可在 `config/test.local.env` 覆盖上游地址。正式配置使用同机 API `http://127.0.0.1:26931`、服务端口 `26935`,可由部署环境或 `config/production.local.env` 覆盖。构建不访问上游,启动时检查地址。
```dotenv
UPSTREAM_API_URL=https://your-api.example
UPSTREAM_TIMEOUT_MS=20000
IMAGE_CDN_URL=
SITEMAP_DIR=../template/pages/sitemaps
TRACKER_APP_ID=JHA-4001
TRACKER_ENCRYPTED_CONFIG=
```
这些变量仅供服务端使用。`config/info.reportSdkConfig` 优先于本地埋点密文配置。官方导航独立进程可以使用 `NAVIGATION_ONLY=true PORT=26813 npm start`,开放 `/`、`/navigation/` 和静态资源,原 `/nav/`、`/dh/` 一次 301 到 `/navigation/`。与原 Go `DHInit` 一致,导航模式不注册 robots、llms、sitemap、验证文件及业务接口,这些地址返回原站 404 页面。
这些变量仅供服务端使用。官方导航独立进程可以使用 `NAVIGATION_ONLY=true PORT=26813 npm start`,开放 `/`、`/navigation/` 和静态资源,原 `/nav/`、`/dh/` 一次 301 到 `/navigation/`。与原 Go `DHInit` 一致,导航模式不注册 robots、llms、sitemap、验证文件及业务接口,这些地址返回原站 404 页面。
图片 CDN 与 API 独立配置。上游 `config/info.config.imgCdn`、`imgAppUsCdn` 按顺序提供图片来源,`IMAGE_CDN_URL` 提供环境级备用来源。开发与测试共用 API 30331,该接口返回裸 `v3/image/...` 且 CDN 配置为空,因此两套环境均显式配置原站已验证可用的 CloudFront 图片线路;生产独立配置。修改环境配置后需重启服务。
图片 CDN 与 API 独立配置。上游 `config/info.config.imgCdn`、`imgAppUsCdn` 按顺序提供图片来源,`IMAGE_CDN_URL` 提供环境级备用来源。开发 API 返回 `imgCdn`/`imgAppUsCdn`;测试 API 30331 返回裸 `v3/image/...` 且 CDN 配置为空,因此两套环境均显式配置原站已验证可用的 CloudFront 图片线路作为备用;生产独立配置。修改环境配置后需重启服务。
公共图片组件统一解析相对路径,覆盖封面、横幅、背景和头像;相对路径不会随页面目录变化。图片保持原首 100 字节 XOR 协议,也兼容未加密的有效图片;校验文件头后才缓存,HTTP、证书、JSON 错误或超时会尝试配置的备用 CDN。完整外域 URL 保持原签名,只有属于已配置 CDN 的资源才允许切换相同对象路径;图片请求不向外域发送本站 Cookie/Authorization。
@@ -91,15 +89,15 @@ TRACKER_ENCRYPTED_CONFIG=
| --- | --- |
| `src/app/` | 原生 App Router TSX 页面、Metadata 与 BFF/媒体 Route Handlers |
| `src/features/` | 短剧目录、播放、账号与内容页面组件 |
| `src/components/` | 页头、页尾、卡片、播放器、图片、弹窗、认证、支付、埋点共用组件 |
| `src/components/` | 页头、页尾、卡片、播放器、图片、弹窗、认证、支付共用组件 |
| `src/styles/` | 基础、公共组件、页面和 PWA 覆盖样式,通过 TSX 导入并由 Next 编译 |
| `src/lib/` | 类型化浏览器请求、Cookie、搜索/展示方法与埋点上下文 |
| `src/lib/` | 类型化浏览器请求、Cookie、搜索/展示方法 |
| `src/proxy.ts`、`src/server/react/` | SSR 数据、状态、响应 Cookie 和 React 数据交接 |
| `src/server/pages/` | 原路由参数、分页、页面数据组合与播放/账号场景 |
| `src/server/services/` | 上游 DTO、展示模型、公共配置和请求级复用 |
| `src/server/upstream.ts`、`auth.ts` | 上游请求、请求头/Cookie 透传、登录与游客恢复 |
| `src/server/seo/` | 原 SEO 策略、TDK、canonical、JSON-LD 和抓取文件 |
| `public/static/` | 本地 Figma 品牌/图标、分享图与私有埋点 SDK |
| `public/static/` | 本地 Figma 品牌/图标、分享图 |
所有业务页面由 TSX 组件服务端渲染,交互使用 React hooks 和 Client Components。App Router 的 `generateMetadata` 输出完整首屏 SEO;JSON-LD 使用安全序列化的独立脚本。没有 HTML 模板运行时或注入整页 HTML 的适配层。
@@ -206,7 +204,7 @@ SSR 在 Proxy 中完成上游数据、游客恢复和 Cookie 写回,再交给
公共配置可短期复用;登录用户内容仅在单次请求内复用,页面响应使用 `private, no-store`。必要内容接口临时不可用返回 503、`Retry-After` 和 noindex;真实缺失的实体及越界分页仍返回 404。
播放器通过 `package.json` 管理 `xgplayer`、`xgplayer-hls`,按需加载官方模块和样式,支持 MP4/HLS、加密密钥、预告切换、章节播放、全屏、解锁、评论及播放埋点。原 DPlayer、全局 Hls 和浏览器业务 JS 已删除。加密图片、二维码分别封装为 TSX 组件,二维码使用 npm `qrcode`;占位图 Logo 透明度保持 0.2。
播放器通过 `package.json` 管理 `xgplayer`、`xgplayer-hls`,按需加载官方模块和样式,支持 MP4/HLS、加密密钥、预告切换、章节播放、全屏、解锁及评论。原 DPlayer、全局 Hls 和浏览器业务 JS 已删除。加密图片、二维码分别封装为 TSX 组件,二维码使用 npm `qrcode`;占位图 Logo 透明度保持 0.2。
## SEO 保留范围
@@ -217,7 +215,7 @@ SSR 在 Proxy 中完成上游数据、游客恢复和 Cookie 写回,再交给
- Organization、WebSite、WebPage、Breadcrumb、ItemList、FAQ、TVSeries/TVEpisode、VideoObject 等原 JSON-LD 条件和真实元数据。
- robots.txt、llms.txt、sitemap 索引/分片、纯渠道首页 302、下载地址 302。
上游 TDK、导航、公告和 JSON-LD 中的完整旧品牌词「黄果短剧」统一显示为「花魁短剧」。永久域名、canonical、真实联系地址、普通影片名称、验证值和 SDK 配置保留原值。
上游 TDK、导航、公告和 JSON-LD 中的完整旧品牌词「黄果短剧」统一显示为「花魁短剧」。永久域名、canonical、真实联系地址、普通影片名称、验证值保留原值。
抓取文件遵循原 Go 路由。普通站点的 robots/llms 读取公共配置,保留 60 秒成功缓存;刷新失败沿用最近成功配置,首次失败使用空配置。地址使用原永久域名优先、代理协议/Host 回退规则。robots 按顺序读取 `template/pages/robots.txt`、`templates/pages/robots.txt`、`app/template/pages/robots.txt`、`app/templates/pages/robots.txt`,没有可用文件时使用原内嵌文本,再回退原内置默认文本。保留原爬虫规则、行处理、已有 Host 和大小写语义,按原算法写入 Sitemap 地址。
@@ -263,11 +261,13 @@ APP_ANDROID_APK_URL=
APP_DEEP_LINK_URL=
```
平台地址未配置时,按钮显示“前往下载页”,沿用上游 `AppAddr` 或 `/app/download/` 跳转;未配置 App 深链时隐藏打开 App 入口。链接保留原始跳转地址与导航埋点,不生成设计示例中的安装包、描述文件或 App Store 地址。
平台地址未配置时,按钮显示“前往下载页”,沿用上游 `AppAddr` 或 `/app/download/` 跳转;未配置 App 深链时隐藏打开 App 入口。链接保留原始跳转地址,不生成设计示例中的安装包、描述文件或 App Store 地址。
所有 Telegram 联系入口(页尾、官方导航、回家的路、播放页链接、2257/DMCA、意见反馈,以及 Telegram 形式的「联系我们」客服链接)统一使用 `CONTACT_TELEGRAM_URL`,三套环境默认 `https://t.me/jbd125`,覆盖上游 `tgGroup`、`telegram`、`telegramAdd`。值无效时回退为默认账号。
## 接口和媒体
保留原 BFF 路由、别名、参数校验、业务响应壳、上传和 CSV 导出。`Cookie`、cookie token 优先的 `Authorization`、客户端 IP、浏览器 UA、`X-User-Agent`、`X-SDK-Common` 透传;过滤 hop-by-hop 和内部控制头。多个 `Set-Cookie` 独立回传。埋点上下文按原白名单校验并保留 SDK 原字段。
保留原 BFF 路由、别名、参数校验、业务响应壳、上传和 CSV 导出。`Cookie`、cookie token 优先的 `Authorization`、客户端 IP、浏览器 UA、`X-User-Agent` 透传;过滤 hop-by-hop 和内部控制头。多个 `Set-Cookie` 独立回传。
公共配置隔离过期登录凭证;个人资料刷新与游客恢复写回当前请求,避免首屏重复建立会话。正式账号失效和内容允许降级游客的规则分别保留。
@@ -289,7 +289,7 @@ SMOKE_URL=http://localhost:25131 npm run test:browser
SMOKE_URL=http://localhost:25131 npm run test:ui
```
浏览器检查默认使用 macOS Chrome,可通过 `CHROME_PATH` 指定。`test:catalog`、`test:popups`、`test:playback` 使用隔离上游与真实 React 组件,验证目录交互、弹层焦点/滚动、官方 xgplayer、加密 HLS、评论与埋点,不改动真实账号。`test:ssr` 使用生产构建核验 SEO、HTTP 状态、Cookie、透传和并发隔离,不访问真实业务接口。
浏览器检查默认使用 macOS Chrome,可通过 `CHROME_PATH` 指定。`test:catalog`、`test:popups`、`test:playback` 使用隔离上游与真实 React 组件,验证目录交互、弹层焦点/滚动、官方 xgplayer、加密 HLS 与评论,不改动真实账号。`test:ssr` 使用生产构建核验 SEO、HTTP 状态、Cookie、透传和并发隔离,不访问真实业务接口。
全站界面截图默认输出 `/tmp/hkdj-figma/implementation`,浏览器基础检查输出 `/tmp/hkdj-browser-smoke`。完整真实内容回归需要原上游剧集、专题与标签数据;空环境可设置 `UI_ALLOW_EMPTY_CONTENT=1`,缺失实体仍必须返回 404 与 noindex。
+5 -6
View File
@@ -1,14 +1,11 @@
APP_ENV=development
UPSTREAM_API_URL=http://172.104.181.99:30331
# UPSTREAM_API_URL=http://172.105.118.202:26931
# The development API returns relative image keys and has no imgCdn configured.
# Verified image source from the original site's imgAppUsCdn; API stays on 30331.
UPSTREAM_API_URL=https://webhuakuiapi.mfcg.me
# UPSTREAM_API_URL=http://172.104.181.99:30331
# The API supplies imgCdn/imgAppUsCdn; this CloudFront source is only the fallback.
# Primary public domain; overrides the backend foreverUrlAddr.
SITE_URL=https://huakuidj.com
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
UPSTREAM_TIMEOUT_MS=20000
TRACKER_APP_ID=JHA-4001
TRACKER_ENCRYPTED_CONFIG=
PORT=25121
# Backend-generated sitemap files; relative to the Node working directory.
SITEMAP_DIR=./template/pages/sitemaps
@@ -20,6 +17,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE=
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL=
APP_IOS_PROFILE_URL=
APP_ANDROID_APK_URL=
+2 -2
View File
@@ -6,8 +6,6 @@ UPSTREAM_API_URL=http://127.0.0.1:26931
SITE_URL=https://huakuidj.com
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
UPSTREAM_TIMEOUT_MS=20000
TRACKER_APP_ID=JHA-4001
TRACKER_ENCRYPTED_CONFIG=
PORT=26935
# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps.
SITEMAP_DIR=../template/pages/sitemaps
@@ -15,6 +13,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE=
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL=
APP_IOS_PROFILE_URL=
APP_ANDROID_APK_URL=
+2 -2
View File
@@ -6,8 +6,6 @@ UPSTREAM_API_URL=http://127.0.0.1:30331
SITE_URL=https://huakuidj.com
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
UPSTREAM_TIMEOUT_MS=20000
TRACKER_APP_ID=JHA-4001
TRACKER_ENCRYPTED_CONFIG=
PORT=30334
# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps.
SITEMAP_DIR=../template/pages/sitemaps
@@ -15,6 +13,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE=
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL=
APP_IOS_PROFILE_URL=
APP_ANDROID_APK_URL=
File diff suppressed because one or more lines are too long
+3 -15
View File
@@ -26,7 +26,7 @@ try {
await context.route("**/*", async (route) => {
const request = route.request();
const local = new URL(request.url()).origin === origin;
// Exercise UI without recording synthetic analytics, purchases or user actions.
// Exercise UI without recording synthetic purchases or user actions.
if (
!local &&
(request.method() !== "GET" || request.resourceType() === "script")
@@ -55,12 +55,6 @@ try {
timeout: 40000,
});
await page.waitForTimeout(500);
if (path !== "/discover/search/")
await page.waitForFunction(
() => typeof (window as unknown as Record<string, unknown>).tracker === "object",
null,
{ timeout: 15000 },
);
for (let index = 0; index < 12; index++) {
const close = page.locator(
"[data-dialog-ad].hk-is-open [data-dialog-ad-item]:not([hidden]) [data-dialog-ad-close]",
@@ -75,9 +69,6 @@ try {
legacyScripts: Array.from(document.scripts).some((script) =>
/\/static\/js\//.test(script.src),
),
tracker:
typeof (window as unknown as Record<string, unknown>).tracker ===
"object",
}));
results.push({
device: name,
@@ -91,11 +82,8 @@ try {
throw new Error(
`${name} ${path} overflows horizontally: ${layout.document}`,
);
if (
layout.legacyScripts ||
(path !== "/discover/search/" && !layout.tracker)
)
throw new Error(`${name} ${path} missing shared client runtime`);
if (layout.legacyScripts)
throw new Error(`${name} ${path} loaded legacy business scripts`);
if (path === "/") {
const accountEntries = await page.locator(
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
+1 -3
View File
@@ -146,10 +146,8 @@ try {
page.on("pageerror", (error) => errors.push(error.message));
await context.addInitScript({
content: `
window.__copies=[];window.__events=[];window.__navigation='';
window.__copies=[];window.__navigation='';
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async function(value){window.__copies.push(value)}}});
window.tracker={getSDKCommonHeader:function(){return 'mock-common'},getCommonFields:function(){return {device_id:'mock-device',device:'OTHER',sid:'mock-sid'}}};
window.WebSDK={track:new Proxy({},{get:function(_,method){return function(payload){window.__events.push({method:String(method),payload:payload})}}})};
if(!localStorage.getItem('hgdj_search_history'))localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));
`,
});
+5 -56
View File
@@ -12,7 +12,7 @@ import { createStyleFixture } from "./style-fixtures";
// Bundle the actual React provider, dialogs, header/footer and encrypted media
// hooks. The ephemeral server and browser request policy isolate all writes,
// destinations and analytics; no original business JS or template runtime runs.
// and destinations; no original business JS or template runtime runs.
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const temporary = await mkdtemp(join(tmpdir(), "huakui-native-site-")),
bundle = join(temporary, "site.js");
@@ -23,7 +23,6 @@ await build({
contents: `
import {createRoot} from 'react-dom/client';
import {SiteProvider,useSite} from '@/components/providers/SiteProvider';
import {TrackerProvider} from '@/components/analytics/TrackerProvider';
import {SiteHeader} from '@/components/layout/SiteHeader';
import {SiteFooter} from '@/components/layout/SiteFooter';
import {SiteShell} from '@/components/layout/SiteShell';
@@ -32,7 +31,7 @@ import {AppDownloadLink} from '@/components/catalog/Interactions';
import {setAuthSession} from '@/lib/api';
window.__setAuthSession=setAuthSession;
const app=createRoot(document.getElementById('root'));function UserProbe(){const site=useSite();return <div style={{position:'absolute',top:150,left:10}}><output id='display-avatar' data-value={site.user?.avatar||''} data-name={site.user?.nickName||''} data-balance={site.user?.recharge||0} data-registered={String(!!site.user?.isRegister)}/></div>}
function render(data){app.render(data.requestPath==='/navigation/'?<SiteShell view='nav' data={data}><SiteNavigationPage data={data}/></SiteShell>:<SiteProvider data={data}><TrackerProvider data={data}><SiteHeader data={data}/><UserProbe/><main style={{minHeight:900,paddingTop:100}}><h1>真实 React 共享组件隔离检查</h1><AppDownloadLink className="hk-app-cta__download" href={data.installGuide.fallbackURL}>点击下载</AppDownloadLink><button id="outside">页面按钮</button></main><SiteFooter data={data}/></TrackerProvider></SiteProvider>);}window.__rerender=render;render(window.__fixture);
function render(data){app.render(data.requestPath==='/navigation/'?<SiteShell view='nav' data={data}><SiteNavigationPage data={data}/></SiteShell>:<SiteProvider data={data}><SiteHeader data={data}/><UserProbe/><main style={{minHeight:900,paddingTop:100}}><h1>真实 React 共享组件隔离检查</h1><AppDownloadLink className="hk-app-cta__download" href={data.installGuide.fallbackURL}>点击下载</AppDownloadLink><button id="outside">页面按钮</button></main><SiteFooter data={data}/></SiteProvider>);}window.__rerender=render;render(window.__fixture);
`,
},
bundle: true,
@@ -72,13 +71,6 @@ const server = createServer(async (request, response) => {
response.end(await readFile(bundle));
return;
}
if (path === "/static/common/web-sdk-v1.2.0.js") {
response.setHeader("Content-Type", "application/javascript");
response.end(
"/* SDK mocked by the browser fixture before React mounts. */",
);
return;
}
if (path.startsWith("/static/")) {
const file = resolve(root, "public", "." + path);
if (!file.startsWith(join(root, "public") + "/")) {
@@ -151,7 +143,6 @@ const server = createServer(async (request, response) => {
requestPath: path,
pageKey: "home",
pageName: "首页",
trackerAppId: "isolated",
isLoggedIn: user,
isRegistered: false,
userName: "隔离游客",
@@ -203,9 +194,7 @@ const image = Buffer.from(
key = Buffer.from("2019ysapp7527");
for (let i = 0; i < Math.min(100, image.length); i++)
image[i] ^= key[i % key.length];
type EventRecord = { method: string; payload: Record<string, unknown> };
const events: EventRecord[] = [],
errors: string[] = [],
const errors: string[] = [],
missing: string[] = [],
unexpected: string[] = [],
destinations: string[] = [];
@@ -214,14 +203,8 @@ let releaseImage: (() => void) | undefined,
delayImage = false,
imageRequested = 0;
const configure = async (context: BrowserContext, ageAccepted = false) => {
await context.exposeBinding(
"__recordEvent",
(_source, event: EventRecord) => {
events.push(event);
},
);
await context.addInitScript({
content: `window.__ageClosed=0;document.addEventListener('eighteen:closed',function(){window.__ageClosed++});window.WebSDK={init:function(){return Promise.resolve()},getCommonFields:function(){return Promise.resolve({device_id:'isolated-device',device:'OTHER',sid:'isolated-sid'})},flush:function(){},track:new Proxy({},{get:function(_,method){return function(payload){return window.__recordEvent({method:String(method),payload:payload})}}})};try{${ageAccepted ? "localStorage.setItem('hg_age_ok','1');" : ""}if(!localStorage.getItem('__fixture_history_seeded')){localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));localStorage.setItem('__fixture_history_seeded','1');}}catch{}`,
content: `window.__ageClosed=0;document.addEventListener('eighteen:closed',function(){window.__ageClosed++});try{${ageAccepted ? "localStorage.setItem('hg_age_ok','1');" : ""}if(!localStorage.getItem('__fixture_history_seeded')){localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));localStorage.setItem('__fixture_history_seeded','1');}}catch{}`,
});
await context.route("**/*", async (route) => {
const request = route.request(),
@@ -302,7 +285,6 @@ const configure = async (context: BrowserContext, ageAccepted = false) => {
};
const ready = async (page: Page) => {
await page.waitForSelector(".hk-site-header");
await page.waitForFunction(() => !!window.__trackerCommonFields);
};
const footerQR = async (page: Page, value: string, previous = "") => {
await page.waitForFunction(({ value, previous }) => {
@@ -413,17 +395,8 @@ try {
await stack.keyboard.press("Escape");
await stack.waitForSelector("[data-dialog-ad]", { state: "detached" });
await expectOverflow(stack, "");
await stack.waitForTimeout(50);
assert.equal(
events.filter(
(event) =>
event.method === "adImpression" && event.payload.ad_id === "stack-1",
).length,
1,
);
await stacked.close();
imageRequested = 0;
events.length = 0;
// Age gate cannot be bypassed by Escape. Ads wait for actual XOR decode + image
// load, resume their queue on another page, and stay dismissed in this session.
const context = await browser.newContext({
@@ -459,7 +432,6 @@ try {
await page.waitForTimeout(150);
assert.equal(imageRequested, 1);
assert.equal(await page.locator("[data-dialog-ad]").isVisible(), false);
assert.equal(events.filter((e) => e.method === "adImpression").length, 0);
assert.equal(
await page.evaluate(() => localStorage.getItem("hg_age_ok")),
"1",
@@ -515,16 +487,6 @@ try {
await page.evaluate(() => sessionStorage.getItem("dialogAdDismissed")),
"2|1|2",
);
assert.equal(
events.filter((e) => e.method === "adImpression" && e.payload.ad_id === "1")
.length,
1,
);
assert.equal(
events.filter((e) => e.method === "adClick" && e.payload.ad_id === "1")
.length,
1,
);
await page.goto(origin + "/?ads=1&bad=1");
await ready(page);
await page.locator("[data-dialog-ad].hk-is-open").waitFor();
@@ -532,12 +494,6 @@ try {
await page.locator("[data-dialog-ad-id]").getAttribute("data-dialog-ad-id"),
"2",
);
assert.equal(
events.some(
(e) => e.method === "adImpression" && e.payload.ad_id === "bad",
),
false,
);
await page.keyboard.press("Escape");
// Actual HeaderSearch hooks, keyboard navigation and history; no account entry remains.
await page.goto(origin + "/?user=1");
@@ -1053,15 +1009,8 @@ try {
assert.ok(destinations.includes("/mock/android.apk"));
assert.ok(destinations.includes("/mock/fallback"));
assert.ok(destinations.includes("/mock/launch"));
assert.ok(
events.some(
(event) =>
event.method === "navigation" &&
event.payload.navigation_key === "header_download",
),
);
console.log(
"Native React site smoke passed: age gate/focus lock, encrypted advertisement readiness/failure/queue/cross-page session, footer QR pixels/size/URL/official fallback/empty configuration/restoration, HeaderSearch/history/keyboard/encoded URLs, configured desktop hover/focus/toggle/Escape/outside-click and mobile expandable navigation/canonical/external/download links, no login/user-menu/account entries, PC download and configured/fallback iOS/Android tabs/actions/deep links, drawer/footer/main entries, scroll/focus restoration and analytics. All APIs and outbound destinations isolated.",
"Native React site smoke passed: age gate/focus lock, encrypted advertisement readiness/failure/queue/cross-page session, footer QR pixels/size/URL/official fallback/empty configuration/restoration, HeaderSearch/history/keyboard/encoded URLs, configured desktop hover/focus/toggle/Escape/outside-click and mobile expandable navigation/canonical/external/download links, no login/user-menu/account entries, PC download and configured/fallback iOS/Android tabs/actions/deep links, drawer/footer/main entries, scroll/focus restoration. All APIs and outbound destinations isolated.",
);
} finally {
releaseImage?.();
-10
View File
@@ -39,13 +39,6 @@ async function expectPageStyles(page: Page, required: string[], absent: string[]
const canonical = "https://canonical.drama.test";
const userAgent =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) NativeSSRSmoke/1.0";
const sdkCommon = JSON.stringify({
sid: "ssr-sdk-session",
device_id: "ssr-sdk-device",
device: "iOS",
device_fingerprint: "ssr-fingerprint",
fp_version: "2",
});
const indexNow = "native_ssr_verification_20261010";
const verifyFilename = `native-ssr-${randomBytes(6).toString("hex")}.html`;
const verifyPath = resolve("public", verifyFilename);
@@ -189,7 +182,6 @@ const mock = createServer(async (req, res) => {
seoNavInfo: {},
navPageList: [],
advitise: {},
reportSdkConfig: "",
});
return;
}
@@ -381,7 +373,6 @@ function headers(caseName: string, token = ""): Headers {
"user-agent": userAgent,
"x-forwarded-for": "203.0.113.42, 198.51.100.7",
"x-user-agent": "DevType=SSR smoke;SysType=h5_ios;sid=original",
"x-sdk-common": sdkCommon,
...(token
? {
cookie: `token=${token}; nickName=STALE_COOKIE; userId=1; isRegister=${token.startsWith("guest-") ? "0" : "1"}; channel=smoke-channel; client_cookie=must_forward`,
@@ -540,7 +531,6 @@ try {
call.headers["x-user-agent"],
"DevType=SSR smoke;SysType=h5_ios;sid=original",
);
assert.equal(call.headers["x-sdk-common"], sdkCommon);
assert.equal(
Object.keys(call.headers).some((key) => key.startsWith("x-hk-")),
false,
+4 -81
View File
@@ -86,7 +86,7 @@ const encrypted = decryptImageBytes(
const entry = join(temp, "fixture.tsx");
await writeFile(
entry,
`import {useState} from 'react';import {createRoot} from 'react-dom/client';import {SiteProvider} from '${cwd}/src/components/providers/SiteProvider';import {TrackerProvider} from '${cwd}/src/components/analytics/TrackerProvider';import {DramaWatchPage} from '${cwd}/src/features/playback/DramaWatchPage';import {SeriesWatchPage} from '${cwd}/src/features/playback/SeriesWatchPage';import {EncryptedImage} from '${cwd}/src/components/media/EncryptedImage';function Fixture(){const [current,setCurrent]=useState(window.fixtureData);window.changePlaybackFixture=setCurrent;return <TrackerProvider boot={{appId:'JHA-4001',encryptedConfig:'mock',uid:'mock_1001',pageKey:'play',pageName:'短剧播放页'}}><SiteProvider data={current} dialogs={false}>{current.series?<SeriesWatchPage key={current.fixtureKey} data={current}/>:<DramaWatchPage key={current.fixtureKey} data={current}/>}<EncryptedImage id="encrypted-image-test" src="/encrypted.png" eager style={{width:32,height:32}} onResolved={()=>window.imageResolved=(window.imageResolved||0)+1} onResolveError={()=>window.imageErrors=(window.imageErrors||0)+1}/><EncryptedImage id="failed-image-test" src="/missing-image" eager style={{width:32,height:32}} onResolved={()=>window.failedImageResolved=true} onResolveError={()=>window.failedImageError=true}/></SiteProvider></TrackerProvider>};createRoot(document.getElementById('root')).render(<Fixture/>);`,
`import {useState} from 'react';import {createRoot} from 'react-dom/client';import {SiteProvider} from '${cwd}/src/components/providers/SiteProvider';import {DramaWatchPage} from '${cwd}/src/features/playback/DramaWatchPage';import {SeriesWatchPage} from '${cwd}/src/features/playback/SeriesWatchPage';import {EncryptedImage} from '${cwd}/src/components/media/EncryptedImage';function Fixture(){const [current,setCurrent]=useState(window.fixtureData);window.changePlaybackFixture=setCurrent;return <SiteProvider data={current} dialogs={false}>{current.series?<SeriesWatchPage key={current.fixtureKey} data={current}/>:<DramaWatchPage key={current.fixtureKey} data={current}/>}<EncryptedImage id="encrypted-image-test" src="/encrypted.png" eager style={{width:32,height:32}} onResolved={()=>window.imageResolved=(window.imageResolved||0)+1} onResolveError={()=>window.imageErrors=(window.imageErrors||0)+1}/><EncryptedImage id="failed-image-test" src="/missing-image" eager style={{width:32,height:32}} onResolved={()=>window.failedImageResolved=true} onResolveError={()=>window.failedImageError=true}/></SiteProvider>};createRoot(document.getElementById('root')).render(<Fixture/>);`,
);
const navStub = join(temp, "navigation.ts");
await writeFile(
@@ -159,18 +159,6 @@ const baseData = () => ({
{ id: 102, currentEpisode: 2, payType: 1, needUnlock: true },
],
},
trackerDrama: {
DramaID: 10,
DramaTitle: "播放测试",
VideoID: 101,
Episode: 1,
TotalEpisode: 2,
DramaDuration: 12,
DramaTypeID: "2",
DramaTypeName: "都市",
DramaTagKey: "3",
DramaTagName: "剧情",
},
hotList: [],
guessLike: [
{ id: 30, slug: "guess", playName: "猜你喜欢测试", cover: "/static/brand/placeholder.svg", totalEpisode: 3, updateStatus: 2 },
@@ -194,13 +182,6 @@ const server = createServer(async (req, res) => {
res.end(playbackStyles.css);
return;
}
if (url.pathname === "/static/common/web-sdk-v1.2.0.js") {
res.setHeader("content-type", "application/javascript");
res.end(
'window.sdkEvents=[];window.WebSDK={init:options=>{window.sdkInit=options},track:new Proxy({},{get:(_,method)=>payload=>{window.sdkEvents.push({method,payload});return Promise.resolve({ok:true})}}),getCommonFields:()=>Promise.resolve({sid:"mock-session",device_id:"mock-device",device:"PC",device_fingerprint:"mock-fp",fp_version:"1"}),flush:()=>Promise.resolve(),setUid:()=>{}};',
);
return;
}
if (url.pathname.startsWith("/api/")) {
res.setHeader("content-type", "application/json");
let data: unknown = {};
@@ -378,35 +359,6 @@ try {
return video.play();
});
await page.waitForFunction(() => document.querySelector("video")!.ended);
const events = await page.evaluate(
() =>
(
window as unknown as {
sdkEvents: { method: string; payload: Record<string, unknown> }[];
}
).sdkEvents,
);
for (const behavior of [
"ep_view",
"ep_play",
"ep_pause",
"ep_forward",
"ep_complete",
])
assert(
events.some(
(event) =>
event.method === "dramaEvent" &&
event.payload.play_behavior_key === behavior,
),
behavior,
);
assert(
events.some(
(event) =>
event.method === "dramaEvent" && event.payload.play_speed === 150,
),
);
assert.equal(
await page
.locator("#failed-image-test")
@@ -440,30 +392,11 @@ try {
),
true,
);
const beforePreview = events.filter(
(event) =>
event.method === "dramaEvent" &&
event.payload.play_behavior_key === "ep_play",
).length;
await page.getByRole("button", { name: "预告片", exact: true }).click();
await page.waitForFunction(() => {
const video = document.querySelector("video");
return !!video && !video.paused && video.currentTime > 0.1;
});
await page.waitForTimeout(300);
const afterPreview = await page.evaluate(
() =>
(
window as unknown as {
sdkEvents: { method: string; payload: Record<string, unknown> }[];
}
).sdkEvents.filter(
(event) =>
event.method === "dramaEvent" &&
event.payload.play_behavior_key === "ep_play",
).length,
);
assert.equal(afterPreview, beforePreview);
assert.equal(
await page.locator(".hk-playback-player").getAttribute("data-play-mode"),
"preview",
@@ -493,18 +426,8 @@ try {
const watchReq = requests.find(
(req) => req.url === "/api/playlet/chapter/watch/report",
);
assert.equal(
watchReq?.headers["x-sdk-common"],
Buffer.from(
JSON.stringify({
sid: "mock-session",
device_id: "mock-device",
device: "PC",
device_fingerprint: "mock-fp",
fp_version: "1",
}),
).toString("base64"),
);
assert(watchReq, "watch report request");
assert.equal(watchReq.headers["x-sdk-common"], undefined);
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: join(output, device + "-player.png") });
const paid = {
@@ -667,7 +590,7 @@ try {
fullscreen: true,
errorRetry: true,
realMp4: true,
recommendationsAndTracking: true,
recommendations: true,
encryptedImageAndFallback: true,
lockedAppDownloadOnly: true,
});
-170
View File
@@ -1,170 +0,0 @@
import assert from "node:assert/strict";
import { createCipheriv, randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { build } from "esbuild";
import { chromium } from "@playwright/test";
// Exercise the shipped SDK and React provider. Every report terminates in a
// browser route; synthetic configuration never reaches an analytics service.
const root = process.cwd();
const temp = await mkdtemp(join(tmpdir(), "huakui-tracker-"));
const sdk = await readFile(join(root, "public/static/common/web-sdk-v1.2.0.js"), "utf8");
const configKey = sdk.match(/_secondGcmKeyBase64="([^"\n]+)"/)?.[1];
assert.ok(configKey, "The SDK configuration encryption contract is available");
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", Buffer.from(configKey, "base64"), iv);
const encryptedConfig = Buffer.concat([
iv,
cipher.update(JSON.stringify({
batchUrls: ["https://analytics.fixture.invalid"],
eventCodes: ["app_page_view", "ad_impression", "page_click", "ad_click"],
signKey: "synthetic-tracker-fixture-key",
securityKeyVersion: "fixture",
})),
cipher.final(),
cipher.getAuthTag(),
]).toString("base64");
const appFile = join(temp, "app.js");
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
let server: ReturnType<typeof createServer> | undefined;
try {
await build({
stdin: {
contents: `import {createRoot} from 'react-dom/client';
import {TrackerProvider} from './src/components/analytics/TrackerProvider';
createRoot(document.getElementById('root')).render(
<TrackerProvider boot={window.__fixtureBoot}>
<a href="#fixture" data-track-ad="external" data-ad-id="fixture-ad"
data-ad-clickable="1" data-ad-type="banner"
style={{display:'block',width:300,height:100}}>广告验证</a>
</TrackerProvider>);`,
loader: "tsx",
resolveDir: root,
},
outfile: appFile,
bundle: true,
platform: "browser",
format: "iife",
tsconfig: join(root, "tsconfig.json"),
define: { "process.env.NODE_ENV": '"production"' },
});
const app = await readFile(appFile);
server = createServer((request, response) => {
const path = new URL(request.url || "/", "http://fixture").pathname;
if (path === "/app.js") {
response.setHeader("Content-Type", "application/javascript").end(app);
} else if (path === "/static/common/web-sdk-v1.2.0.js") {
response.setHeader("Content-Type", "application/javascript").end(sdk);
} else if (path === "/") {
response.setHeader("Content-Type", "text/html; charset=utf-8").end(
'<!doctype html><html><head><meta charset="utf-8"><title>Tracker fixture</title></head><body><div id="root"></div><script>window.__fixtureBoot=' +
JSON.stringify({
appId: "JHA-4001",
encryptedConfig,
appVersion: "1.0.0",
pageKey: "home",
pageName: "首页",
uid: "fixture_user",
}).replace(/</g, "\\u003c") +
'</script><script src="/app.js"></script></body></html>',
);
} else response.writeHead(404).end();
});
await new Promise<void>((done) => server!.listen(0, "127.0.0.1", done));
const address = server.address();
assert.ok(address && typeof address !== "string");
const origin = `http://127.0.0.1:${address.port}`;
browser = await chromium.launch({
executablePath: process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
});
const commonKeys = ["sid", "device_id", "device", "device_fingerprint", "fp_version"].sort();
const results: Record<string, unknown>[] = [];
for (const fixture of [
{
device: "iOS", system: "iOS", version: "18.0",
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1",
},
{
device: "Android", system: "Android", version: "14",
ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36",
},
{
device: "PC", system: "Windows", version: "10.0",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36",
},
{
device: "PC", system: "macOS", version: "14.6",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15",
},
]) {
const context = await browser.newContext({ userAgent: fixture.ua });
const reports: Record<string, unknown>[] = [];
const errors: string[] = [];
await context.route("**/*", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin === origin) return route.continue();
if (url.hostname === "analytics.fixture.invalid") {
if (request.method() === "POST") {
const body: unknown = JSON.parse(request.postData() || "[]");
assert.ok(Array.isArray(body), "SDK reports use a top-level batch array");
for (const record of body) {
assert.ok(record && typeof record === "object");
reports.push(record as Record<string, unknown>);
}
}
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
}
return route.abort();
});
const page = await context.newPage();
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(origin, { waitUntil: "load" });
await page.waitForFunction(() => !!window.__trackerCommonFields, null, { timeout: 15000 });
await page.waitForFunction(() => !!window.tracker, null, { timeout: 15000 });
const common = await page.evaluate(async () => {
const fields = await window.WebSDK!.getCommonFields!(["device", "system_name", "system_version"]);
const header = await window.tracker!.getSDKCommonHeader();
return { fields, headerKeys: Object.keys(JSON.parse(atob(header))).sort() };
});
assert.equal(common.fields.device, fixture.device);
assert.equal(common.fields.system_name, fixture.system);
assert.equal(common.fields.system_version, fixture.version);
assert.deepEqual(common.headerKeys, commonKeys, "X-SDK-Common retains its five-field contract");
await page.waitForFunction(async () => {
await window.tracker!.flush();
return true;
});
await assertEventually(() => reports.some((r) => r.event === "app_page_view") &&
reports.some((r) => r.event === "ad_impression"));
for (const event of ["app_page_view", "ad_impression"]) {
const report = reports.find((record) => record.event === event);
assert.ok(report, `${fixture.system}: ${event} was sent through the real SDK`);
assert.equal(report.device, fixture.device);
assert.equal(report.system_name, fixture.system);
assert.equal(report.system_version, fixture.version);
assert.equal(report.app_id, "JHA-4001");
assert.ok(typeof report.event_sig === "string" && report.event_sig.length > 0, "Real SDK signs the report");
}
assert.deepEqual(errors, []);
results.push({ system: fixture.system, version: fixture.version, events: ["app_page_view", "ad_impression"], headerKeys: common.headerKeys.length });
await context.close();
}
console.log(JSON.stringify({ passed: true, cases: results }, null, 2));
} finally {
await browser?.close();
if (server) await new Promise<void>((done) => server!.close(() => done()));
await rm(temp, { recursive: true, force: true });
}
async function assertEventually(predicate: () => boolean) {
const deadline = Date.now() + 15000;
while (!predicate() && Date.now() < deadline)
await new Promise((done) => setTimeout(done, 50));
assert.ok(predicate(), "The SDK sent both the page view and visible ad impression");
}
@@ -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(
+2 -2
View File
@@ -37,14 +37,14 @@ test('page injection and cached upstream configuration cannot restore the former
process.env.UPSTREAM_API_URL='http://brand-contract.test';const calls=new Map<string,number>();
globalThis.fetch=async(input)=>{
const path=new URL(String(input)).pathname;calls.set(path,(calls.get(path)||0)+1);let data:unknown={list:[],totalCount:0};
if(path==='/api/app/config/info')data={config:{foreverUrlAddr:'https://canonical.drama.test',gmailAddr:'support@huangguo.com',brandName:'黄果短剧'},seoInfo:{title:'黄果短剧首页',description:'黄果短剧介绍',webSiteName:'黄果短剧',keywords:['黄果短剧']},seoNavInfo:{dhTitle:'黄果短剧导航',dhDescription:'黄果短剧最新线路',dhSiteName:'黄果短剧'},reportSdkConfig:'黄果短剧-signed-sdk'};
if(path==='/api/app/config/info')data={config:{foreverUrlAddr:'https://canonical.drama.test',gmailAddr:'support@huangguo.com',brandName:'黄果短剧'},seoInfo:{title:'黄果短剧首页',description:'黄果短剧介绍',webSiteName:'黄果短剧',keywords:['黄果短剧']},seoNavInfo:{dhTitle:'黄果短剧导航',dhDescription:'黄果短剧最新线路',dhSiteName:'黄果短剧'}};
if(path==='/api/app/translation/category/list')data={categoryList:[{name:'黄果短剧首页',urlValue:'/',categoryType:1,seoInfo:{title:'黄果短剧首页',description:'黄果短剧介绍'}}]};
if(path==='/api/app/playlet/home')data={seoInfo:{title:'黄果短剧首页'},announcements:[{content:'欢迎来到黄果短剧'}],homeConfigData:[{name:'黄果短剧精选',type:1,status:true}],homeBannerData:[{id:1,playName:'黄果恋人'}]};
return Response.json({code:200,data});
};
try{
const page=await buildPage(context('/'));const config=page.data.appConfig;
assert.equal(page.data.title,'花魁短剧首页');assert.equal(config.config.brandName,BRAND_NAME);assert.equal(config.seoInfo.webSiteName,BRAND_NAME);assert.equal(config.seoNavInfo.dhTitle,'花魁短剧导航');assert.equal(page.data.categoryList[0].name,'花魁短剧首页');assert.equal(page.data.announcements[0].content,'欢迎来到花魁短剧');assert.equal(page.data.homeConfigPlaylets[0].name,'花魁短剧精选');assert.equal(page.data.homeBannerData[0].playName,'黄果恋人');assert.equal(config.gmailAddr,'support@huangguo.com');assert.equal(config.reportSdkConfig,'黄果短剧-signed-sdk');
assert.equal(page.data.title,'花魁短剧首页');assert.equal(config.config.brandName,BRAND_NAME);assert.equal(config.seoInfo.webSiteName,BRAND_NAME);assert.equal(config.seoNavInfo.dhTitle,'花魁短剧导航');assert.equal(page.data.categoryList[0].name,'花魁短剧首页');assert.equal(page.data.announcements[0].content,'欢迎来到花魁短剧');assert.equal(page.data.homeConfigPlaylets[0].name,'花魁短剧精选');assert.equal(page.data.homeBannerData[0].playName,'黄果恋人');assert.equal(config.gmailAddr,'support@huangguo.com');
const cached=await apiData(context('/'),'/api/app/config/info',{}, {public:true,ttl:60000});assert.equal(cached.seoInfo.webSiteName,BRAND_NAME);assert.equal(cached.config.brandName,BRAND_NAME);assert.equal(calls.get('/api/app/config/info'),1);
const rendered=enrichSEO(context('/'),page.data);assert.equal(rendered.siteName,BRAND_NAME);assert.equal(rendered.canonicalURL,'https://canonical.drama.test/');assert.equal(rendered.metaTitle,'花魁短剧首页');
const help=await buildPage(context('/help/'));assert.equal(help.data.faqItems[0].Question,'花魁短剧网页版可以看全集吗?');
+2 -3
View File
@@ -112,9 +112,8 @@ test("React list advertising keeps independent desktop and mobile row positions
3,
);
const promo = renderToStaticMarkup(<PromoGrid items={ads} />);
assert.equal((promo.match(/data-track-ad="external"/g) || []).length, 2);
assert.equal((promo.match(/data-ad-clone="1"/g) || []).length, 1);
assert.match(promo, /rel="sponsored nofollow noopener"/);
assert.equal((promo.match(/rel="sponsored nofollow noopener"/g) || []).length, 2);
assert.equal((promo.match(/tabindex="-1"/g) || []).length, 1);
});
test("React filter and pagination links preserve the source path-only canonical route contracts", () => {
+2 -2
View File
@@ -63,7 +63,7 @@ test('missing upstream fields retain legacy Go zero values rather than blank att
assert.equal(detail.CountLike,0);assert.equal(detail.IsLike,0);assert.equal(detail.Preview,'');assert.equal(detail.ThemeNames,null);assert.equal(detail.PlayletChapterInfo[0].MediaTime,0);assert.equal(detail.PlayletChapterInfo[0].NeedUnlock,false);assert.equal(detail.PlayletChapterInfo[0].AllowWatch,true);
});
test('list and playback page loaders preserve filters, canonical paths, locked media and tracking',async()=>{
test('list and playback page loaders preserve filters, canonical paths and locked media',async()=>{
const originalFetch=globalThis.fetch,originalUpstream=process.env.UPSTREAM_API_URL;process.env.UPSTREAM_API_URL='http://page-contract-test';const calls:{url:URL;headers:Headers}[]=[];
globalThis.fetch=async(input,init)=>{
const url=new URL(String(input));calls.push({url,headers:new Headers(init?.headers)});let data:any={list:[],totalCount:0};
@@ -78,7 +78,7 @@ test('list and playback page loaders preserve filters, canonical paths, locked m
const listed=await list(ctx('/list/12/2/3/page/2/'),{theme:'12',updateStatus:'2',sortType:'3',page:'2'});
assert.equal(listed.themeId,12);assert.equal(listed.themeSlug,'romance');assert.equal(listed.canonicalPath,'/list/romance/2/3/page/2/');assert.equal(listed.pagination.NextURL,'/list/romance/2/3/page/3/');assert.equal(listed.pageHeading,'爱情短剧');
const catalogue=calls.find(call=>call.url.pathname==='/api/app/playlet/all/list')!;assert.equal(catalogue.url.searchParams.get('themeSlug'),'romance');assert.equal(catalogue.headers.has('authorization'),false);
const played=await play(ctx('/play/drama/102/'),{slug:'drama',ep:'102'});assert.ok(played);assert.equal(played.chapterID,102);assert.equal(played.episode,2);assert.equal(played.canWatch,false);assert.equal(played.mainURL,'');assert.equal(played.previewURL,'preview.m3u8');assert.equal(played.balanceText,'12,345');assert.equal(played.seoInfo.title,'剧名 - 第二集');assert.equal(played.canonicalPath,'/drama/drama/');assert.equal(played.trackerDrama.VideoID,102);assert.equal(played.trackerDrama.DramaTypeID,'12');assert.equal(played.comments,undefined);assert.ok(!calls.some(call=>call.url.pathname==='/api/app/comment/list'));
const played=await play(ctx('/play/drama/102/'),{slug:'drama',ep:'102'});assert.ok(played);assert.equal(played.chapterID,102);assert.equal(played.episode,2);assert.equal(played.canWatch,false);assert.equal(played.mainURL,'');assert.equal(played.previewURL,'preview.m3u8');assert.equal(played.balanceText,'12,345');assert.equal(played.seoInfo.title,'剧名 - 第二集');assert.equal(played.canonicalPath,'/drama/drama/');assert.equal(played.comments,undefined);assert.ok(!calls.some(call=>call.url.pathname==='/api/app/comment/list'));
const chapter=calls.find(call=>call.url.pathname==='/api/app/playlet/chapter/detail')!;assert.equal(chapter.url.searchParams.get('chapterId'),'102');assert.equal(chapter.headers.get('authorization'),'test-session');
assert.equal(await play(ctx('/play/drama/999/'),{slug:'drama',ep:'999'}),null);
for(const path of ['/search/?q=legacy','/search/?page=2','/play/drama/?guessPage=2','/favorites/?filter=all','/favorites/invalid/','/history/page/10001/','/settings//'])assert.equal((await buildPage(ctx(path))).status,404,path);
-26
View File
@@ -6,11 +6,6 @@ import {
shouldDecryptImage,
} from "../src/lib/media/images";
import { isHLSURL, nativeHLS, resolvePlayURL } from "../src/lib/media/playback";
import { trackingContext } from "../src/features/playback/model";
import {
normalizeChannel,
normalizeUID,
} from "../src/components/analytics/tracker";
test("legacy image XOR is immutable, symmetric and changes only the first 100 bytes", () => {
const plain = Uint8Array.from({ length: 150 }, (_, index) => index),
@@ -75,24 +70,3 @@ test("native HLS only uses Safari and shell browsers keep the inline MSE player"
])
assert(!nativeHLS(video, "Mozilla iPhone Safari/604.1 " + browser));
});
test("drama tracking and timestamps preserve upstream field meaning", () => {
assert.equal(trackingContext({ DramaID: 0 }), null);
const context = trackingContext({
DramaID: 42,
VideoID: 101,
Episode: 2,
TotalEpisode: 8,
DramaTypeID: 3,
DramaTypeName: "都市",
DramaTagKey: "1,2",
DramaTagName: "剧情,原创",
});
assert.equal(context?.drama_id, "42");
assert.equal(context?.episode, 2);
assert.equal(context?.drama_content_type, "short_drama");
assert.equal(context?.drama_tag_key, "1,2");
assert.equal(normalizeChannel("official"), "organic");
assert.equal(normalizeChannel("channel_A-2"), "channel_A-2");
assert.equal(normalizeUID("stress_user"), "");
assert.equal(normalizeUID("real_1001"), "real_1001");
});
+3 -11
View File
@@ -44,13 +44,13 @@ after(async () => { await new Promise<void>(resolve => server.close(() => resolv
const request = (path: string, init: RequestInit = {}) => new Request('http://local.example' + path, init);
const post = (path: string, body: unknown, headers: HeadersInit = {}) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...Object.fromEntries(new Headers(headers)) }, body: JSON.stringify(body) });
test('SSR/upstream preserve client cookies, cookie-token priority, IP, custom UA and SDK headers', async () => {
test('SSR/upstream preserve client cookies, cookie-token priority, IP, and custom UA headers', async () => {
scenario = ''; received.length = 0;
const ctx = createContext(request('/list/', { headers: { Cookie: 'token=cookie-token; extra=keep%20me', Authorization: 'header-token', 'User-Agent': 'browser-agent', 'X-User-Agent': 'client-UA', 'X-Real-IP': '203.0.113.12', 'X-SDK-Common': 'sdk-snapshot' } }));
const ctx = createContext(request('/list/', { headers: { Cookie: 'token=cookie-token; extra=keep%20me', Authorization: 'header-token', 'User-Agent': 'browser-agent', 'X-User-Agent': 'client-UA', 'X-Real-IP': '203.0.113.12'} }));
await upstreamGet(ctx, '/api/app/config/info');
const headers = received[0].headers;
assert.equal(headers.authorization, 'cookie-token'); assert.equal(headers.cookie, 'token=cookie-token; extra=keep%20me');
assert.equal(headers['user-agent'], 'browser-agent'); assert.equal(headers['x-user-agent'], 'client-UA'); assert.equal(headers['x-real-ip'], '203.0.113.12'); assert.equal(headers['x-sdk-common'], 'sdk-snapshot');
assert.equal(headers['user-agent'], 'browser-agent'); assert.equal(headers['x-user-agent'], 'client-UA'); assert.equal(headers['x-real-ip'], '203.0.113.12');
});
test('public data requests isolate both stale Authorization and auth cookies', async () => {
scenario = ''; received.length = 0;
@@ -67,14 +67,6 @@ test('hop-by-hop, Connection-nominated and internal headers never cross the BFF'
const headers = buildUpstreamHeaders(ctx);
for (const name of ['connection', 'x-internal', 'x-origin-head', 'x-tracker-request-id', 'x-dc-request-context']) assert.equal(headers.has(name), false);
});
test('canonical attribution snapshot forwards only on the original allowed action', () => {
const id = 'a'.repeat(32);
const envelope = { v: 1, request_id: id, event_name: 'drama_like', common: { app_id: 'JHA-4001', uid: '12345', channel: 'campaign', sid: 'session', client_ts: 1791550000, device: 'PC', device_id: 'device', device_brand: '', device_model: '', device_fingerprint: '', fp_version: '', user_agent: 'browser', sdk_version: '1.2.0', app_version: '1.0.0' }, drama: { media_id: '', drama_id: '123', episode: 1, video_id: '', recommend_trace_id: '' } };
const headers = { 'X-Tracker-Request-Id': id, 'X-Tracker-Event-Name': 'drama_like', 'X-DC-Request-Context': Buffer.from(JSON.stringify(envelope)).toString('base64url') };
const allowed = buildUpstreamHeaders(createContext(post('/api/playlet/like/set', { id: 123, isLike: true }, headers)));
assert.deepEqual(JSON.parse(Buffer.from(allowed.get('X-DC-Request-Context')!, 'base64url').toString()), envelope);
assert.equal(buildUpstreamHeaders(createContext(post('/api/video/like/set', {}, headers))).has('X-DC-Request-Context'), false);
});
test('Set-Cookie values remain separate and updated same-name cookies are written once', async () => {
scenario = 'cookies'; received.length = 0;
const ctx = createContext(request('/api/auth/login'));
+2 -2
View File
@@ -107,8 +107,8 @@ test("React tags and list pages preserve the original Go catalog content and nav
name,
);
assert.equal(
(actual.match(/data-track-ad="external"/g) || []).length,
(expected.match(/data-track-ad="external"/g) || []).length,
(actual.match(/rel="sponsored nofollow noopener"/g) || []).length,
(expected.match(/rel="sponsored nofollow noopener"/g) || []).length,
name + " ad placement",
);
assert.match(actual, /花魁 &amp; 第一部/);
+5 -5
View File
@@ -6,7 +6,7 @@ import { canonicalRedirect } from "../src/server/redirects";
import { matchPageRoute } from "../src/server/pages";
import { pageRoutes, publicPageRoutes } from "../src/server/pages/routes";
import { preparePage } from "../src/server/react/page-data";
import { trackerPage } from "../src/server/services/shared";
import { pageIdentity } from "../src/server/services/shared";
test("Huakui route prefixes preserve encoded slugs, raw queries and fragments", () => {
const suffix = "/%E7%9F%AD%E5%89%A7%2F%E9%9B%86%252F1/?x=a+b&x=%2b&token=a%2FB#episode%202";
@@ -262,7 +262,7 @@ test("navigation-only home road links use a configured main-site origin, then th
});
test("semantic page paths retain their original analytics classification", () => {
test("semantic page paths retain their original page classification", () => {
const paths: [string, string, string][] = [
["/list/romance/", "/dramas/romance/", "list"],
["/play/a%2Fb/123/", "/drama/a%2Fb/123/", "play"],
@@ -279,8 +279,8 @@ test("semantic page paths retain their original analytics classification", () =>
["/home-road/", "/return-home/", "home_road"],
];
for (const [legacy, current, key] of paths) {
assert.deepEqual(trackerPage(current), trackerPage(legacy), current);
assert.equal(trackerPage(current)[0], key, current);
assert.deepEqual(pageIdentity(current), pageIdentity(legacy), current);
assert.equal(pageIdentity(current)[0], key, current);
}
assert.equal(trackerPage("/unknown/")[0], "internal_other");
assert.equal(pageIdentity("/unknown/")[0], "internal_other");
});
-81
View File
@@ -1,81 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const sdkSource = readFileSync(new URL("../public/static/common/web-sdk-v1.2.0.js", import.meta.url), "utf8");
interface SDKContext {
navigator?: { userAgent: string; platform: string; maxTouchPoints: number };
WebSDK?: { getCommonFields(fields?: string[]): Promise<Record<string, unknown>> };
}
function loadSDK(userAgent?: string, platform = "", maxTouchPoints = 0) {
const context: SDKContext = {};
if (userAgent !== undefined) context.navigator = { userAgent, platform, maxTouchPoints };
runInNewContext(sdkSource, context, { filename: "web-sdk-v1.2.0.js" });
assert.ok(context.WebSDK);
return context.WebSDK;
}
const userAgents = [
{
name: "iPhone",
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1",
platform: "iPhone", touch: 5, system: "iOS", version: "18.0",
},
{
name: "iPad mobile mode",
ua: "Mozilla/5.0 (iPad; CPU OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 Version/17.6 Mobile/15E148 Safari/604.1",
platform: "iPad", touch: 5, system: "iOS", version: "17.6.1",
},
{
name: "Android",
ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/129.0.0.0 Mobile Safari/537.36",
platform: "Linux armv8l", touch: 5, system: "Android", version: "14",
},
{
name: "Windows NT",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36",
platform: "Win32", touch: 0, system: "Windows", version: "10.0",
},
{
name: "macOS",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_1) AppleWebKit/605.1.15 Version/17.6 Safari/605.1.15",
platform: "MacIntel", touch: 0, system: "macOS", version: "14.6.1",
},
{
name: "iPad desktop mode without an exposed iOS version",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15",
platform: "MacIntel", touch: 5, system: "iOS", version: "",
},
{
name: "Linux without an exposed kernel version",
ua: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/129.0.0.0 Safari/537.36",
platform: "Linux x86_64", touch: 0, system: "Linux", version: "",
},
{
name: "unknown operating system",
ua: "ExampleBrowser/1.0", platform: "", touch: 0, system: "Other", version: "",
},
];
for (const device of userAgents) {
test(`bundled SDK common fields report the operating system for ${device.name}`, async () => {
const sdk = loadSDK(device.ua, device.platform, device.touch);
const common = await sdk.getCommonFields();
assert.equal(common.system_name, device.system);
assert.equal(common.system_version, device.version);
assert.equal(common.user_agent, device.ua);
const selected = await sdk.getCommonFields(["system_name", "system_version"]);
assert.deepEqual(JSON.parse(JSON.stringify(selected)), {
system_name: device.system, system_version: device.version,
});
});
}
test("bundled SDK OS fields tolerate a missing navigator without inventing a version", async () => {
const common = await loadSDK().getCommonFields();
assert.equal(common.system_name, "Other");
assert.equal(common.system_version, "");
});