样式修改
This commit is contained in:
@@ -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` 页面适配器。
|
- 本仓库是花魁短剧原生 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 的状态和索引规则。
|
- 保持原站功能、PC/H5 界面和完整 SEO 行为:metadata、JSON-LD、canonical、分页、robots、llms、sitemap、站点验证、IndexNow,以及重定向/404/503 的状态和索引规则。
|
||||||
- 保留服务端 API 转发、请求头和 Cookie 透传、游客会话恢复、多条 Set-Cookie 和私有响应禁止缓存。不要把鉴权或 SDK 私密配置泄露到公开数据、日志或图片 CDN。
|
- 保留服务端 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/临时状态。
|
- 业务 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,不在请求中改写生成结果。
|
- 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,持续写入的原目录应外置挂载,不能随前端包清空。
|
- 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 避免框架自动压缩改变文件响应,外层代理可沿用原传输配置。
|
- 成功抓取文件通过原生 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 或旧业务脚本。
|
- 播放器通过 npm 使用 `xgplayer` 与 `xgplayer-hls`;不要恢复 CDN 脚本版 DPlayer/Hls/Swiper 或旧业务脚本。
|
||||||
- `/h5/m3u8/**`、`/enkey`、`/newenkey` 保留服务端媒体转发:签名 query 不重组,UA/IP/Range 保留,上游重定向按原 Go 客户端跟随。签名媒体请求按原合同不附登录 Cookie/Authorization;上游业务 JSON 错误返回 502,不能当播放清单成功响应。
|
- `/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/页面数据一致性。大快照使用私有临时文件,部署多实例时须处理交接存储共享或实例亲和。
|
- SSR 请求准备在 `src/proxy.ts` 完成,通过 `src/server/react/handoff.ts` 向 React 交接;保留同请求 Cookie/页面数据一致性。大快照使用私有临时文件,部署多实例时须处理交接存储共享或实例亲和。
|
||||||
- 所有“黄果短剧”统一为“花魁短剧”。占位图品牌 logo 透明度固定为 `0.2`。Figma 文件为 `1vUq4wf5xOs8ZOlmt26Q3O`,读取设计使用本地 `FIGMA_TOKEN` 调用 REST API,不能打印令牌。
|
- 所有“黄果短剧”统一为“花魁短剧”。占位图品牌 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 dev` 默认使用 Webpack;该项目已确认 Next 16.4 Turbopack 开发 HMR 存在循环刷新,未经验证不要改回默认。Charles 调试使用 `npm run dev:charles`,不改业务接口地址。
|
||||||
- 验证至少覆盖 `npm run check` 和受影响的浏览器链路;图片必须检查真实接口返回的地址、解密后的自然尺寸、背景图及最终可见性(包含 computed opacity),模拟图片测试通过不能证明 CDN 可用。首页 Hero 封面成功/失败均需结束加载状态,保留淡入和失败占位;不能遗漏 CSS 依赖的就绪标记。
|
- 验证至少覆盖 `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。
|
- 提交遵循用户全局 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。
|
- 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。
|
- `/manifest.webmanifest` 与 `/sw.js` 必须绕过 SSR Proxy/游客初始化,包括 NAVIGATION_ONLY;Worker 源码为 TypeScript,开发/构建自动生成 public/sw.js,不拦截或缓存鉴权 API、HTML/RSC、签名媒体及密钥。PWA 安全区样式最后加载,普通浏览器不改变原布局;验证 `npm run test:pwa` 与实际 SSR 清单/标签/资源,真实设备安装需 HTTPS。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 花魁短剧 · Next.js / TypeScript
|
# 花魁短剧 · 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`。
|
配置优先级:进程环境变量 > `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
|
```dotenv
|
||||||
UPSTREAM_API_URL=https://your-api.example
|
UPSTREAM_API_URL=https://your-api.example
|
||||||
UPSTREAM_TIMEOUT_MS=20000
|
UPSTREAM_TIMEOUT_MS=20000
|
||||||
IMAGE_CDN_URL=
|
IMAGE_CDN_URL=
|
||||||
SITEMAP_DIR=../template/pages/sitemaps
|
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。
|
公共图片组件统一解析相对路径,覆盖封面、横幅、背景和头像;相对路径不会随页面目录变化。图片保持原首 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/app/` | 原生 App Router TSX 页面、Metadata 与 BFF/媒体 Route Handlers |
|
||||||
| `src/features/` | 短剧目录、播放、账号与内容页面组件 |
|
| `src/features/` | 短剧目录、播放、账号与内容页面组件 |
|
||||||
| `src/components/` | 页头、页尾、卡片、播放器、图片、弹窗、认证、支付、埋点共用组件 |
|
| `src/components/` | 页头、页尾、卡片、播放器、图片、弹窗、认证、支付共用组件 |
|
||||||
| `src/styles/` | 基础、公共组件、页面和 PWA 覆盖样式,通过 TSX 导入并由 Next 编译 |
|
| `src/styles/` | 基础、公共组件、页面和 PWA 覆盖样式,通过 TSX 导入并由 Next 编译 |
|
||||||
| `src/lib/` | 类型化浏览器请求、Cookie、搜索/展示方法与埋点上下文 |
|
| `src/lib/` | 类型化浏览器请求、Cookie、搜索/展示方法 |
|
||||||
| `src/proxy.ts`、`src/server/react/` | SSR 数据、状态、响应 Cookie 和 React 数据交接 |
|
| `src/proxy.ts`、`src/server/react/` | SSR 数据、状态、响应 Cookie 和 React 数据交接 |
|
||||||
| `src/server/pages/` | 原路由参数、分页、页面数据组合与播放/账号场景 |
|
| `src/server/pages/` | 原路由参数、分页、页面数据组合与播放/账号场景 |
|
||||||
| `src/server/services/` | 上游 DTO、展示模型、公共配置和请求级复用 |
|
| `src/server/services/` | 上游 DTO、展示模型、公共配置和请求级复用 |
|
||||||
| `src/server/upstream.ts`、`auth.ts` | 上游请求、请求头/Cookie 透传、登录与游客恢复 |
|
| `src/server/upstream.ts`、`auth.ts` | 上游请求、请求头/Cookie 透传、登录与游客恢复 |
|
||||||
| `src/server/seo/` | 原 SEO 策略、TDK、canonical、JSON-LD 和抓取文件 |
|
| `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 的适配层。
|
所有业务页面由 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。
|
公共配置可短期复用;登录用户内容仅在单次请求内复用,页面响应使用 `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 保留范围
|
## SEO 保留范围
|
||||||
|
|
||||||
@@ -217,7 +215,7 @@ SSR 在 Proxy 中完成上游数据、游客恢复和 Cookie 写回,再交给
|
|||||||
- Organization、WebSite、WebPage、Breadcrumb、ItemList、FAQ、TVSeries/TVEpisode、VideoObject 等原 JSON-LD 条件和真实元数据。
|
- Organization、WebSite、WebPage、Breadcrumb、ItemList、FAQ、TVSeries/TVEpisode、VideoObject 等原 JSON-LD 条件和真实元数据。
|
||||||
- robots.txt、llms.txt、sitemap 索引/分片、纯渠道首页 302、下载地址 302。
|
- 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 地址。
|
抓取文件遵循原 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=
|
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
|
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。
|
全站界面截图默认输出 `/tmp/hkdj-figma/implementation`,浏览器基础检查输出 `/tmp/hkdj-browser-smoke`。完整真实内容回归需要原上游剧集、专题与标签数据;空环境可设置 `UI_ALLOW_EMPTY_CONTENT=1`,缺失实体仍必须返回 404 与 noindex。
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
UPSTREAM_API_URL=http://172.104.181.99:30331
|
UPSTREAM_API_URL=https://webhuakuiapi.mfcg.me
|
||||||
# UPSTREAM_API_URL=http://172.105.118.202:26931
|
# UPSTREAM_API_URL=http://172.104.181.99:30331
|
||||||
# The development API returns relative image keys and has no imgCdn configured.
|
# The API supplies imgCdn/imgAppUsCdn; this CloudFront source is only the fallback.
|
||||||
# Verified image source from the original site's imgAppUsCdn; API stays on 30331.
|
|
||||||
# Primary public domain; overrides the backend foreverUrlAddr.
|
# Primary public domain; overrides the backend foreverUrlAddr.
|
||||||
SITE_URL=https://huakuidj.com
|
SITE_URL=https://huakuidj.com
|
||||||
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
||||||
UPSTREAM_TIMEOUT_MS=20000
|
UPSTREAM_TIMEOUT_MS=20000
|
||||||
TRACKER_APP_ID=JHA-4001
|
|
||||||
TRACKER_ENCRYPTED_CONFIG=
|
|
||||||
PORT=25121
|
PORT=25121
|
||||||
# Backend-generated sitemap files; relative to the Node working directory.
|
# Backend-generated sitemap files; relative to the Node working directory.
|
||||||
SITEMAP_DIR=./template/pages/sitemaps
|
SITEMAP_DIR=./template/pages/sitemaps
|
||||||
@@ -20,6 +17,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
|
|||||||
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
||||||
SEO_BAIDU_SITE_VERIFICATION=
|
SEO_BAIDU_SITE_VERIFICATION=
|
||||||
SEO_BAIDU_HTML_FILE=
|
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_STORE_URL=
|
||||||
APP_IOS_PROFILE_URL=
|
APP_IOS_PROFILE_URL=
|
||||||
APP_ANDROID_APK_URL=
|
APP_ANDROID_APK_URL=
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ UPSTREAM_API_URL=http://127.0.0.1:26931
|
|||||||
SITE_URL=https://huakuidj.com
|
SITE_URL=https://huakuidj.com
|
||||||
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
||||||
UPSTREAM_TIMEOUT_MS=20000
|
UPSTREAM_TIMEOUT_MS=20000
|
||||||
TRACKER_APP_ID=JHA-4001
|
|
||||||
TRACKER_ENCRYPTED_CONFIG=
|
|
||||||
PORT=26935
|
PORT=26935
|
||||||
# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps.
|
# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps.
|
||||||
SITEMAP_DIR=../template/pages/sitemaps
|
SITEMAP_DIR=../template/pages/sitemaps
|
||||||
@@ -15,6 +13,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
|
|||||||
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
||||||
SEO_BAIDU_SITE_VERIFICATION=
|
SEO_BAIDU_SITE_VERIFICATION=
|
||||||
SEO_BAIDU_HTML_FILE=
|
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_STORE_URL=
|
||||||
APP_IOS_PROFILE_URL=
|
APP_IOS_PROFILE_URL=
|
||||||
APP_ANDROID_APK_URL=
|
APP_ANDROID_APK_URL=
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,6 @@ UPSTREAM_API_URL=http://127.0.0.1:30331
|
|||||||
SITE_URL=https://huakuidj.com
|
SITE_URL=https://huakuidj.com
|
||||||
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
||||||
UPSTREAM_TIMEOUT_MS=20000
|
UPSTREAM_TIMEOUT_MS=20000
|
||||||
TRACKER_APP_ID=JHA-4001
|
|
||||||
TRACKER_ENCRYPTED_CONFIG=
|
|
||||||
PORT=30334
|
PORT=30334
|
||||||
# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps.
|
# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps.
|
||||||
SITEMAP_DIR=../template/pages/sitemaps
|
SITEMAP_DIR=../template/pages/sitemaps
|
||||||
@@ -15,6 +13,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
|
|||||||
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
||||||
SEO_BAIDU_SITE_VERIFICATION=
|
SEO_BAIDU_SITE_VERIFICATION=
|
||||||
SEO_BAIDU_HTML_FILE=
|
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_STORE_URL=
|
||||||
APP_IOS_PROFILE_URL=
|
APP_IOS_PROFILE_URL=
|
||||||
APP_ANDROID_APK_URL=
|
APP_ANDROID_APK_URL=
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@ try {
|
|||||||
await context.route("**/*", async (route) => {
|
await context.route("**/*", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const local = new URL(request.url()).origin === origin;
|
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 (
|
if (
|
||||||
!local &&
|
!local &&
|
||||||
(request.method() !== "GET" || request.resourceType() === "script")
|
(request.method() !== "GET" || request.resourceType() === "script")
|
||||||
@@ -55,12 +55,6 @@ try {
|
|||||||
timeout: 40000,
|
timeout: 40000,
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(500);
|
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++) {
|
for (let index = 0; index < 12; index++) {
|
||||||
const close = page.locator(
|
const close = page.locator(
|
||||||
"[data-dialog-ad].hk-is-open [data-dialog-ad-item]:not([hidden]) [data-dialog-ad-close]",
|
"[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) =>
|
legacyScripts: Array.from(document.scripts).some((script) =>
|
||||||
/\/static\/js\//.test(script.src),
|
/\/static\/js\//.test(script.src),
|
||||||
),
|
),
|
||||||
tracker:
|
|
||||||
typeof (window as unknown as Record<string, unknown>).tracker ===
|
|
||||||
"object",
|
|
||||||
}));
|
}));
|
||||||
results.push({
|
results.push({
|
||||||
device: name,
|
device: name,
|
||||||
@@ -91,11 +82,8 @@ try {
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`${name} ${path} overflows horizontally: ${layout.document}`,
|
`${name} ${path} overflows horizontally: ${layout.document}`,
|
||||||
);
|
);
|
||||||
if (
|
if (layout.legacyScripts)
|
||||||
layout.legacyScripts ||
|
throw new Error(`${name} ${path} loaded legacy business scripts`);
|
||||||
(path !== "/discover/search/" && !layout.tracker)
|
|
||||||
)
|
|
||||||
throw new Error(`${name} ${path} missing shared client runtime`);
|
|
||||||
if (path === "/") {
|
if (path === "/") {
|
||||||
const accountEntries = await page.locator(
|
const accountEntries = await page.locator(
|
||||||
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
|
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
|
||||||
|
|||||||
@@ -146,10 +146,8 @@ try {
|
|||||||
page.on("pageerror", (error) => errors.push(error.message));
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
await context.addInitScript({
|
await context.addInitScript({
|
||||||
content: `
|
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)}}});
|
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(['历史词']));
|
if(!localStorage.getItem('hgdj_search_history'))localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { createStyleFixture } from "./style-fixtures";
|
|||||||
|
|
||||||
// Bundle the actual React provider, dialogs, header/footer and encrypted media
|
// Bundle the actual React provider, dialogs, header/footer and encrypted media
|
||||||
// hooks. The ephemeral server and browser request policy isolate all writes,
|
// 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 root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const temporary = await mkdtemp(join(tmpdir(), "huakui-native-site-")),
|
const temporary = await mkdtemp(join(tmpdir(), "huakui-native-site-")),
|
||||||
bundle = join(temporary, "site.js");
|
bundle = join(temporary, "site.js");
|
||||||
@@ -23,7 +23,6 @@ await build({
|
|||||||
contents: `
|
contents: `
|
||||||
import {createRoot} from 'react-dom/client';
|
import {createRoot} from 'react-dom/client';
|
||||||
import {SiteProvider,useSite} from '@/components/providers/SiteProvider';
|
import {SiteProvider,useSite} from '@/components/providers/SiteProvider';
|
||||||
import {TrackerProvider} from '@/components/analytics/TrackerProvider';
|
|
||||||
import {SiteHeader} from '@/components/layout/SiteHeader';
|
import {SiteHeader} from '@/components/layout/SiteHeader';
|
||||||
import {SiteFooter} from '@/components/layout/SiteFooter';
|
import {SiteFooter} from '@/components/layout/SiteFooter';
|
||||||
import {SiteShell} from '@/components/layout/SiteShell';
|
import {SiteShell} from '@/components/layout/SiteShell';
|
||||||
@@ -32,7 +31,7 @@ import {AppDownloadLink} from '@/components/catalog/Interactions';
|
|||||||
import {setAuthSession} from '@/lib/api';
|
import {setAuthSession} from '@/lib/api';
|
||||||
window.__setAuthSession=setAuthSession;
|
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>}
|
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,
|
bundle: true,
|
||||||
@@ -72,13 +71,6 @@ const server = createServer(async (request, response) => {
|
|||||||
response.end(await readFile(bundle));
|
response.end(await readFile(bundle));
|
||||||
return;
|
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/")) {
|
if (path.startsWith("/static/")) {
|
||||||
const file = resolve(root, "public", "." + path);
|
const file = resolve(root, "public", "." + path);
|
||||||
if (!file.startsWith(join(root, "public") + "/")) {
|
if (!file.startsWith(join(root, "public") + "/")) {
|
||||||
@@ -151,7 +143,6 @@ const server = createServer(async (request, response) => {
|
|||||||
requestPath: path,
|
requestPath: path,
|
||||||
pageKey: "home",
|
pageKey: "home",
|
||||||
pageName: "首页",
|
pageName: "首页",
|
||||||
trackerAppId: "isolated",
|
|
||||||
isLoggedIn: user,
|
isLoggedIn: user,
|
||||||
isRegistered: false,
|
isRegistered: false,
|
||||||
userName: "隔离游客",
|
userName: "隔离游客",
|
||||||
@@ -203,9 +194,7 @@ const image = Buffer.from(
|
|||||||
key = Buffer.from("2019ysapp7527");
|
key = Buffer.from("2019ysapp7527");
|
||||||
for (let i = 0; i < Math.min(100, image.length); i++)
|
for (let i = 0; i < Math.min(100, image.length); i++)
|
||||||
image[i] ^= key[i % key.length];
|
image[i] ^= key[i % key.length];
|
||||||
type EventRecord = { method: string; payload: Record<string, unknown> };
|
const errors: string[] = [],
|
||||||
const events: EventRecord[] = [],
|
|
||||||
errors: string[] = [],
|
|
||||||
missing: string[] = [],
|
missing: string[] = [],
|
||||||
unexpected: string[] = [],
|
unexpected: string[] = [],
|
||||||
destinations: string[] = [];
|
destinations: string[] = [];
|
||||||
@@ -214,14 +203,8 @@ let releaseImage: (() => void) | undefined,
|
|||||||
delayImage = false,
|
delayImage = false,
|
||||||
imageRequested = 0;
|
imageRequested = 0;
|
||||||
const configure = async (context: BrowserContext, ageAccepted = false) => {
|
const configure = async (context: BrowserContext, ageAccepted = false) => {
|
||||||
await context.exposeBinding(
|
|
||||||
"__recordEvent",
|
|
||||||
(_source, event: EventRecord) => {
|
|
||||||
events.push(event);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await context.addInitScript({
|
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) => {
|
await context.route("**/*", async (route) => {
|
||||||
const request = route.request(),
|
const request = route.request(),
|
||||||
@@ -302,7 +285,6 @@ const configure = async (context: BrowserContext, ageAccepted = false) => {
|
|||||||
};
|
};
|
||||||
const ready = async (page: Page) => {
|
const ready = async (page: Page) => {
|
||||||
await page.waitForSelector(".hk-site-header");
|
await page.waitForSelector(".hk-site-header");
|
||||||
await page.waitForFunction(() => !!window.__trackerCommonFields);
|
|
||||||
};
|
};
|
||||||
const footerQR = async (page: Page, value: string, previous = "") => {
|
const footerQR = async (page: Page, value: string, previous = "") => {
|
||||||
await page.waitForFunction(({ value, previous }) => {
|
await page.waitForFunction(({ value, previous }) => {
|
||||||
@@ -413,17 +395,8 @@ try {
|
|||||||
await stack.keyboard.press("Escape");
|
await stack.keyboard.press("Escape");
|
||||||
await stack.waitForSelector("[data-dialog-ad]", { state: "detached" });
|
await stack.waitForSelector("[data-dialog-ad]", { state: "detached" });
|
||||||
await expectOverflow(stack, "");
|
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();
|
await stacked.close();
|
||||||
imageRequested = 0;
|
imageRequested = 0;
|
||||||
events.length = 0;
|
|
||||||
// Age gate cannot be bypassed by Escape. Ads wait for actual XOR decode + image
|
// 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.
|
// load, resume their queue on another page, and stay dismissed in this session.
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
@@ -459,7 +432,6 @@ try {
|
|||||||
await page.waitForTimeout(150);
|
await page.waitForTimeout(150);
|
||||||
assert.equal(imageRequested, 1);
|
assert.equal(imageRequested, 1);
|
||||||
assert.equal(await page.locator("[data-dialog-ad]").isVisible(), false);
|
assert.equal(await page.locator("[data-dialog-ad]").isVisible(), false);
|
||||||
assert.equal(events.filter((e) => e.method === "adImpression").length, 0);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(() => localStorage.getItem("hg_age_ok")),
|
await page.evaluate(() => localStorage.getItem("hg_age_ok")),
|
||||||
"1",
|
"1",
|
||||||
@@ -515,16 +487,6 @@ try {
|
|||||||
await page.evaluate(() => sessionStorage.getItem("dialogAdDismissed")),
|
await page.evaluate(() => sessionStorage.getItem("dialogAdDismissed")),
|
||||||
"2|1|2",
|
"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 page.goto(origin + "/?ads=1&bad=1");
|
||||||
await ready(page);
|
await ready(page);
|
||||||
await page.locator("[data-dialog-ad].hk-is-open").waitFor();
|
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"),
|
await page.locator("[data-dialog-ad-id]").getAttribute("data-dialog-ad-id"),
|
||||||
"2",
|
"2",
|
||||||
);
|
);
|
||||||
assert.equal(
|
|
||||||
events.some(
|
|
||||||
(e) => e.method === "adImpression" && e.payload.ad_id === "bad",
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
// Actual HeaderSearch hooks, keyboard navigation and history; no account entry remains.
|
// Actual HeaderSearch hooks, keyboard navigation and history; no account entry remains.
|
||||||
await page.goto(origin + "/?user=1");
|
await page.goto(origin + "/?user=1");
|
||||||
@@ -1053,15 +1009,8 @@ try {
|
|||||||
assert.ok(destinations.includes("/mock/android.apk"));
|
assert.ok(destinations.includes("/mock/android.apk"));
|
||||||
assert.ok(destinations.includes("/mock/fallback"));
|
assert.ok(destinations.includes("/mock/fallback"));
|
||||||
assert.ok(destinations.includes("/mock/launch"));
|
assert.ok(destinations.includes("/mock/launch"));
|
||||||
assert.ok(
|
|
||||||
events.some(
|
|
||||||
(event) =>
|
|
||||||
event.method === "navigation" &&
|
|
||||||
event.payload.navigation_key === "header_download",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
console.log(
|
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 {
|
} finally {
|
||||||
releaseImage?.();
|
releaseImage?.();
|
||||||
|
|||||||
@@ -39,13 +39,6 @@ async function expectPageStyles(page: Page, required: string[], absent: string[]
|
|||||||
const canonical = "https://canonical.drama.test";
|
const canonical = "https://canonical.drama.test";
|
||||||
const userAgent =
|
const userAgent =
|
||||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) NativeSSRSmoke/1.0";
|
"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 indexNow = "native_ssr_verification_20261010";
|
||||||
const verifyFilename = `native-ssr-${randomBytes(6).toString("hex")}.html`;
|
const verifyFilename = `native-ssr-${randomBytes(6).toString("hex")}.html`;
|
||||||
const verifyPath = resolve("public", verifyFilename);
|
const verifyPath = resolve("public", verifyFilename);
|
||||||
@@ -189,7 +182,6 @@ const mock = createServer(async (req, res) => {
|
|||||||
seoNavInfo: {},
|
seoNavInfo: {},
|
||||||
navPageList: [],
|
navPageList: [],
|
||||||
advitise: {},
|
advitise: {},
|
||||||
reportSdkConfig: "",
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -381,7 +373,6 @@ function headers(caseName: string, token = ""): Headers {
|
|||||||
"user-agent": userAgent,
|
"user-agent": userAgent,
|
||||||
"x-forwarded-for": "203.0.113.42, 198.51.100.7",
|
"x-forwarded-for": "203.0.113.42, 198.51.100.7",
|
||||||
"x-user-agent": "DevType=SSR smoke;SysType=h5_ios;sid=original",
|
"x-user-agent": "DevType=SSR smoke;SysType=h5_ios;sid=original",
|
||||||
"x-sdk-common": sdkCommon,
|
|
||||||
...(token
|
...(token
|
||||||
? {
|
? {
|
||||||
cookie: `token=${token}; nickName=STALE_COOKIE; userId=1; isRegister=${token.startsWith("guest-") ? "0" : "1"}; channel=smoke-channel; client_cookie=must_forward`,
|
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"],
|
call.headers["x-user-agent"],
|
||||||
"DevType=SSR smoke;SysType=h5_ios;sid=original",
|
"DevType=SSR smoke;SysType=h5_ios;sid=original",
|
||||||
);
|
);
|
||||||
assert.equal(call.headers["x-sdk-common"], sdkCommon);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
Object.keys(call.headers).some((key) => key.startsWith("x-hk-")),
|
Object.keys(call.headers).some((key) => key.startsWith("x-hk-")),
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ const encrypted = decryptImageBytes(
|
|||||||
const entry = join(temp, "fixture.tsx");
|
const entry = join(temp, "fixture.tsx");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
entry,
|
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");
|
const navStub = join(temp, "navigation.ts");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
@@ -159,18 +159,6 @@ const baseData = () => ({
|
|||||||
{ id: 102, currentEpisode: 2, payType: 1, needUnlock: true },
|
{ 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: [],
|
hotList: [],
|
||||||
guessLike: [
|
guessLike: [
|
||||||
{ id: 30, slug: "guess", playName: "猜你喜欢测试", cover: "/static/brand/placeholder.svg", totalEpisode: 3, updateStatus: 2 },
|
{ 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);
|
res.end(playbackStyles.css);
|
||||||
return;
|
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/")) {
|
if (url.pathname.startsWith("/api/")) {
|
||||||
res.setHeader("content-type", "application/json");
|
res.setHeader("content-type", "application/json");
|
||||||
let data: unknown = {};
|
let data: unknown = {};
|
||||||
@@ -378,35 +359,6 @@ try {
|
|||||||
return video.play();
|
return video.play();
|
||||||
});
|
});
|
||||||
await page.waitForFunction(() => document.querySelector("video")!.ended);
|
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(
|
assert.equal(
|
||||||
await page
|
await page
|
||||||
.locator("#failed-image-test")
|
.locator("#failed-image-test")
|
||||||
@@ -440,30 +392,11 @@ try {
|
|||||||
),
|
),
|
||||||
true,
|
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.getByRole("button", { name: "预告片", exact: true }).click();
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
const video = document.querySelector("video");
|
const video = document.querySelector("video");
|
||||||
return !!video && !video.paused && video.currentTime > 0.1;
|
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(
|
assert.equal(
|
||||||
await page.locator(".hk-playback-player").getAttribute("data-play-mode"),
|
await page.locator(".hk-playback-player").getAttribute("data-play-mode"),
|
||||||
"preview",
|
"preview",
|
||||||
@@ -493,18 +426,8 @@ try {
|
|||||||
const watchReq = requests.find(
|
const watchReq = requests.find(
|
||||||
(req) => req.url === "/api/playlet/chapter/watch/report",
|
(req) => req.url === "/api/playlet/chapter/watch/report",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert(watchReq, "watch report request");
|
||||||
watchReq?.headers["x-sdk-common"],
|
assert.equal(watchReq.headers["x-sdk-common"], undefined);
|
||||||
Buffer.from(
|
|
||||||
JSON.stringify({
|
|
||||||
sid: "mock-session",
|
|
||||||
device_id: "mock-device",
|
|
||||||
device: "PC",
|
|
||||||
device_fingerprint: "mock-fp",
|
|
||||||
fp_version: "1",
|
|
||||||
}),
|
|
||||||
).toString("base64"),
|
|
||||||
);
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
await page.screenshot({ path: join(output, device + "-player.png") });
|
await page.screenshot({ path: join(output, device + "-player.png") });
|
||||||
const paid = {
|
const paid = {
|
||||||
@@ -667,7 +590,7 @@ try {
|
|||||||
fullscreen: true,
|
fullscreen: true,
|
||||||
errorRetry: true,
|
errorRetry: true,
|
||||||
realMp4: true,
|
realMp4: true,
|
||||||
recommendationsAndTracking: true,
|
recommendations: true,
|
||||||
encryptedImageAndFallback: true,
|
encryptedImageAndFallback: true,
|
||||||
lockedAppDownloadOnly: true,
|
lockedAppDownloadOnly: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
import { toLegacyPath } from "@/lib/routes";
|
|
||||||
export type Fields = Record<string, unknown>;
|
|
||||||
export interface TrackerBoot {
|
|
||||||
appId?: string;
|
|
||||||
encryptedConfig?: unknown;
|
|
||||||
appVersion?: string;
|
|
||||||
uid?: string;
|
|
||||||
channel?: string;
|
|
||||||
pageKey?: string;
|
|
||||||
pageName?: string;
|
|
||||||
userType?: string;
|
|
||||||
}
|
|
||||||
interface WebSDK {
|
|
||||||
init(options: Fields): unknown;
|
|
||||||
track: Record<string, (payload: Fields) => unknown>;
|
|
||||||
getCommonFields?(keys: string[]): Promise<Fields>;
|
|
||||||
flush?(): unknown;
|
|
||||||
setUid?(uid: string): void;
|
|
||||||
setChannel?(channel: string): void;
|
|
||||||
setContext?(fields: Fields): void;
|
|
||||||
}
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
WebSDK?: WebSDK;
|
|
||||||
__TRACKER_BOOT__?: TrackerBoot;
|
|
||||||
__TRACKER__?: typeof tracker;
|
|
||||||
tracker?: typeof tracker;
|
|
||||||
__trackerCommonFields?: Record<string, string>;
|
|
||||||
__CHANNEL_CODE__?: string;
|
|
||||||
dataLayer?: unknown[];
|
|
||||||
gtag?: (...args: unknown[]) => void;
|
|
||||||
ym?: ((...args: unknown[]) => void) & { a?: unknown[][]; l?: number };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const str = (value: unknown, fallback = "") =>
|
|
||||||
String(value ?? "").trim() || fallback;
|
|
||||||
const number = (value: unknown, fallback = 0) =>
|
|
||||||
Number.isFinite(Number(value)) ? Number(value) : fallback;
|
|
||||||
const commonKeys = [
|
|
||||||
"sid",
|
|
||||||
"device_id",
|
|
||||||
"device",
|
|
||||||
"device_fingerprint",
|
|
||||||
"fp_version",
|
|
||||||
];
|
|
||||||
const serverKeys = [
|
|
||||||
"app_id",
|
|
||||||
"uid",
|
|
||||||
"channel",
|
|
||||||
"sid",
|
|
||||||
"client_ts",
|
|
||||||
"device",
|
|
||||||
"device_id",
|
|
||||||
"device_brand",
|
|
||||||
"device_model",
|
|
||||||
"device_fingerprint",
|
|
||||||
"fp_version",
|
|
||||||
"user_agent",
|
|
||||||
"sdk_version",
|
|
||||||
"app_version",
|
|
||||||
];
|
|
||||||
let activeBoot: TrackerBoot = {};
|
|
||||||
let finishReady: () => void;
|
|
||||||
const ready = new Promise<void>((resolve) => {
|
|
||||||
finishReady = resolve;
|
|
||||||
});
|
|
||||||
let initializing = false;
|
|
||||||
let common: Record<string, string> = Object.fromEntries(
|
|
||||||
commonKeys.map((key) => [key, ""]),
|
|
||||||
);
|
|
||||||
let commonReady: Promise<void> = Promise.resolve();
|
|
||||||
let page = { key: "internal_other", name: "站内其他页面" };
|
|
||||||
let userType = "normal";
|
|
||||||
let drama: Fields | null = null;
|
|
||||||
const impressions = new Set<string>();
|
|
||||||
export function normalizeUID(value: unknown): string {
|
|
||||||
const uid = str(value);
|
|
||||||
return uid.length >= 4 &&
|
|
||||||
uid.length <= 32 &&
|
|
||||||
/^[A-Za-z0-9_]+$/.test(uid) &&
|
|
||||||
!["u_1001", "stress_user", "u_lab"].includes(uid.toLowerCase())
|
|
||||||
? uid
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
export function normalizeChannel(value: unknown): string {
|
|
||||||
const channel = str(value);
|
|
||||||
return channel.length <= 50 &&
|
|
||||||
/^[A-Za-z0-9_.-]+$/.test(channel) &&
|
|
||||||
!["0", "self", "natural", "official", "channel", "unknown", "-"].includes(
|
|
||||||
channel.toLowerCase(),
|
|
||||||
)
|
|
||||||
? channel
|
|
||||||
: "organic";
|
|
||||||
}
|
|
||||||
export function inferPage(path: string) {
|
|
||||||
path = toLegacyPath(path);
|
|
||||||
if (path === "/") return { key: "home", name: "首页" };
|
|
||||||
const rules = [
|
|
||||||
["series-play", "series_play", "AI成人剧集播放页"],
|
|
||||||
["series", "series", "AI成人剧集"],
|
|
||||||
["play", "play", "短剧播放页"],
|
|
||||||
["detail", "detail", "短剧详情"],
|
|
||||||
["list", "list", "短剧列表"],
|
|
||||||
["rank", "rank", "排行榜"],
|
|
||||||
["topic-detail", "topic_detail", "专题详情"],
|
|
||||||
["topic", "topic", "专题列表"],
|
|
||||||
["tags", "tags", "标签详情"],
|
|
||||||
["search-mobile", "search", "搜索结果页"],
|
|
||||||
["search", "search", "搜索结果页"],
|
|
||||||
["history", "history", "观看历史"],
|
|
||||||
["favorites", "favorites", "我的收藏"],
|
|
||||||
["profile", "profile", "个人中心"],
|
|
||||||
["home-road", "home_road", "回家的路"],
|
|
||||||
];
|
|
||||||
const rule = rules.find(([prefix]) =>
|
|
||||||
new RegExp(`^/${prefix}(?:/|$)`).test(path),
|
|
||||||
);
|
|
||||||
return rule
|
|
||||||
? { key: rule[1], name: rule[2] }
|
|
||||||
: { key: "internal_other", name: "站内其他页面" };
|
|
||||||
}
|
|
||||||
export function initializeTracker(boot: TrackerBoot): Promise<void> {
|
|
||||||
const inferred = inferPage(location.pathname);
|
|
||||||
page = {
|
|
||||||
key: str(boot.pageKey, inferred.key),
|
|
||||||
name: str(boot.pageName, inferred.name),
|
|
||||||
};
|
|
||||||
userType = boot.userType === "vip" ? "vip" : "normal";
|
|
||||||
activeBoot = boot;
|
|
||||||
window.__TRACKER_BOOT__ = boot;
|
|
||||||
window.__TRACKER__ = tracker;
|
|
||||||
window.tracker = tracker;
|
|
||||||
if (!initializing) {
|
|
||||||
initializing = true;
|
|
||||||
const resolve = () => finishReady();
|
|
||||||
let attempts = 0;
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (!window.WebSDK?.track && ++attempts < 100) return;
|
|
||||||
clearInterval(timer);
|
|
||||||
if (!window.WebSDK) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Promise.resolve()
|
|
||||||
.then(() =>
|
|
||||||
window.WebSDK?.init({
|
|
||||||
appId: str(boot.appId),
|
|
||||||
uid: normalizeUID(boot.uid),
|
|
||||||
channel: normalizeChannel(boot.channel || window.__CHANNEL_CODE__),
|
|
||||||
encryptedConfig: str(boot.encryptedConfig),
|
|
||||||
appVersion: str(boot.appVersion, "1.0.0"),
|
|
||||||
disableAutoEvents: ["app_page_view", "page_click"],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
|
||||||
.then(() => {
|
|
||||||
// Core events need only SDK initialization; device fingerprint loading must not stall them.
|
|
||||||
commonReady = (async () => {
|
|
||||||
try {
|
|
||||||
const fields = await window.WebSDK?.getCommonFields?.(
|
|
||||||
commonKeys.slice(),
|
|
||||||
);
|
|
||||||
common = Object.fromEntries(
|
|
||||||
commonKeys.map((key) => [key, str(fields?.[key])]),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* Empty common fields preserve the original degraded SDK contract. */
|
|
||||||
}
|
|
||||||
window.__trackerCommonFields = common;
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("tracker:common-fields", { detail: common }),
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("tracker:ready", { detail: page }),
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
return ready;
|
|
||||||
}
|
|
||||||
export async function track(method: string, payload: Fields): Promise<unknown> {
|
|
||||||
await ready;
|
|
||||||
try {
|
|
||||||
return await window.WebSDK?.track[method]?.(payload);
|
|
||||||
} catch {
|
|
||||||
return { sent: false, ok: false, reason: "exception" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export function pageView(extra: Fields = {}) {
|
|
||||||
let referrer = { key: "direct", name: "直接访问" };
|
|
||||||
try {
|
|
||||||
const saved = sessionStorage.getItem("__tracker_last_page");
|
|
||||||
if (saved) referrer = JSON.parse(saved);
|
|
||||||
else if (document.referrer) {
|
|
||||||
const url = new URL(document.referrer);
|
|
||||||
referrer =
|
|
||||||
url.host === location.host
|
|
||||||
? inferPage(url.pathname)
|
|
||||||
: { key: "external", name: url.host };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Storage is optional. */
|
|
||||||
}
|
|
||||||
const navigation = performance.getEntriesByType("navigation")[0] as
|
|
||||||
| PerformanceNavigationTiming
|
|
||||||
| undefined;
|
|
||||||
void track("appPageView", {
|
|
||||||
user_type: userType,
|
|
||||||
page_key: page.key,
|
|
||||||
page_name: page.name,
|
|
||||||
referrer_page_key: referrer.key,
|
|
||||||
referrer_page_name: referrer.name,
|
|
||||||
current_page_key: page.key,
|
|
||||||
current_page_name: page.name,
|
|
||||||
page_load_time: Math.max(
|
|
||||||
0,
|
|
||||||
Math.round(
|
|
||||||
navigation?.loadEventEnd ||
|
|
||||||
navigation?.domContentLoadedEventEnd ||
|
|
||||||
performance.now(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
recommend_trace_id: "",
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem("__tracker_last_page", JSON.stringify(page));
|
|
||||||
} catch {
|
|
||||||
/* Storage is optional. */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export function navigation(anchor: HTMLAnchorElement) {
|
|
||||||
const href = anchor.getAttribute("href");
|
|
||||||
if (!href || /^(#|javascript:)/i.test(href)) return;
|
|
||||||
let key = str(anchor.dataset.navigationKey || anchor.dataset.navKey);
|
|
||||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) {
|
|
||||||
const url = new URL(href, location.href);
|
|
||||||
const target = inferPage(url.pathname);
|
|
||||||
key =
|
|
||||||
url.origin !== location.origin
|
|
||||||
? "nav_external"
|
|
||||||
: target.key !== "internal_other"
|
|
||||||
? "nav_" + target.key
|
|
||||||
: "nav_" +
|
|
||||||
(url.pathname.split("/").filter(Boolean)[0] || "home")
|
|
||||||
.replace(/[^a-z0-9]+/gi, "_")
|
|
||||||
.slice(0, 48);
|
|
||||||
}
|
|
||||||
void track("navigation", {
|
|
||||||
navigation_key: key.toLowerCase(),
|
|
||||||
navigation_name: str(
|
|
||||||
anchor.dataset.navigationName || anchor.dataset.navName,
|
|
||||||
anchor.getAttribute("aria-label") ||
|
|
||||||
anchor.textContent?.replace(/\s+/g, " ") ||
|
|
||||||
key,
|
|
||||||
).slice(0, 255),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
export function pageClick(event: MouseEvent) {
|
|
||||||
void track("pageClick", {
|
|
||||||
page_key: page.key,
|
|
||||||
page_name: page.name,
|
|
||||||
click_page_x: Math.trunc(event.clientX),
|
|
||||||
click_page_y: Math.trunc(event.clientY),
|
|
||||||
click_x_percent: innerWidth
|
|
||||||
? Math.round(
|
|
||||||
Math.max(0, Math.min(100, (event.clientX / innerWidth) * 100)),
|
|
||||||
)
|
|
||||||
: 0,
|
|
||||||
click_y_percent: innerHeight
|
|
||||||
? Math.round(
|
|
||||||
Math.max(0, Math.min(100, (event.clientY / innerHeight) * 100)),
|
|
||||||
)
|
|
||||||
: 0,
|
|
||||||
screen_width: screen.width,
|
|
||||||
screen_height: screen.height,
|
|
||||||
recommend_trace_id: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
export function externalAd(node: HTMLElement, type: string) {
|
|
||||||
if (!["show", "click"].includes(type) || !node.dataset.adId) return;
|
|
||||||
const slots = Array.from(
|
|
||||||
document.querySelectorAll(
|
|
||||||
'[data-track-ad="external"][data-ad-id]:not([data-ad-clone])',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const slot = node.hasAttribute("data-ad-clone")
|
|
||||||
? slots.find(
|
|
||||||
(slot) => slot.getAttribute("data-ad-id") === node.dataset.adId,
|
|
||||||
)
|
|
||||||
: node;
|
|
||||||
const position = Math.max(1, slots.indexOf(slot as Element) + 1);
|
|
||||||
const interstitial = node.dataset.adType === "interstitial";
|
|
||||||
const payload = {
|
|
||||||
page_key: page.key,
|
|
||||||
page_name: page.name,
|
|
||||||
ad_slot_key: str(
|
|
||||||
node.dataset.adSlotKey,
|
|
||||||
`${page.key}_${interstitial ? "popup" : "content_banner"}_${position}`,
|
|
||||||
),
|
|
||||||
ad_slot_name: str(
|
|
||||||
node.dataset.adSlotName,
|
|
||||||
page.name + (interstitial ? "弹窗广告位" : `内容横幅第${position}位`),
|
|
||||||
),
|
|
||||||
ad_id: node.dataset.adId,
|
|
||||||
creative_id: str(node.dataset.creativeId),
|
|
||||||
ad_type: str(node.dataset.adType, "banner"),
|
|
||||||
};
|
|
||||||
const key = payload.ad_slot_key + "|" + payload.ad_id;
|
|
||||||
if (!impressions.has(key)) {
|
|
||||||
impressions.add(key);
|
|
||||||
void track("adImpression", payload);
|
|
||||||
}
|
|
||||||
if (type === "click" && node.dataset.adClickable === "1")
|
|
||||||
void track("adClick", payload);
|
|
||||||
}
|
|
||||||
export function setDramaContext(value: Fields | null) {
|
|
||||||
drama = value;
|
|
||||||
}
|
|
||||||
const behaviorNames: Record<string, string> = {
|
|
||||||
ep_view: "展示",
|
|
||||||
ep_play: "播放",
|
|
||||||
ep_pause: "暂停",
|
|
||||||
ep_forward: "快进",
|
|
||||||
ep_rewind: "快退",
|
|
||||||
ep_complete: "播放完成",
|
|
||||||
ep_share: "分享",
|
|
||||||
};
|
|
||||||
export function dramaEvent(
|
|
||||||
behavior: string,
|
|
||||||
video?: HTMLVideoElement | null,
|
|
||||||
options: { played?: number; forceProgress?: number } = {},
|
|
||||||
) {
|
|
||||||
if (!drama) return Promise.resolve();
|
|
||||||
const duration =
|
|
||||||
video && Number.isFinite(video.duration)
|
|
||||||
? Math.round(video.duration)
|
|
||||||
: number(drama.drama_duration);
|
|
||||||
const current = Math.round(video?.currentTime || 0);
|
|
||||||
return track("dramaEvent", {
|
|
||||||
...drama,
|
|
||||||
drama_duration: Math.max(0, duration),
|
|
||||||
play_duration: Math.max(0, Math.round(options.played || 0)),
|
|
||||||
play_progress:
|
|
||||||
options.forceProgress ??
|
|
||||||
(behavior === "ep_complete"
|
|
||||||
? 100
|
|
||||||
: duration > 0
|
|
||||||
? Math.round(Math.min(100, (current / duration) * 100))
|
|
||||||
: 0),
|
|
||||||
play_speed: Math.max(1, Math.round((video?.playbackRate || 1) * 100)),
|
|
||||||
play_behavior_key: behavior,
|
|
||||||
play_behavior_name: behaviorNames[behavior] || behavior,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
export function dramaStatusChange(
|
|
||||||
episode: number,
|
|
||||||
videoID: string | number,
|
|
||||||
override?: string,
|
|
||||||
) {
|
|
||||||
if (!drama) return Promise.resolve();
|
|
||||||
const previous = number(drama.episode, 1);
|
|
||||||
const behavior =
|
|
||||||
override ||
|
|
||||||
(episode === previous + 1
|
|
||||||
? "ep_next"
|
|
||||||
: episode === previous - 1
|
|
||||||
? "ep_previous"
|
|
||||||
: "ep_switch");
|
|
||||||
if (!override && episode === previous && str(videoID) === str(drama.video_id))
|
|
||||||
return Promise.resolve();
|
|
||||||
const names: Record<string, string> = {
|
|
||||||
ep_auto_play: "自动播放",
|
|
||||||
ep_previous: "上一集",
|
|
||||||
ep_next: "下一集",
|
|
||||||
ep_switch: "切换剧集",
|
|
||||||
ep_unlock: "解锁",
|
|
||||||
};
|
|
||||||
return track("dramaStatusChange", {
|
|
||||||
media_id: drama.media_id,
|
|
||||||
drama_id: drama.drama_id,
|
|
||||||
drama_title: drama.drama_title,
|
|
||||||
episode: Math.max(1, episode),
|
|
||||||
video_id: str(videoID, str(drama.video_id)),
|
|
||||||
pre_episode: previous,
|
|
||||||
drama_behavior_key: behavior,
|
|
||||||
drama_behavior_name: names[behavior] || behavior,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/** Full per-request SDK snapshot for server-side drama events; header compatibility remains five fields. */
|
|
||||||
export async function serverContext(keys = serverKeys): Promise<Fields> {
|
|
||||||
await ready;
|
|
||||||
let fields: Fields = {};
|
|
||||||
try {
|
|
||||||
fields = (await window.WebSDK?.getCommonFields?.(serverKeys.slice())) || {};
|
|
||||||
} catch {
|
|
||||||
/* Cached device identifiers remain usable. */
|
|
||||||
}
|
|
||||||
const defaults: Fields = {
|
|
||||||
...common,
|
|
||||||
app_id: str(activeBoot.appId),
|
|
||||||
uid: normalizeUID(activeBoot.uid),
|
|
||||||
channel: normalizeChannel(activeBoot.channel || window.__CHANNEL_CODE__),
|
|
||||||
client_ts: Math.floor(Date.now() / 1000),
|
|
||||||
device_brand: "",
|
|
||||||
device_model: "",
|
|
||||||
user_agent: navigator.userAgent,
|
|
||||||
sdk_version: "1.2.0",
|
|
||||||
app_version: str(activeBoot.appVersion, "1.0.0"),
|
|
||||||
};
|
|
||||||
return Object.fromEntries(
|
|
||||||
keys.map((key) => [
|
|
||||||
key,
|
|
||||||
key === "client_ts"
|
|
||||||
? Math.floor(number(fields[key], number(defaults[key])))
|
|
||||||
: str(fields[key] ?? defaults[key]),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export const tracker = {
|
|
||||||
init: initializeTracker,
|
|
||||||
pageView,
|
|
||||||
dramaEvent,
|
|
||||||
dramaStatusChange,
|
|
||||||
dramaUnlock: () =>
|
|
||||||
dramaStatusChange(
|
|
||||||
number(drama?.episode, 1),
|
|
||||||
str(drama?.video_id),
|
|
||||||
"ep_unlock",
|
|
||||||
),
|
|
||||||
externalAd,
|
|
||||||
advertising: (payload: Fields) => track("advertising", payload),
|
|
||||||
keywordSearch: (payload: Fields) => track("keywordSearch", payload),
|
|
||||||
keywordClick: (payload: Fields) => track("keywordClick", payload),
|
|
||||||
getDramaContext: () => ({ ...drama }),
|
|
||||||
getCommonFieldsSync: () => common,
|
|
||||||
getCommonFields: serverContext,
|
|
||||||
serverContext,
|
|
||||||
getCommonField: async (key: string) => {
|
|
||||||
await ready;
|
|
||||||
await commonReady;
|
|
||||||
return common[key] || "";
|
|
||||||
},
|
|
||||||
encodeSDKCommonHeader: (fields = common) =>
|
|
||||||
btoa(
|
|
||||||
unescape(
|
|
||||||
encodeURIComponent(
|
|
||||||
JSON.stringify(
|
|
||||||
Object.fromEntries(
|
|
||||||
commonKeys.map((key) => [key, str(fields[key])]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
getSDKCommonHeader: async () => {
|
|
||||||
await ready;
|
|
||||||
await commonReady;
|
|
||||||
return tracker.encodeSDKCommonHeader();
|
|
||||||
},
|
|
||||||
flush: async () => {
|
|
||||||
try {
|
|
||||||
await window.WebSDK?.flush?.();
|
|
||||||
} catch {
|
|
||||||
/* Analytics must never interrupt playback. */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setUid: (uid: unknown) => {
|
|
||||||
activeBoot.uid = normalizeUID(uid);
|
|
||||||
window.WebSDK?.setUid?.(activeBoot.uid);
|
|
||||||
},
|
|
||||||
setChannel: (channel: unknown) => {
|
|
||||||
activeBoot.channel = normalizeChannel(channel);
|
|
||||||
window.WebSDK?.setChannel?.(activeBoot.channel);
|
|
||||||
},
|
|
||||||
setContext: (context: Fields) => window.WebSDK?.setContext?.(context),
|
|
||||||
};
|
|
||||||
@@ -8,23 +8,10 @@ import {
|
|||||||
str,
|
str,
|
||||||
type CatalogRecord,
|
type CatalogRecord,
|
||||||
} from "@/features/catalog/model";
|
} from "@/features/catalog/model";
|
||||||
function adProps(item: CatalogRecord, type: "banner" | "icon") {
|
function adProps(item: CatalogRecord) {
|
||||||
const location = str(item, "Location"),
|
const link = str(item, "Link");
|
||||||
link = str(item, "Link");
|
|
||||||
return {
|
return {
|
||||||
href: safeHref(link),
|
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" } : {}),
|
...(link ? { target: "_blank", rel: "sponsored nofollow noopener" } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -37,7 +24,7 @@ export function AdBanners({ items }: { items: unknown }) {
|
|||||||
<a
|
<a
|
||||||
key={`${str(item, "ID")}-${index}`}
|
key={`${str(item, "ID")}-${index}`}
|
||||||
className="hk-promotion-card"
|
className="hk-promotion-card"
|
||||||
{...adProps(item, "banner")}
|
{...adProps(item)}
|
||||||
>
|
>
|
||||||
<EncryptedImage
|
<EncryptedImage
|
||||||
loading="eager"
|
loading="eager"
|
||||||
@@ -73,9 +60,8 @@ export function PromoGrid({ items }: { items: unknown }) {
|
|||||||
<a
|
<a
|
||||||
key={`${str(item, "ID")}-${index}`}
|
key={`${str(item, "ID")}-${index}`}
|
||||||
className="hk-promotion-grid__item"
|
className="hk-promotion-grid__item"
|
||||||
{...adProps(item, "icon")}
|
{...adProps(item)}
|
||||||
tabIndex={clone ? -1 : undefined}
|
tabIndex={clone ? -1 : undefined}
|
||||||
data-ad-clone={clone ? "1" : undefined}
|
|
||||||
>
|
>
|
||||||
<span className="hk-promotion-grid__thumb">
|
<span className="hk-promotion-grid__thumb">
|
||||||
<EncryptedImage
|
<EncryptedImage
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { Data } from "@/server/services/model";
|
import type { Data } from "@/server/services/model";
|
||||||
import { SiteProvider } from "@/components/providers/SiteProvider";
|
import { SiteProvider } from "@/components/providers/SiteProvider";
|
||||||
import { TrackerProvider } from "@/components/analytics/TrackerProvider";
|
|
||||||
import { presentations } from "@/server/react/presentation";
|
import { presentations } from "@/server/react/presentation";
|
||||||
import { SiteHeader } from "./SiteHeader";
|
import { SiteHeader } from "./SiteHeader";
|
||||||
import { SiteFooter } from "./SiteFooter";
|
import { SiteFooter } from "./SiteFooter";
|
||||||
@@ -20,23 +19,9 @@ export function SiteShell({
|
|||||||
return (
|
return (
|
||||||
<SiteProvider data={data} dialogs={presentation.header}>
|
<SiteProvider data={data} dialogs={presentation.header}>
|
||||||
<PagePresentation view={view} />
|
<PagePresentation view={view} />
|
||||||
<TrackerProvider data={data}>
|
{presentation.header && <SiteHeader data={data} />} {children}
|
||||||
{presentation.header && <SiteHeader data={data} />} {children}
|
{presentation.footer && <SiteFooter data={data} />}{" "}
|
||||||
{presentation.footer && <SiteFooter data={data} />}{" "}
|
{presentation.header && <MobileTabbar 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>
|
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
resolvePlayURL,
|
resolvePlayURL,
|
||||||
SHELL_BROWSER,
|
SHELL_BROWSER,
|
||||||
} from "@/lib/media/playback";
|
} from "@/lib/media/playback";
|
||||||
import { bindPlaybackAnalytics } from "./usePlaybackAnalytics";
|
|
||||||
import "xgplayer/dist/index.min.css";
|
import "xgplayer/dist/index.min.css";
|
||||||
import "@/styles/components/video-player.css";
|
import "@/styles/components/video-player.css";
|
||||||
export interface PlayerHandle {
|
export interface PlayerHandle {
|
||||||
@@ -23,14 +22,13 @@ export interface PlayerHandle {
|
|||||||
}
|
}
|
||||||
export interface VideoPlayerProps {
|
export interface VideoPlayerProps {
|
||||||
url: string;
|
url: string;
|
||||||
trackDrama?: boolean;
|
|
||||||
onPlaying?: () => void;
|
onPlaying?: () => void;
|
||||||
onEnded?: () => void;
|
onEnded?: () => void;
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
}
|
}
|
||||||
export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
||||||
function VideoPlayer(
|
function VideoPlayer(
|
||||||
{ url, trackDrama = false, onPlaying, onEnded, autoPlay = false },
|
{ url, onPlaying, onEnded, autoPlay = false },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const host = useRef<HTMLDivElement>(null),
|
const host = useRef<HTMLDivElement>(null),
|
||||||
@@ -58,8 +56,7 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
|||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false,
|
let disposed = false,
|
||||||
instance: Player | null = null,
|
instance: Player | null = null;
|
||||||
unbind = () => undefined as void;
|
|
||||||
setError("");
|
setError("");
|
||||||
void Promise.all([import("xgplayer"), import("xgplayer-hls")])
|
void Promise.all([import("xgplayer"), import("xgplayer-hls")])
|
||||||
.then(([{ default: XGPlayer }, { default: HlsPlugin }]) => {
|
.then(([{ default: XGPlayer }, { default: HlsPlugin }]) => {
|
||||||
@@ -112,7 +109,6 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
|||||||
void instance.play()?.catch(() => undefined);
|
void instance.play()?.catch(() => undefined);
|
||||||
}
|
}
|
||||||
const video = instance.video as HTMLVideoElement;
|
const video = instance.video as HTMLVideoElement;
|
||||||
unbind = bindPlaybackAnalytics(video, trackDrama);
|
|
||||||
video.addEventListener("playing", () => {
|
video.addEventListener("playing", () => {
|
||||||
setError("");
|
setError("");
|
||||||
callbacks.current.onPlaying?.();
|
callbacks.current.onPlaying?.();
|
||||||
@@ -125,11 +121,10 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
unbind();
|
|
||||||
instance?.destroy();
|
instance?.destroy();
|
||||||
if (player.current === instance) player.current = null;
|
if (player.current === instance) player.current = null;
|
||||||
};
|
};
|
||||||
}, [url, trackDrama, autoPlay, retry]);
|
}, [url, autoPlay, retry]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import {
|
|
||||||
dramaEvent,
|
|
||||||
setDramaContext,
|
|
||||||
tracker,
|
|
||||||
type Fields,
|
|
||||||
} from "@/components/analytics/tracker";
|
|
||||||
export function useDramaContext(context: Fields | null) {
|
|
||||||
const viewed = useRef("");
|
|
||||||
useEffect(() => {
|
|
||||||
setDramaContext(context);
|
|
||||||
const key = context
|
|
||||||
? String(context.drama_id) + ":" + String(context.video_id)
|
|
||||||
: "";
|
|
||||||
if (context && viewed.current !== key) {
|
|
||||||
viewed.current = key;
|
|
||||||
void dramaEvent("ep_view");
|
|
||||||
}
|
|
||||||
return () => setDramaContext(null);
|
|
||||||
}, [context]);
|
|
||||||
}
|
|
||||||
const reporters = new WeakMap<HTMLVideoElement, (behavior: string) => void>();
|
|
||||||
export function reportPlaybackAction(
|
|
||||||
behavior: string,
|
|
||||||
video?: HTMLVideoElement | null,
|
|
||||||
) {
|
|
||||||
const report = video && reporters.get(video);
|
|
||||||
if (report) report(behavior);
|
|
||||||
else void dramaEvent(behavior, video);
|
|
||||||
}
|
|
||||||
/** Count wall-clock playing time, excluding buffering and seeking, as the original SDK expects. */
|
|
||||||
export function bindPlaybackAnalytics(
|
|
||||||
video: HTMLVideoElement,
|
|
||||||
enabled: boolean,
|
|
||||||
): () => void {
|
|
||||||
if (!enabled) return () => undefined;
|
|
||||||
let started = 0,
|
|
||||||
elapsed = 0,
|
|
||||||
position = 0,
|
|
||||||
seekFrom: number | null = null;
|
|
||||||
const settle = () => {
|
|
||||||
if (started) elapsed += Math.max(0, Date.now() - started);
|
|
||||||
started = 0;
|
|
||||||
};
|
|
||||||
const begin = () => {
|
|
||||||
if (!started) started = Date.now();
|
|
||||||
};
|
|
||||||
const emit = (behavior: string, keep = false, forceProgress?: number) => {
|
|
||||||
const played =
|
|
||||||
(elapsed + (started ? Math.max(0, Date.now() - started) : 0)) / 1000;
|
|
||||||
void dramaEvent(behavior, video, { played, forceProgress });
|
|
||||||
if (!keep) {
|
|
||||||
elapsed = 0;
|
|
||||||
if (started) started = Date.now();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reporters.set(video, emit);
|
|
||||||
const events: Record<string, () => void> = {
|
|
||||||
play: () => emit("ep_play"),
|
|
||||||
playing: begin,
|
|
||||||
waiting: settle,
|
|
||||||
pause: () => {
|
|
||||||
const pending = started || elapsed > 0;
|
|
||||||
settle();
|
|
||||||
if (!video.ended && !video.seeking && pending) emit("ep_pause");
|
|
||||||
},
|
|
||||||
timeupdate: () => {
|
|
||||||
if (!video.seeking) position = video.currentTime;
|
|
||||||
},
|
|
||||||
seeking: () => {
|
|
||||||
if (seekFrom == null) {
|
|
||||||
settle();
|
|
||||||
seekFrom = position;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
seeked: () => {
|
|
||||||
if (seekFrom == null) return;
|
|
||||||
const from = seekFrom;
|
|
||||||
seekFrom = null;
|
|
||||||
position = video.currentTime;
|
|
||||||
if (Math.abs(position - from) >= 0.5)
|
|
||||||
emit(position > from ? "ep_forward" : "ep_rewind");
|
|
||||||
if (!video.paused && !video.ended) begin();
|
|
||||||
},
|
|
||||||
ended: () => {
|
|
||||||
settle();
|
|
||||||
emit("ep_complete", true);
|
|
||||||
emit("ep_play", false, 100);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Object.entries(events).forEach(([name, handler]) =>
|
|
||||||
video.addEventListener(name, handler),
|
|
||||||
);
|
|
||||||
const report = () => {
|
|
||||||
if (elapsed + (started ? Date.now() - started : 0) >= 500) {
|
|
||||||
settle();
|
|
||||||
emit("ep_play");
|
|
||||||
void tracker.flush();
|
|
||||||
setTimeout(() => void tracker.flush(), 50);
|
|
||||||
setTimeout(() => void tracker.flush(), 200);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const visibility = () => {
|
|
||||||
if (document.visibilityState === "hidden") report();
|
|
||||||
if (!video.paused && !video.ended) begin();
|
|
||||||
};
|
|
||||||
document.addEventListener("visibilitychange", visibility);
|
|
||||||
window.addEventListener("pagehide", report);
|
|
||||||
window.addEventListener("beforeunload", report);
|
|
||||||
return () => {
|
|
||||||
reporters.delete(video);
|
|
||||||
const pending = started || elapsed;
|
|
||||||
settle();
|
|
||||||
if (pending && !video.ended) emit("ep_pause");
|
|
||||||
Object.entries(events).forEach(([name, handler]) =>
|
|
||||||
video.removeEventListener(name, handler),
|
|
||||||
);
|
|
||||||
document.removeEventListener("visibilitychange", visibility);
|
|
||||||
window.removeEventListener("pagehide", report);
|
|
||||||
window.removeEventListener("beforeunload", report);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -439,17 +439,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
|||||||
const timer = setTimeout(onClose, 12000);
|
const timer = setTimeout(onClose, 12000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [onClose]);
|
}, [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 = (
|
const image = (
|
||||||
<EncryptedImage
|
<EncryptedImage
|
||||||
eager
|
eager
|
||||||
@@ -461,15 +450,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
|||||||
onError={onClose}
|
onError={onClose}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const click = () =>
|
|
||||||
document.dispatchEvent(
|
|
||||||
new CustomEvent("external-ad:track", {
|
|
||||||
detail: {
|
|
||||||
element: ref.current?.querySelector("[data-track-ad]"),
|
|
||||||
eventType: "click",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -488,16 +468,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
|||||||
className="hk-advertisement-dialog-panel"
|
className="hk-advertisement-dialog-panel"
|
||||||
data-dialog-ad-item=""
|
data-dialog-ad-item=""
|
||||||
data-dialog-ad-id={id}
|
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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ad.title || "广告"}
|
aria-label={ad.title || "广告"}
|
||||||
@@ -511,7 +481,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="sponsored nofollow noopener"
|
rel="sponsored nofollow noopener"
|
||||||
data-dialog-ad-link=""
|
data-dialog-ad-link=""
|
||||||
onClick={click}
|
|
||||||
>
|
>
|
||||||
{image}
|
{image}
|
||||||
</a>
|
</a>
|
||||||
@@ -541,7 +510,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="sponsored nofollow noopener"
|
rel="sponsored nofollow noopener"
|
||||||
data-dialog-ad-link=""
|
data-dialog-ad-link=""
|
||||||
onClick={click}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
name="play_hollow"
|
name="play_hollow"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { EncryptedImage } from "@/components/media/EncryptedImage";
|
|||||||
import { BackLink } from "@/components/catalog/Interactions";
|
import { BackLink } from "@/components/catalog/Interactions";
|
||||||
import { RecommendationCard } from "@/components/catalog/Cards";
|
import { RecommendationCard } from "@/components/catalog/Cards";
|
||||||
import { Pagination } from "@/components/catalog/Pagination";
|
import { Pagination } from "@/components/catalog/Pagination";
|
||||||
import { track } from "@/lib/tracker";
|
|
||||||
import {
|
import {
|
||||||
array,
|
array,
|
||||||
first,
|
first,
|
||||||
@@ -192,16 +191,6 @@ export function SearchPage({ data }: CatalogPageProps) {
|
|||||||
keyword = str(data, "query"),
|
keyword = str(data, "query"),
|
||||||
works = array(data.works);
|
works = array(data.works);
|
||||||
useEffect(() => setQuery(keyword), [keyword]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchHeader
|
<SearchHeader
|
||||||
@@ -245,20 +234,6 @@ export function SearchPage({ data }: CatalogPageProps) {
|
|||||||
<RecommendationCard
|
<RecommendationCard
|
||||||
key={`${str(item, "ID")}-${index}`}
|
key={`${str(item, "ID")}-${index}`}
|
||||||
item={item}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function CompliancePage({ data }: PageProps) {
|
|||||||
value(config, "TGGroup") ||
|
value(config, "TGGroup") ||
|
||||||
value(config, "Telegram") ||
|
value(config, "Telegram") ||
|
||||||
value(config, "TelegramAdd") ||
|
value(config, "TelegramAdd") ||
|
||||||
"https://t.me/huangguosan";
|
"https://t.me/jbd125";
|
||||||
const ref = useLegalNavigation();
|
const ref = useLegalNavigation();
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function DMCAPage({ data }: PageProps) {
|
|||||||
value(config, "TGGroup") ||
|
value(config, "TGGroup") ||
|
||||||
value(config, "Telegram") ||
|
value(config, "Telegram") ||
|
||||||
value(config, "TelegramAdd") ||
|
value(config, "TelegramAdd") ||
|
||||||
"https://t.me/huangguosan";
|
"https://t.me/jbd125";
|
||||||
const ref = useLegalNavigation();
|
const ref = useLegalNavigation();
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { apiRequest } from "@/lib/api";
|
import { apiRequest } from "@/lib/api";
|
||||||
import { useSite } from "@/components/providers/SiteProvider";
|
import { useSite } from "@/components/providers/SiteProvider";
|
||||||
import { Icon } from "@/components/ui/Icon";
|
import { Icon } from "@/components/ui/Icon";
|
||||||
@@ -7,7 +7,6 @@ import { BackLink } from "@/components/catalog/Interactions";
|
|||||||
import { AdBanners } from "@/components/catalog/Promotions";
|
import { AdBanners } from "@/components/catalog/Promotions";
|
||||||
import { PlayLinks } from "@/components/catalog/PlayLinks";
|
import { PlayLinks } from "@/components/catalog/PlayLinks";
|
||||||
import type { PlayerHandle } from "@/components/player/VideoPlayer";
|
import type { PlayerHandle } from "@/components/player/VideoPlayer";
|
||||||
import { useDramaContext } from "@/components/player/usePlaybackAnalytics";
|
|
||||||
import {
|
import {
|
||||||
array,
|
array,
|
||||||
chapterPlayURL,
|
chapterPlayURL,
|
||||||
@@ -23,8 +22,7 @@ import {
|
|||||||
externalProps,
|
externalProps,
|
||||||
type Data,
|
type Data,
|
||||||
} from "@/features/catalog/model";
|
} from "@/features/catalog/model";
|
||||||
import { dramaStatusChange } from "@/lib/tracker";
|
import { coinGate } from "./model";
|
||||||
import { trackingContext, coinGate } from "./model";
|
|
||||||
import { PlaybackStage } from "./PlaybackStage";
|
import { PlaybackStage } from "./PlaybackStage";
|
||||||
import { AppOnlyDialog } from "./AppOnlyDialog";
|
import { AppOnlyDialog } from "./AppOnlyDialog";
|
||||||
import { GuessLike, HotList, PlaybackShare, Related, Synopsis } from "./Shared";
|
import { GuessLike, HotList, PlaybackShare, Related, Synopsis } from "./Shared";
|
||||||
@@ -47,12 +45,7 @@ export function DramaWatchPage({ data }: { data: Data }) {
|
|||||||
watchSent = useRef(false);
|
watchSent = useRef(false);
|
||||||
const site = useSite(),
|
const site = useSite(),
|
||||||
loggedIn = !!site.user,
|
loggedIn = !!site.user,
|
||||||
name = displayName(p),
|
name = displayName(p);
|
||||||
context = useMemo(
|
|
||||||
() => trackingContext(data.trackerDrama),
|
|
||||||
[data.trackerDrama],
|
|
||||||
);
|
|
||||||
useDramaContext(context);
|
|
||||||
const themes = namedThemeTags(
|
const themes = namedThemeTags(
|
||||||
get(p, "ThemeIDs"),
|
get(p, "ThemeIDs"),
|
||||||
get(p, "ThemeNames"),
|
get(p, "ThemeNames"),
|
||||||
@@ -103,11 +96,7 @@ export function DramaWatchPage({ data }: { data: Data }) {
|
|||||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="播放页顶栏">
|
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="播放页顶栏">
|
||||||
<BackLink />
|
<BackLink />
|
||||||
<div className="hk-page-layout-subheader__title">{name}</div>
|
<div className="hk-page-layout-subheader__title">{name}</div>
|
||||||
<PlaybackShare
|
<PlaybackShare />
|
||||||
getVideo={() =>
|
|
||||||
mode === "main" ? player.current?.video() || null : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
<div className="hk-page-layout hk-playback-page">
|
<div className="hk-page-layout hk-playback-page">
|
||||||
<main className="hk-page-main hk-playback-main">
|
<main className="hk-page-main hk-playback-main">
|
||||||
@@ -208,9 +197,6 @@ export function DramaWatchPage({ data }: { data: Data }) {
|
|||||||
<PlaybackShare
|
<PlaybackShare
|
||||||
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
|
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
|
||||||
label
|
label
|
||||||
getVideo={() =>
|
|
||||||
mode === "main" ? player.current?.video() || null : null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
|
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}
|
key={chid}
|
||||||
className={`hk-playback-ep${chid === chapterID ? " hk-is-active" : ""}${coinGate(chapter) ? " hk-playback-ep--coin" : ""}`}
|
className={`hk-playback-ep${chid === chapterID ? " hk-is-active" : ""}${coinGate(chapter) ? " hk-playback-ep--coin" : ""}`}
|
||||||
href={chapterPlayURL(get(p, "Slug"), id, chid)}
|
href={chapterPlayURL(get(p, "Slug"), id, chid)}
|
||||||
onClick={() => void dramaStatusChange(ep, chid)}
|
|
||||||
>
|
>
|
||||||
{ep}
|
{ep}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
type Data,
|
type Data,
|
||||||
} from "@/features/catalog/model";
|
} from "@/features/catalog/model";
|
||||||
import { mediaTime } from "./model";
|
import { mediaTime } from "./model";
|
||||||
import { dramaStatusChange } from "@/lib/tracker";
|
|
||||||
export interface PlaybackStageProps {
|
export interface PlaybackStageProps {
|
||||||
data: Data;
|
data: Data;
|
||||||
series?: boolean;
|
series?: boolean;
|
||||||
@@ -84,7 +83,6 @@ export function PlaybackStage({
|
|||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
ref={playerRef}
|
ref={playerRef}
|
||||||
url={url}
|
url={url}
|
||||||
trackDrama={!series && !preview}
|
|
||||||
onPlaying={onPlaying}
|
onPlaying={onPlaying}
|
||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
/>
|
/>
|
||||||
@@ -183,9 +181,6 @@ export function PlaybackStage({
|
|||||||
className="hk-playback-player__icon"
|
className="hk-playback-player__icon"
|
||||||
href={nextURL}
|
href={nextURL}
|
||||||
aria-label="下一集"
|
aria-label="下一集"
|
||||||
onClick={() =>
|
|
||||||
void dramaStatusChange(ep + 1, Number(data.nextChapterID))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon name="chevron-right" />
|
<Icon name="chevron-right" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { BackLink } from "@/components/catalog/Interactions";
|
|||||||
import { AdBanners } from "@/components/catalog/Promotions";
|
import { AdBanners } from "@/components/catalog/Promotions";
|
||||||
import { PlayLinks } from "@/components/catalog/PlayLinks";
|
import { PlayLinks } from "@/components/catalog/PlayLinks";
|
||||||
import type { PlayerHandle } from "@/components/player/VideoPlayer";
|
import type { PlayerHandle } from "@/components/player/VideoPlayer";
|
||||||
import { useDramaContext } from "@/components/player/usePlaybackAnalytics";
|
|
||||||
import {
|
import {
|
||||||
array,
|
array,
|
||||||
first,
|
first,
|
||||||
@@ -24,7 +23,6 @@ export function SeriesWatchPage({ data }: { data: Data }) {
|
|||||||
title = str(video, "Title") || "视频",
|
title = str(video, "Title") || "视频",
|
||||||
player = useRef<PlayerHandle>(null),
|
player = useRef<PlayerHandle>(null),
|
||||||
[playing, setPlaying] = useState(false);
|
[playing, setPlaying] = useState(false);
|
||||||
useDramaContext(null);
|
|
||||||
const play = () => player.current?.play(),
|
const play = () => player.current?.play(),
|
||||||
actions = "btn btn--ghost play-actions__btn play-actions__btn--icon";
|
actions = "btn btn--ghost play-actions__btn play-actions__btn--icon";
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,15 +20,12 @@ import {
|
|||||||
type Data,
|
type Data,
|
||||||
type NamedTag,
|
type NamedTag,
|
||||||
} from "@/features/catalog/model";
|
} from "@/features/catalog/model";
|
||||||
import { reportPlaybackAction } from "@/components/player/usePlaybackAnalytics";
|
|
||||||
export function PlaybackShare({
|
export function PlaybackShare({
|
||||||
className = "page-subheader__btn",
|
className = "page-subheader__btn",
|
||||||
label = false,
|
label = false,
|
||||||
getVideo,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
label?: boolean;
|
label?: boolean;
|
||||||
getVideo?: () => HTMLVideoElement | null;
|
|
||||||
}) {
|
}) {
|
||||||
const site = useSite();
|
const site = useSite();
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +34,6 @@ export function PlaybackShare({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="分享"
|
aria-label="分享"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
reportPlaybackAction("ep_share", getVideo?.());
|
|
||||||
try {
|
try {
|
||||||
if (navigator.share)
|
if (navigator.share)
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
import { get, number, type CatalogRecord } from "@/features/catalog/model";
|
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 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 {
|
export function coinGate(chapter: CatalogRecord): boolean {
|
||||||
return (
|
return (
|
||||||
!chapter.canWatch &&
|
!chapter.canWatch &&
|
||||||
|
|||||||
+4
-126
@@ -10,35 +10,14 @@ export class ApiError extends Error {
|
|||||||
this.name = "ApiError";
|
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 {
|
export interface RequestOptions {
|
||||||
method?: string;
|
method?: string;
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
trackerContext?: DramaContext;
|
|
||||||
trackerEvent?: string | false;
|
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
skipRecovery?: boolean;
|
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 {
|
export function getCookie(name: string): string {
|
||||||
if (typeof document === "undefined") return "";
|
if (typeof document === "undefined") return "";
|
||||||
const value =
|
const value =
|
||||||
@@ -103,7 +82,6 @@ export function setAuthSession(user: Record<string, unknown>) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* storage disabled */
|
/* storage disabled */
|
||||||
}
|
}
|
||||||
if (user.userId != null) tracker()?.setUid?.(String(user.userId));
|
|
||||||
document.dispatchEvent(new CustomEvent("auth:login", { detail: { user } }));
|
document.dispatchEvent(new CustomEvent("auth:login", { detail: { user } }));
|
||||||
}
|
}
|
||||||
export function getDeviceID(): string {
|
export function getDeviceID(): string {
|
||||||
@@ -149,111 +127,11 @@ export function getChannel(): string {
|
|||||||
setCookie("channel", value, 365);
|
setCookie("channel", value, 365);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
const events: Record<string, string> = {
|
function requestHeaders(options: RequestOptions): Headers {
|
||||||
"/api/playlet/chapter/like/set": "drama_like",
|
return new Headers({
|
||||||
"/api/playlet/like/set": "drama_like",
|
|
||||||
"/api/playlet/chapter/collect/set": "drama_collect",
|
|
||||||
"/api/playlet/collect/set": "drama_collect",
|
|
||||||
"/api/comment/send": "drama_comment",
|
|
||||||
"/api/playlet/chapter/unlock": "drama_purchase",
|
|
||||||
};
|
|
||||||
const base64 = (value: unknown) =>
|
|
||||||
btoa(
|
|
||||||
Array.from(new TextEncoder().encode(JSON.stringify(value)), (n) =>
|
|
||||||
String.fromCharCode(n),
|
|
||||||
).join(""),
|
|
||||||
)
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
const clean = (value: unknown) =>
|
|
||||||
String(value ?? "")
|
|
||||||
.replace(/[^\x20-\x7e]|[;=]/g, " ")
|
|
||||||
.trim();
|
|
||||||
async function deadline<T>(
|
|
||||||
promise: Promise<T>,
|
|
||||||
fallback: T,
|
|
||||||
ms = 1500,
|
|
||||||
): Promise<T> {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
try {
|
|
||||||
return await Promise.race([
|
|
||||||
promise.catch(() => fallback),
|
|
||||||
new Promise<T>((resolve) => {
|
|
||||||
timer = setTimeout(() => resolve(fallback), ms);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function requestHeaders(
|
|
||||||
path: string,
|
|
||||||
method: string,
|
|
||||||
options: RequestOptions,
|
|
||||||
): Promise<Headers> {
|
|
||||||
const headers = new Headers({
|
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
...options.headers,
|
...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;
|
let guestPromise: Promise<boolean> | null = null;
|
||||||
export async function ensureGuestSession(): Promise<boolean> {
|
export async function ensureGuestSession(): Promise<boolean> {
|
||||||
@@ -294,7 +172,7 @@ async function recoverSession(): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
return recovery;
|
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>>(
|
export async function apiRequest<T = Record<string, unknown>>(
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
@@ -304,7 +182,7 @@ export async function apiRequest<T = Record<string, unknown>>(
|
|||||||
const url = new URL(path, location.href);
|
const url = new URL(path, location.href);
|
||||||
for (const [key, value] of Object.entries(options.params || {}))
|
for (const [key, value] of Object.entries(options.params || {}))
|
||||||
if (value != null) url.searchParams.set(key, String(value));
|
if (value != null) url.searchParams.set(key, String(value));
|
||||||
const headers = await requestHeaders(url.href, method, options);
|
const headers = requestHeaders(options);
|
||||||
const payload =
|
const payload =
|
||||||
body == null
|
body == null
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@ const navigationFields = new Set([
|
|||||||
// Raw API/SDK/payment/media objects must retain their request and signed source addresses.
|
// Raw API/SDK/payment/media objects must retain their request and signed source addresses.
|
||||||
const opaqueFields = new Set([
|
const opaqueFields = new Set([
|
||||||
"appConfig", "config", "advitise", "advertise", "chapter", "rawMedia", "videoInfo", "video", "media", "images",
|
"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",
|
"jsonLD", "seoInfo", "canonicalURL", "paginationPrevURL", "paginationNextURL",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"use client";
|
|
||||||
export {
|
|
||||||
track,
|
|
||||||
tracker,
|
|
||||||
dramaEvent,
|
|
||||||
dramaStatusChange,
|
|
||||||
setDramaContext,
|
|
||||||
externalAd,
|
|
||||||
} from "@/components/analytics/tracker";
|
|
||||||
export type { Fields, TrackerBoot } from "@/components/analytics/tracker";
|
|
||||||
@@ -15,7 +15,7 @@ export function installationURL(value: string | undefined, deepLink = false): st
|
|||||||
export function serverConfig() {
|
export function serverConfig() {
|
||||||
const environment = (process.env.APP_ENV || 'development') as AppEnvironment;
|
const environment = (process.env.APP_ENV || 'development') as AppEnvironment;
|
||||||
if (!['development', 'test', 'production'].includes(environment)) throw new Error('Invalid APP_ENV');
|
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');
|
if (upstreamURL && !/^https?:\/\//.test(upstreamURL)) throw new Error('UPSTREAM_API_URL must be an HTTP(S) URL');
|
||||||
return {
|
return {
|
||||||
environment, upstreamURL,
|
environment, upstreamURL,
|
||||||
@@ -23,10 +23,10 @@ export function serverConfig() {
|
|||||||
siteURL: (process.env.SITE_URL || '').trim().replace(/\/+$/, ''),
|
siteURL: (process.env.SITE_URL || '').trim().replace(/\/+$/, ''),
|
||||||
imageCDNURL: (process.env.IMAGE_CDN_URL || '').trim(),
|
imageCDNURL: (process.env.IMAGE_CDN_URL || '').trim(),
|
||||||
timeout: Math.max(100, Number(process.env.UPSTREAM_TIMEOUT_MS) || 20000),
|
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',
|
m3u8KeyFile: process.env.M3U8_KEY_FILE || './config/secrets/m3u8.key',
|
||||||
navigationOnly: process.env.NAVIGATION_ONLY === 'true',
|
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: {
|
installation: {
|
||||||
iosStoreURL: installationURL(process.env.APP_IOS_STORE_URL),
|
iosStoreURL: installationURL(process.env.APP_IOS_STORE_URL),
|
||||||
iosProfileURL: installationURL(process.env.APP_IOS_PROFILE_URL),
|
iosProfileURL: installationURL(process.env.APP_IOS_PROFILE_URL),
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ async function guessData(ctx:RequestContext,id:number,page:number):Promise<Data>
|
|||||||
if(!arr(raw.list).length&&page>1)raw=await apiData(ctx,'/api/app/playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:6},{public:true});
|
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;
|
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>{
|
export async function play(ctx:RequestContext,p:Params):Promise<Data|null>{
|
||||||
if(p.ep&&integer(p.ep)<=0)return null;
|
if(p.ep&&integer(p.ep)<=0)return null;
|
||||||
await ensureGuestOnEnter(ctx);
|
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);}
|
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 [hot,guess]=await Promise.all([hotListData(ctx),guessData(ctx,num(playlet.id),positive(p.guessPage))]);
|
||||||
const seo=resolvePlayPageSEO(chapter,playlet,episode);
|
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};
|
data.playResponse={playlet,chapter,chapterID,canWatch,mainURL,previewURL,hasPreview:!!previewURL,guessLike:guess,hotList};
|
||||||
return model(data);
|
return model(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ export function searchTrends(items: unknown): Data[] {
|
|||||||
}
|
}
|
||||||
export async function appConfigData(ctx:RequestContext):Promise<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 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);
|
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;};
|
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.qq=text(raw.config?.qq);cfg.weiChart=text(raw.config?.weiChart);
|
||||||
cfg.gmailAddr=email(cfg.gmailAddr);
|
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]));
|
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;
|
const siteURL=serverConfig().siteURL;
|
||||||
if(siteURL)cfg.foreverUrlAddr=vm.foreverUrlAddr=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})));
|
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.themeList??=themes;data.homeConfigPlaylets??=activeConfigs(home).filter(config=>num(config.type)===1);
|
||||||
data.searchTrends??=model(searchTrends(hot.hotSearch).slice(0,8));
|
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;
|
const user=ctx.user as Data|null;
|
||||||
data.isLoggedIn=!!ctx.token;
|
data.isLoggedIn=!!ctx.token;
|
||||||
data.isRegistered=isRegistered(ctx);
|
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.recharge??=num(user?.recharge, num(ctx.cookies.get('recharge')));
|
||||||
data.rechargeText??=count(data.recharge);data.vipType??=num(user?.vipType,num(ctx.getCookie('vipType')));
|
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];}
|
}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));
|
return model(applyBrandCopy(data));
|
||||||
}
|
}
|
||||||
export function trackerPage(path:string):[string,string]{
|
export function pageIdentity(path:string):[string,string]{
|
||||||
path=toLegacyPath(path);
|
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','注册']];
|
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','站内其他页面'];
|
if(path==='/')return ['home','首页'];const rule=routes.find(([prefix])=>path==='/'+prefix||path.startsWith('/'+prefix+'/'));return rule?[rule[1],rule[2]]:['internal_other','站内其他页面'];
|
||||||
|
|||||||
@@ -31,160 +31,6 @@ const excluded = new Set([
|
|||||||
"x-tracker-request-id",
|
"x-tracker-request-id",
|
||||||
"x-tracker-event-name",
|
"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 {
|
export function buildXUserAgent(ctx: RequestContext): string {
|
||||||
const supplied = ctx.headers.get("x-user-agent")?.trim();
|
const supplied = ctx.headers.get("x-user-agent")?.trim();
|
||||||
if (supplied) return supplied;
|
if (supplied) return supplied;
|
||||||
@@ -289,8 +135,6 @@ export function buildUpstreamHeaders(
|
|||||||
result.delete("Cookie");
|
result.delete("Cookie");
|
||||||
}
|
}
|
||||||
if (options.forwardCookies === false) 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;
|
return result;
|
||||||
}
|
}
|
||||||
export async function upstreamFetch(
|
export async function upstreamFetch(
|
||||||
|
|||||||
+2
-2
@@ -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>();
|
process.env.UPSTREAM_API_URL='http://brand-contract.test';const calls=new Map<string,number>();
|
||||||
globalThis.fetch=async(input)=>{
|
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};
|
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/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:'黄果恋人'}]};
|
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});
|
return Response.json({code:200,data});
|
||||||
};
|
};
|
||||||
try{
|
try{
|
||||||
const page=await buildPage(context('/'));const config=page.data.appConfig;
|
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 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 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,'花魁短剧网页版可以看全集吗?');
|
const help=await buildPage(context('/help/'));assert.equal(help.data.faqItems[0].Question,'花魁短剧网页版可以看全集吗?');
|
||||||
|
|||||||
@@ -112,9 +112,8 @@ test("React list advertising keeps independent desktop and mobile row positions
|
|||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
const promo = renderToStaticMarkup(<PromoGrid items={ads} />);
|
const promo = renderToStaticMarkup(<PromoGrid items={ads} />);
|
||||||
assert.equal((promo.match(/data-track-ad="external"/g) || []).length, 2);
|
assert.equal((promo.match(/rel="sponsored nofollow noopener"/g) || []).length, 2);
|
||||||
assert.equal((promo.match(/data-ad-clone="1"/g) || []).length, 1);
|
assert.equal((promo.match(/tabindex="-1"/g) || []).length, 1);
|
||||||
assert.match(promo, /rel="sponsored nofollow noopener"/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("React filter and pagination links preserve the source path-only canonical route contracts", () => {
|
test("React filter and pagination links preserve the source path-only canonical route contracts", () => {
|
||||||
|
|||||||
+2
-2
@@ -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);
|
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}[]=[];
|
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)=>{
|
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};
|
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'});
|
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,'爱情短剧');
|
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 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');
|
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);
|
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);
|
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);
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import {
|
|||||||
shouldDecryptImage,
|
shouldDecryptImage,
|
||||||
} from "../src/lib/media/images";
|
} from "../src/lib/media/images";
|
||||||
import { isHLSURL, nativeHLS, resolvePlayURL } from "../src/lib/media/playback";
|
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", () => {
|
test("legacy image XOR is immutable, symmetric and changes only the first 100 bytes", () => {
|
||||||
const plain = Uint8Array.from({ length: 150 }, (_, index) => index),
|
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));
|
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
@@ -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 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) });
|
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;
|
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');
|
await upstreamGet(ctx, '/api/app/config/info');
|
||||||
const headers = received[0].headers;
|
const headers = received[0].headers;
|
||||||
assert.equal(headers.authorization, 'cookie-token'); assert.equal(headers.cookie, 'token=cookie-token; extra=keep%20me');
|
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 () => {
|
test('public data requests isolate both stale Authorization and auth cookies', async () => {
|
||||||
scenario = ''; received.length = 0;
|
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);
|
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);
|
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 () => {
|
test('Set-Cookie values remain separate and updated same-name cookies are written once', async () => {
|
||||||
scenario = 'cookies'; received.length = 0;
|
scenario = 'cookies'; received.length = 0;
|
||||||
const ctx = createContext(request('/api/auth/login'));
|
const ctx = createContext(request('/api/auth/login'));
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ test("React tags and list pages preserve the original Go catalog content and nav
|
|||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(actual.match(/data-track-ad="external"/g) || []).length,
|
(actual.match(/rel="sponsored nofollow noopener"/g) || []).length,
|
||||||
(expected.match(/data-track-ad="external"/g) || []).length,
|
(expected.match(/rel="sponsored nofollow noopener"/g) || []).length,
|
||||||
name + " ad placement",
|
name + " ad placement",
|
||||||
);
|
);
|
||||||
assert.match(actual, /花魁 & 第一部/);
|
assert.match(actual, /花魁 & 第一部/);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { canonicalRedirect } from "../src/server/redirects";
|
|||||||
import { matchPageRoute } from "../src/server/pages";
|
import { matchPageRoute } from "../src/server/pages";
|
||||||
import { pageRoutes, publicPageRoutes } from "../src/server/pages/routes";
|
import { pageRoutes, publicPageRoutes } from "../src/server/pages/routes";
|
||||||
import { preparePage } from "../src/server/react/page-data";
|
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", () => {
|
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";
|
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][] = [
|
const paths: [string, string, string][] = [
|
||||||
["/list/romance/", "/dramas/romance/", "list"],
|
["/list/romance/", "/dramas/romance/", "list"],
|
||||||
["/play/a%2Fb/123/", "/drama/a%2Fb/123/", "play"],
|
["/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"],
|
["/home-road/", "/return-home/", "home_road"],
|
||||||
];
|
];
|
||||||
for (const [legacy, current, key] of paths) {
|
for (const [legacy, current, key] of paths) {
|
||||||
assert.deepEqual(trackerPage(current), trackerPage(legacy), current);
|
assert.deepEqual(pageIdentity(current), pageIdentity(legacy), current);
|
||||||
assert.equal(trackerPage(current)[0], key, current);
|
assert.equal(pageIdentity(current)[0], key, current);
|
||||||
}
|
}
|
||||||
assert.equal(trackerPage("/unknown/")[0], "internal_other");
|
assert.equal(pageIdentity("/unknown/")[0], "internal_other");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, "");
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user