样式修改
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` 页面适配器。
|
||||
- 保持原站功能、PC/H5 界面和完整 SEO 行为:metadata、JSON-LD、canonical、分页、robots、llms、sitemap、站点验证、IndexNow,以及重定向/404/503 的状态和索引规则。
|
||||
- 保留服务端 API 转发、请求头和 Cookie 透传、游客会话恢复、多条 Set-Cookie 和私有响应禁止缓存。不要把鉴权或 SDK 私密配置泄露到公开数据、日志或图片 CDN。
|
||||
- 页面按 `src/features/{catalog,account,content,playback}` 组织;页头/页尾、卡片、图片解密、播放器、鉴权、支付及埋点优先复用 `src/components`、`src/lib`、`src/server` 中的公共实现。
|
||||
- 页面按 `src/features/{catalog,account,content,playback}` 组织;页头/页尾、卡片、图片解密、播放器、鉴权及支付优先复用 `src/components`、`src/lib`、`src/server` 中的公共实现。
|
||||
- 业务 CSS 统一在 `src/styles/{foundations,components,pages,overrides}`,通过 TSX 顶层导入并由 Next 编译为哈希资源;不要恢复 `/static/css` 或手工 stylesheet 链接。业务类名使用 `hk-` 与语义组件名称,xgplayer 第三方类名保留;页面样式使用零特异性 `html[data-hk-page-styles]` 隔离,PWA 最后加载。客户端切页由 `PagePresentation` 同步作用域与页面 body class,不可覆盖滚动锁/PWA/临时状态。
|
||||
- 2026-10-14 用户明确重新启用语义新路径并同步全部引用,覆盖此前回退要求。代码入口和对外 URL 使用 `/dramas`、`/drama`、`/series/watch`、`/collections`、`/collection`、`/account`、`/navigation` 等;旧有效页面 GET/HEAD 一次 301 到新路径,规则统一在 `src/lib/routes.ts`。页面文件使用 `HomePage`、`BrowsePages`、`DiscoveryPages`、`DramaWatchPage`、`SeriesWatchPage`、`SettingsPage`、`SiteNavigationPage`,服务端播放模块为 `src/server/pages/playback.ts`。站内链接、metadata、分页、JSON-LD、llms、安装默认入口使用新路径,API/媒体签名/外链保持原合同与编码。`npm run routes:export -- --admin ../hkdj-web-admin` 从实际路由生成全部新旧对照及后台路径清单,`routes:check` 核验无漂移;后台导航分类支持选择静态入口或自由输入,参数模板不作为可选链接。后端 `hkdj-web-server` 的 h5path、sitemap 与 SEO 推送源同步新路径,Next 仍原字节服务 XML,不在请求中改写生成结果。
|
||||
- robots/llms/sitemap 完整遵循参考 Go 合同:robots/llms 读取公共配置并使用原永久域名/代理 Host 规则,robots 保留四个磁盘候选与内嵌文本的优先级及原字符串处理。sitemap 只服务后端生成的文件;开发 `SITEMAP_DIR=./template/pages/sitemaps`,测试/生产 `SITEMAP_DIR=../template/pages/sitemaps`,仅作为部署目录映射。测试 Node 工作目录 `/server/hkdj_net/h5/next`,真实产物在父级 `h5/template/pages/sitemaps`,生产遵循相同目录关系;索引其次读取 `./template/pages/sitemap.xml`。请求不读取远端 sitemap、不生成静态回退或合成索引、不改写 XML;缺失/非法文件走原完整 404 页面及共享数据加载。开发仅可显式 `npm run sitemap:sync` 通过可信 SSH 只读同步索引引用的完整分片,保留原字节/mtime,不修改远端、不删除未知文件;开发 XML 忽略提交,页面 trace 排除;Next 16.4 Node Proxy trace 仍可能带入原始 standalone,必须通过 bundle 脚本在隔离 dist 精确清理 XML,并逐层 lstat 拒绝符号链接父目录,保留 robots/key/未知文件和后端源目录。导航模式与 Go `DHInit` 一致,不注册抓取文件/验证/API/媒体路由。发布保留后端 XML,持续写入的原目录应外置挂载,不能随前端包清空。
|
||||
- 成功抓取文件通过原生 Route Handler 返回;Proxy 仅选择成功出口或准备并改写完整 TSX 404,不能直接从 Proxy 输出文件,否则 Next 会过滤 Content-Length。保留 Go ServeFile 的 MIME、原字节、Last-Modified、Content-Length、Accept-Ranges、304/412/206/416、If-Range/多段 Range 和 HEAD;Next compress=false 避免框架自动压缩改变文件响应,外层代理可沿用原传输配置。
|
||||
- 播放器通过 npm 使用 `xgplayer` 与 `xgplayer-hls`;不要恢复 CDN 脚本版 DPlayer/Hls/Swiper 或旧业务脚本。
|
||||
- `/h5/m3u8/**`、`/enkey`、`/newenkey` 保留服务端媒体转发:签名 query 不重组,UA/IP/Range 保留,上游重定向按原 Go 客户端跟随。签名媒体请求按原合同不附登录 Cookie/Authorization;上游业务 JSON 错误返回 502,不能当播放清单成功响应。
|
||||
- `config/development.env`、`config/test.env`、`config/production.env` 分别管理三套环境,本地覆盖使用忽略提交的 `*.local.env`;进程环境优先。开发 API 按用户指定保持 `http://172.104.181.99:30331`,不要擅自切回原站 25319。
|
||||
- `config/development.env`、`config/test.env`、`config/production.env` 分别管理三套环境,本地覆盖使用忽略提交的 `*.local.env`;进程环境优先。2026-10-16 起开发 API 按用户指定使用 `https://webhuakuiapi.mfcg.me`(替代原 `http://172.104.181.99:30331`),不要擅自切回原站 25319 或旧地址。
|
||||
- SSR 请求准备在 `src/proxy.ts` 完成,通过 `src/server/react/handoff.ts` 向 React 交接;保留同请求 Cookie/页面数据一致性。大快照使用私有临时文件,部署多实例时须处理交接存储共享或实例亲和。
|
||||
- 所有“黄果短剧”统一为“花魁短剧”。占位图品牌 logo 透明度固定为 `0.2`。Figma 文件为 `1vUq4wf5xOs8ZOlmt26Q3O`,读取设计使用本地 `FIGMA_TOKEN` 调用 REST API,不能打印令牌。
|
||||
- 本地 `npm run dev` 默认使用 Webpack;该项目已确认 Next 16.4 Turbopack 开发 HMR 存在循环刷新,未经验证不要改回默认。Charles 调试使用 `npm run dev:charles`,不改业务接口地址。
|
||||
- 验证至少覆盖 `npm run check` 和受影响的浏览器链路;图片必须检查真实接口返回的地址、解密后的自然尺寸、背景图及最终可见性(包含 computed opacity),模拟图片测试通过不能证明 CDN 可用。首页 Hero 封面成功/失败均需结束加载状态,保留淡入和失败占位;不能遗漏 CSS 依赖的就绪标记。
|
||||
- 图片地址由公共 ImageProvider/解密组件统一解析:优先上游 `imgCdn`、`imgAppUsCdn`,再用环境 `IMAGE_CDN_URL`。当前开发 API 缺 CDN,开发环境单独配置已核验的 CloudFront 来源;不要把 API 地址当作图片 CDN。保留原 XOR 协议并验证图片文件头,错误响应不能缓存为图片。
|
||||
- 图片地址由公共 ImageProvider/解密组件统一解析:优先上游 `imgCdn`、`imgAppUsCdn`,再用环境 `IMAGE_CDN_URL`。开发 API 已返回 imgCdn/imgAppUsCdn,测试 API 30331 缺 CDN,环境仍配置已核验的 CloudFront 作为备用;不要把 API 地址当作图片 CDN。保留原 XOR 协议并验证图片文件头,错误响应不能缓存为图片。
|
||||
- 提交遵循用户全局 AGENTS 规则:无前缀请求使用 `[Ai] <type>: <中文主题>`,正文具体说明文件/模块/资源;仅确有 Claude 共创时添加 Claude trailer。
|
||||
- PWA 安装配置使用 `src/app/manifest.ts` 与 `src/lib/pwa/config.ts`:fullscreen 优先、standalone 回退、orientation any;全局 viewport-fit cover 与 Apple 独立窗口标签不可只放播放器页。Next 16 的 appleWebApp.capable 仅生成非 Apple 前缀标签,需保留 Metadata.other 中的 apple-mobile-web-app-capable。
|
||||
- `/manifest.webmanifest` 与 `/sw.js` 必须绕过 SSR Proxy/游客初始化,包括 NAVIGATION_ONLY;Worker 源码为 TypeScript,开发/构建自动生成 public/sw.js,不拦截或缓存鉴权 API、HTML/RSC、签名媒体及密钥。PWA 安全区样式最后加载,普通浏览器不改变原布局;验证 `npm run test:pwa` 与实际 SSR 清单/标签/资源,真实设备安装需 HTTPS。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 花魁短剧 · Next.js / TypeScript
|
||||
|
||||
从 `jha-1045-hgdjnet-web` 迁移的独立 Next.js 16 项目。运行时仅需要 Node.js,不依赖 Go、原项目目录或原 Go 服务。保留内容浏览页面、原 URL、游客会话、播放器和统计交互(登录、账户与充值功能已移除,旧账户地址 301 到首页);PC/H5 界面按花魁短剧 Figma 设计更新。
|
||||
从 `jha-1045-hgdjnet-web` 迁移的独立 Next.js 16 项目。运行时仅需要 Node.js,不依赖 Go、原项目目录或原 Go 服务。保留内容浏览页面、原 URL、游客会话和播放器(登录、账户与充值功能已移除,旧账户地址 301 到首页);PC/H5 界面按花魁短剧 Figma 设计更新。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -68,20 +68,18 @@ Node 原生代理说明:[Node.js HTTP 文档](https://nodejs.org/download/rele
|
||||
|
||||
配置优先级:进程环境变量 > `config/<环境>.local.env` > `config/<环境>.env`。本地覆盖文件不会提交。`APP_ENV` 选择业务环境;测试环境的优化构建仍使用 Next.js 要求的 `NODE_ENV=production`。
|
||||
|
||||
开发 `UPSTREAM_API_URL` 使用 `http://172.104.181.99:30331`;测试服务与 API 同机,使用 `http://127.0.0.1:30331`,测试应用端口为 `30334`。本地运行测试环境且 API 不在本机时,可在 `config/test.local.env` 覆盖上游地址。正式配置使用同机 API `http://127.0.0.1:26931`、服务端口 `26935`,可由部署环境或 `config/production.local.env` 覆盖。构建不访问上游,启动时检查地址。
|
||||
开发 `UPSTREAM_API_URL` 使用 `https://webhuakuiapi.mfcg.me`;测试服务与 API 同机,使用 `http://127.0.0.1:30331`,测试应用端口为 `30334`。本地运行测试环境且 API 不在本机时,可在 `config/test.local.env` 覆盖上游地址。正式配置使用同机 API `http://127.0.0.1:26931`、服务端口 `26935`,可由部署环境或 `config/production.local.env` 覆盖。构建不访问上游,启动时检查地址。
|
||||
|
||||
```dotenv
|
||||
UPSTREAM_API_URL=https://your-api.example
|
||||
UPSTREAM_TIMEOUT_MS=20000
|
||||
IMAGE_CDN_URL=
|
||||
SITEMAP_DIR=../template/pages/sitemaps
|
||||
TRACKER_APP_ID=JHA-4001
|
||||
TRACKER_ENCRYPTED_CONFIG=
|
||||
```
|
||||
|
||||
这些变量仅供服务端使用。`config/info.reportSdkConfig` 优先于本地埋点密文配置。官方导航独立进程可以使用 `NAVIGATION_ONLY=true PORT=26813 npm start`,开放 `/`、`/navigation/` 和静态资源,原 `/nav/`、`/dh/` 一次 301 到 `/navigation/`。与原 Go `DHInit` 一致,导航模式不注册 robots、llms、sitemap、验证文件及业务接口,这些地址返回原站 404 页面。
|
||||
这些变量仅供服务端使用。官方导航独立进程可以使用 `NAVIGATION_ONLY=true PORT=26813 npm start`,开放 `/`、`/navigation/` 和静态资源,原 `/nav/`、`/dh/` 一次 301 到 `/navigation/`。与原 Go `DHInit` 一致,导航模式不注册 robots、llms、sitemap、验证文件及业务接口,这些地址返回原站 404 页面。
|
||||
|
||||
图片 CDN 与 API 独立配置。上游 `config/info.config.imgCdn`、`imgAppUsCdn` 按顺序提供图片来源,`IMAGE_CDN_URL` 提供环境级备用来源。开发与测试共用 API 30331,该接口返回裸 `v3/image/...` 且 CDN 配置为空,因此两套环境均显式配置原站已验证可用的 CloudFront 图片线路;生产独立配置。修改环境配置后需重启服务。
|
||||
图片 CDN 与 API 独立配置。上游 `config/info.config.imgCdn`、`imgAppUsCdn` 按顺序提供图片来源,`IMAGE_CDN_URL` 提供环境级备用来源。开发 API 返回 `imgCdn`/`imgAppUsCdn`;测试 API 30331 返回裸 `v3/image/...` 且 CDN 配置为空,因此两套环境均显式配置原站已验证可用的 CloudFront 图片线路作为备用;生产独立配置。修改环境配置后需重启服务。
|
||||
|
||||
公共图片组件统一解析相对路径,覆盖封面、横幅、背景和头像;相对路径不会随页面目录变化。图片保持原首 100 字节 XOR 协议,也兼容未加密的有效图片;校验文件头后才缓存,HTTP、证书、JSON 错误或超时会尝试配置的备用 CDN。完整外域 URL 保持原签名,只有属于已配置 CDN 的资源才允许切换相同对象路径;图片请求不向外域发送本站 Cookie/Authorization。
|
||||
|
||||
@@ -91,15 +89,15 @@ TRACKER_ENCRYPTED_CONFIG=
|
||||
| --- | --- |
|
||||
| `src/app/` | 原生 App Router TSX 页面、Metadata 与 BFF/媒体 Route Handlers |
|
||||
| `src/features/` | 短剧目录、播放、账号与内容页面组件 |
|
||||
| `src/components/` | 页头、页尾、卡片、播放器、图片、弹窗、认证、支付、埋点共用组件 |
|
||||
| `src/components/` | 页头、页尾、卡片、播放器、图片、弹窗、认证、支付共用组件 |
|
||||
| `src/styles/` | 基础、公共组件、页面和 PWA 覆盖样式,通过 TSX 导入并由 Next 编译 |
|
||||
| `src/lib/` | 类型化浏览器请求、Cookie、搜索/展示方法与埋点上下文 |
|
||||
| `src/lib/` | 类型化浏览器请求、Cookie、搜索/展示方法 |
|
||||
| `src/proxy.ts`、`src/server/react/` | SSR 数据、状态、响应 Cookie 和 React 数据交接 |
|
||||
| `src/server/pages/` | 原路由参数、分页、页面数据组合与播放/账号场景 |
|
||||
| `src/server/services/` | 上游 DTO、展示模型、公共配置和请求级复用 |
|
||||
| `src/server/upstream.ts`、`auth.ts` | 上游请求、请求头/Cookie 透传、登录与游客恢复 |
|
||||
| `src/server/seo/` | 原 SEO 策略、TDK、canonical、JSON-LD 和抓取文件 |
|
||||
| `public/static/` | 本地 Figma 品牌/图标、分享图与私有埋点 SDK |
|
||||
| `public/static/` | 本地 Figma 品牌/图标、分享图 |
|
||||
|
||||
所有业务页面由 TSX 组件服务端渲染,交互使用 React hooks 和 Client Components。App Router 的 `generateMetadata` 输出完整首屏 SEO;JSON-LD 使用安全序列化的独立脚本。没有 HTML 模板运行时或注入整页 HTML 的适配层。
|
||||
|
||||
@@ -206,7 +204,7 @@ SSR 在 Proxy 中完成上游数据、游客恢复和 Cookie 写回,再交给
|
||||
|
||||
公共配置可短期复用;登录用户内容仅在单次请求内复用,页面响应使用 `private, no-store`。必要内容接口临时不可用返回 503、`Retry-After` 和 noindex;真实缺失的实体及越界分页仍返回 404。
|
||||
|
||||
播放器通过 `package.json` 管理 `xgplayer`、`xgplayer-hls`,按需加载官方模块和样式,支持 MP4/HLS、加密密钥、预告切换、章节播放、全屏、解锁、评论及播放埋点。原 DPlayer、全局 Hls 和浏览器业务 JS 已删除。加密图片、二维码分别封装为 TSX 组件,二维码使用 npm `qrcode`;占位图 Logo 透明度保持 0.2。
|
||||
播放器通过 `package.json` 管理 `xgplayer`、`xgplayer-hls`,按需加载官方模块和样式,支持 MP4/HLS、加密密钥、预告切换、章节播放、全屏、解锁及评论。原 DPlayer、全局 Hls 和浏览器业务 JS 已删除。加密图片、二维码分别封装为 TSX 组件,二维码使用 npm `qrcode`;占位图 Logo 透明度保持 0.2。
|
||||
|
||||
## SEO 保留范围
|
||||
|
||||
@@ -217,7 +215,7 @@ SSR 在 Proxy 中完成上游数据、游客恢复和 Cookie 写回,再交给
|
||||
- Organization、WebSite、WebPage、Breadcrumb、ItemList、FAQ、TVSeries/TVEpisode、VideoObject 等原 JSON-LD 条件和真实元数据。
|
||||
- robots.txt、llms.txt、sitemap 索引/分片、纯渠道首页 302、下载地址 302。
|
||||
|
||||
上游 TDK、导航、公告和 JSON-LD 中的完整旧品牌词「黄果短剧」统一显示为「花魁短剧」。永久域名、canonical、真实联系地址、普通影片名称、验证值和 SDK 配置保留原值。
|
||||
上游 TDK、导航、公告和 JSON-LD 中的完整旧品牌词「黄果短剧」统一显示为「花魁短剧」。永久域名、canonical、真实联系地址、普通影片名称、验证值保留原值。
|
||||
|
||||
抓取文件遵循原 Go 路由。普通站点的 robots/llms 读取公共配置,保留 60 秒成功缓存;刷新失败沿用最近成功配置,首次失败使用空配置。地址使用原永久域名优先、代理协议/Host 回退规则。robots 按顺序读取 `template/pages/robots.txt`、`templates/pages/robots.txt`、`app/template/pages/robots.txt`、`app/templates/pages/robots.txt`,没有可用文件时使用原内嵌文本,再回退原内置默认文本。保留原爬虫规则、行处理、已有 Host 和大小写语义,按原算法写入 Sitemap 地址。
|
||||
|
||||
@@ -263,11 +261,13 @@ APP_ANDROID_APK_URL=
|
||||
APP_DEEP_LINK_URL=
|
||||
```
|
||||
|
||||
平台地址未配置时,按钮显示“前往下载页”,沿用上游 `AppAddr` 或 `/app/download/` 跳转;未配置 App 深链时隐藏打开 App 入口。链接保留原始跳转地址与导航埋点,不生成设计示例中的安装包、描述文件或 App Store 地址。
|
||||
平台地址未配置时,按钮显示“前往下载页”,沿用上游 `AppAddr` 或 `/app/download/` 跳转;未配置 App 深链时隐藏打开 App 入口。链接保留原始跳转地址,不生成设计示例中的安装包、描述文件或 App Store 地址。
|
||||
|
||||
所有 Telegram 联系入口(页尾、官方导航、回家的路、播放页链接、2257/DMCA、意见反馈,以及 Telegram 形式的「联系我们」客服链接)统一使用 `CONTACT_TELEGRAM_URL`,三套环境默认 `https://t.me/jbd125`,覆盖上游 `tgGroup`、`telegram`、`telegramAdd`。值无效时回退为默认账号。
|
||||
|
||||
## 接口和媒体
|
||||
|
||||
保留原 BFF 路由、别名、参数校验、业务响应壳、上传和 CSV 导出。`Cookie`、cookie token 优先的 `Authorization`、客户端 IP、浏览器 UA、`X-User-Agent`、`X-SDK-Common` 透传;过滤 hop-by-hop 和内部控制头。多个 `Set-Cookie` 独立回传。埋点上下文按原白名单校验并保留 SDK 原字段。
|
||||
保留原 BFF 路由、别名、参数校验、业务响应壳、上传和 CSV 导出。`Cookie`、cookie token 优先的 `Authorization`、客户端 IP、浏览器 UA、`X-User-Agent` 透传;过滤 hop-by-hop 和内部控制头。多个 `Set-Cookie` 独立回传。
|
||||
|
||||
公共配置隔离过期登录凭证;个人资料刷新与游客恢复写回当前请求,避免首屏重复建立会话。正式账号失效和内容允许降级游客的规则分别保留。
|
||||
|
||||
@@ -289,7 +289,7 @@ SMOKE_URL=http://localhost:25131 npm run test:browser
|
||||
SMOKE_URL=http://localhost:25131 npm run test:ui
|
||||
```
|
||||
|
||||
浏览器检查默认使用 macOS Chrome,可通过 `CHROME_PATH` 指定。`test:catalog`、`test:popups`、`test:playback` 使用隔离上游与真实 React 组件,验证目录交互、弹层焦点/滚动、官方 xgplayer、加密 HLS、评论与埋点,不改动真实账号。`test:ssr` 使用生产构建核验 SEO、HTTP 状态、Cookie、透传和并发隔离,不访问真实业务接口。
|
||||
浏览器检查默认使用 macOS Chrome,可通过 `CHROME_PATH` 指定。`test:catalog`、`test:popups`、`test:playback` 使用隔离上游与真实 React 组件,验证目录交互、弹层焦点/滚动、官方 xgplayer、加密 HLS 与评论,不改动真实账号。`test:ssr` 使用生产构建核验 SEO、HTTP 状态、Cookie、透传和并发隔离,不访问真实业务接口。
|
||||
|
||||
全站界面截图默认输出 `/tmp/hkdj-figma/implementation`,浏览器基础检查输出 `/tmp/hkdj-browser-smoke`。完整真实内容回归需要原上游剧集、专题与标签数据;空环境可设置 `UI_ALLOW_EMPTY_CONTENT=1`,缺失实体仍必须返回 404 与 noindex。
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
APP_ENV=development
|
||||
UPSTREAM_API_URL=http://172.104.181.99:30331
|
||||
# UPSTREAM_API_URL=http://172.105.118.202:26931
|
||||
# The development API returns relative image keys and has no imgCdn configured.
|
||||
# Verified image source from the original site's imgAppUsCdn; API stays on 30331.
|
||||
UPSTREAM_API_URL=https://webhuakuiapi.mfcg.me
|
||||
# UPSTREAM_API_URL=http://172.104.181.99:30331
|
||||
# The API supplies imgCdn/imgAppUsCdn; this CloudFront source is only the fallback.
|
||||
# Primary public domain; overrides the backend foreverUrlAddr.
|
||||
SITE_URL=https://huakuidj.com
|
||||
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
||||
UPSTREAM_TIMEOUT_MS=20000
|
||||
TRACKER_APP_ID=JHA-4001
|
||||
TRACKER_ENCRYPTED_CONFIG=
|
||||
PORT=25121
|
||||
# Backend-generated sitemap files; relative to the Node working directory.
|
||||
SITEMAP_DIR=./template/pages/sitemaps
|
||||
@@ -20,6 +17,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
|
||||
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
||||
SEO_BAIDU_SITE_VERIFICATION=
|
||||
SEO_BAIDU_HTML_FILE=
|
||||
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
|
||||
CONTACT_TELEGRAM_URL=https://t.me/jbd125
|
||||
APP_IOS_STORE_URL=
|
||||
APP_IOS_PROFILE_URL=
|
||||
APP_ANDROID_APK_URL=
|
||||
|
||||
@@ -6,8 +6,6 @@ UPSTREAM_API_URL=http://127.0.0.1:26931
|
||||
SITE_URL=https://huakuidj.com
|
||||
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
||||
UPSTREAM_TIMEOUT_MS=20000
|
||||
TRACKER_APP_ID=JHA-4001
|
||||
TRACKER_ENCRYPTED_CONFIG=
|
||||
PORT=26935
|
||||
# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps.
|
||||
SITEMAP_DIR=../template/pages/sitemaps
|
||||
@@ -15,6 +13,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
|
||||
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
||||
SEO_BAIDU_SITE_VERIFICATION=
|
||||
SEO_BAIDU_HTML_FILE=
|
||||
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
|
||||
CONTACT_TELEGRAM_URL=https://t.me/jbd125
|
||||
APP_IOS_STORE_URL=
|
||||
APP_IOS_PROFILE_URL=
|
||||
APP_ANDROID_APK_URL=
|
||||
|
||||
+2
-2
@@ -6,8 +6,6 @@ UPSTREAM_API_URL=http://127.0.0.1:30331
|
||||
SITE_URL=https://huakuidj.com
|
||||
IMAGE_CDN_URL=https://d3n8qx4rvy4vol.cloudfront.net/
|
||||
UPSTREAM_TIMEOUT_MS=20000
|
||||
TRACKER_APP_ID=JHA-4001
|
||||
TRACKER_ENCRYPTED_CONFIG=
|
||||
PORT=30334
|
||||
# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps.
|
||||
SITEMAP_DIR=../template/pages/sitemaps
|
||||
@@ -15,6 +13,8 @@ M3U8_KEY_FILE=./config/secrets/m3u8.key
|
||||
SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee
|
||||
SEO_BAIDU_SITE_VERIFICATION=
|
||||
SEO_BAIDU_HTML_FILE=
|
||||
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
|
||||
CONTACT_TELEGRAM_URL=https://t.me/jbd125
|
||||
APP_IOS_STORE_URL=
|
||||
APP_IOS_PROFILE_URL=
|
||||
APP_ANDROID_APK_URL=
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@ try {
|
||||
await context.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
const local = new URL(request.url()).origin === origin;
|
||||
// Exercise UI without recording synthetic analytics, purchases or user actions.
|
||||
// Exercise UI without recording synthetic purchases or user actions.
|
||||
if (
|
||||
!local &&
|
||||
(request.method() !== "GET" || request.resourceType() === "script")
|
||||
@@ -55,12 +55,6 @@ try {
|
||||
timeout: 40000,
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
if (path !== "/discover/search/")
|
||||
await page.waitForFunction(
|
||||
() => typeof (window as unknown as Record<string, unknown>).tracker === "object",
|
||||
null,
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
for (let index = 0; index < 12; index++) {
|
||||
const close = page.locator(
|
||||
"[data-dialog-ad].hk-is-open [data-dialog-ad-item]:not([hidden]) [data-dialog-ad-close]",
|
||||
@@ -75,9 +69,6 @@ try {
|
||||
legacyScripts: Array.from(document.scripts).some((script) =>
|
||||
/\/static\/js\//.test(script.src),
|
||||
),
|
||||
tracker:
|
||||
typeof (window as unknown as Record<string, unknown>).tracker ===
|
||||
"object",
|
||||
}));
|
||||
results.push({
|
||||
device: name,
|
||||
@@ -91,11 +82,8 @@ try {
|
||||
throw new Error(
|
||||
`${name} ${path} overflows horizontally: ${layout.document}`,
|
||||
);
|
||||
if (
|
||||
layout.legacyScripts ||
|
||||
(path !== "/discover/search/" && !layout.tracker)
|
||||
)
|
||||
throw new Error(`${name} ${path} missing shared client runtime`);
|
||||
if (layout.legacyScripts)
|
||||
throw new Error(`${name} ${path} loaded legacy business scripts`);
|
||||
if (path === "/") {
|
||||
const accountEntries = await page.locator(
|
||||
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
|
||||
|
||||
@@ -146,10 +146,8 @@ try {
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await context.addInitScript({
|
||||
content: `
|
||||
window.__copies=[];window.__events=[];window.__navigation='';
|
||||
window.__copies=[];window.__navigation='';
|
||||
Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async function(value){window.__copies.push(value)}}});
|
||||
window.tracker={getSDKCommonHeader:function(){return 'mock-common'},getCommonFields:function(){return {device_id:'mock-device',device:'OTHER',sid:'mock-sid'}}};
|
||||
window.WebSDK={track:new Proxy({},{get:function(_,method){return function(payload){window.__events.push({method:String(method),payload:payload})}}})};
|
||||
if(!localStorage.getItem('hgdj_search_history'))localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { createStyleFixture } from "./style-fixtures";
|
||||
|
||||
// Bundle the actual React provider, dialogs, header/footer and encrypted media
|
||||
// hooks. The ephemeral server and browser request policy isolate all writes,
|
||||
// destinations and analytics; no original business JS or template runtime runs.
|
||||
// and destinations; no original business JS or template runtime runs.
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const temporary = await mkdtemp(join(tmpdir(), "huakui-native-site-")),
|
||||
bundle = join(temporary, "site.js");
|
||||
@@ -23,7 +23,6 @@ await build({
|
||||
contents: `
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {SiteProvider,useSite} from '@/components/providers/SiteProvider';
|
||||
import {TrackerProvider} from '@/components/analytics/TrackerProvider';
|
||||
import {SiteHeader} from '@/components/layout/SiteHeader';
|
||||
import {SiteFooter} from '@/components/layout/SiteFooter';
|
||||
import {SiteShell} from '@/components/layout/SiteShell';
|
||||
@@ -32,7 +31,7 @@ import {AppDownloadLink} from '@/components/catalog/Interactions';
|
||||
import {setAuthSession} from '@/lib/api';
|
||||
window.__setAuthSession=setAuthSession;
|
||||
const app=createRoot(document.getElementById('root'));function UserProbe(){const site=useSite();return <div style={{position:'absolute',top:150,left:10}}><output id='display-avatar' data-value={site.user?.avatar||''} data-name={site.user?.nickName||''} data-balance={site.user?.recharge||0} data-registered={String(!!site.user?.isRegister)}/></div>}
|
||||
function render(data){app.render(data.requestPath==='/navigation/'?<SiteShell view='nav' data={data}><SiteNavigationPage data={data}/></SiteShell>:<SiteProvider data={data}><TrackerProvider data={data}><SiteHeader data={data}/><UserProbe/><main style={{minHeight:900,paddingTop:100}}><h1>真实 React 共享组件隔离检查</h1><AppDownloadLink className="hk-app-cta__download" href={data.installGuide.fallbackURL}>点击下载</AppDownloadLink><button id="outside">页面按钮</button></main><SiteFooter data={data}/></TrackerProvider></SiteProvider>);}window.__rerender=render;render(window.__fixture);
|
||||
function render(data){app.render(data.requestPath==='/navigation/'?<SiteShell view='nav' data={data}><SiteNavigationPage data={data}/></SiteShell>:<SiteProvider data={data}><SiteHeader data={data}/><UserProbe/><main style={{minHeight:900,paddingTop:100}}><h1>真实 React 共享组件隔离检查</h1><AppDownloadLink className="hk-app-cta__download" href={data.installGuide.fallbackURL}>点击下载</AppDownloadLink><button id="outside">页面按钮</button></main><SiteFooter data={data}/></SiteProvider>);}window.__rerender=render;render(window.__fixture);
|
||||
`,
|
||||
},
|
||||
bundle: true,
|
||||
@@ -72,13 +71,6 @@ const server = createServer(async (request, response) => {
|
||||
response.end(await readFile(bundle));
|
||||
return;
|
||||
}
|
||||
if (path === "/static/common/web-sdk-v1.2.0.js") {
|
||||
response.setHeader("Content-Type", "application/javascript");
|
||||
response.end(
|
||||
"/* SDK mocked by the browser fixture before React mounts. */",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/static/")) {
|
||||
const file = resolve(root, "public", "." + path);
|
||||
if (!file.startsWith(join(root, "public") + "/")) {
|
||||
@@ -151,7 +143,6 @@ const server = createServer(async (request, response) => {
|
||||
requestPath: path,
|
||||
pageKey: "home",
|
||||
pageName: "首页",
|
||||
trackerAppId: "isolated",
|
||||
isLoggedIn: user,
|
||||
isRegistered: false,
|
||||
userName: "隔离游客",
|
||||
@@ -203,9 +194,7 @@ const image = Buffer.from(
|
||||
key = Buffer.from("2019ysapp7527");
|
||||
for (let i = 0; i < Math.min(100, image.length); i++)
|
||||
image[i] ^= key[i % key.length];
|
||||
type EventRecord = { method: string; payload: Record<string, unknown> };
|
||||
const events: EventRecord[] = [],
|
||||
errors: string[] = [],
|
||||
const errors: string[] = [],
|
||||
missing: string[] = [],
|
||||
unexpected: string[] = [],
|
||||
destinations: string[] = [];
|
||||
@@ -214,14 +203,8 @@ let releaseImage: (() => void) | undefined,
|
||||
delayImage = false,
|
||||
imageRequested = 0;
|
||||
const configure = async (context: BrowserContext, ageAccepted = false) => {
|
||||
await context.exposeBinding(
|
||||
"__recordEvent",
|
||||
(_source, event: EventRecord) => {
|
||||
events.push(event);
|
||||
},
|
||||
);
|
||||
await context.addInitScript({
|
||||
content: `window.__ageClosed=0;document.addEventListener('eighteen:closed',function(){window.__ageClosed++});window.WebSDK={init:function(){return Promise.resolve()},getCommonFields:function(){return Promise.resolve({device_id:'isolated-device',device:'OTHER',sid:'isolated-sid'})},flush:function(){},track:new Proxy({},{get:function(_,method){return function(payload){return window.__recordEvent({method:String(method),payload:payload})}}})};try{${ageAccepted ? "localStorage.setItem('hg_age_ok','1');" : ""}if(!localStorage.getItem('__fixture_history_seeded')){localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));localStorage.setItem('__fixture_history_seeded','1');}}catch{}`,
|
||||
content: `window.__ageClosed=0;document.addEventListener('eighteen:closed',function(){window.__ageClosed++});try{${ageAccepted ? "localStorage.setItem('hg_age_ok','1');" : ""}if(!localStorage.getItem('__fixture_history_seeded')){localStorage.setItem('hgdj_search_history',JSON.stringify(['历史词']));localStorage.setItem('__fixture_history_seeded','1');}}catch{}`,
|
||||
});
|
||||
await context.route("**/*", async (route) => {
|
||||
const request = route.request(),
|
||||
@@ -302,7 +285,6 @@ const configure = async (context: BrowserContext, ageAccepted = false) => {
|
||||
};
|
||||
const ready = async (page: Page) => {
|
||||
await page.waitForSelector(".hk-site-header");
|
||||
await page.waitForFunction(() => !!window.__trackerCommonFields);
|
||||
};
|
||||
const footerQR = async (page: Page, value: string, previous = "") => {
|
||||
await page.waitForFunction(({ value, previous }) => {
|
||||
@@ -413,17 +395,8 @@ try {
|
||||
await stack.keyboard.press("Escape");
|
||||
await stack.waitForSelector("[data-dialog-ad]", { state: "detached" });
|
||||
await expectOverflow(stack, "");
|
||||
await stack.waitForTimeout(50);
|
||||
assert.equal(
|
||||
events.filter(
|
||||
(event) =>
|
||||
event.method === "adImpression" && event.payload.ad_id === "stack-1",
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
await stacked.close();
|
||||
imageRequested = 0;
|
||||
events.length = 0;
|
||||
// Age gate cannot be bypassed by Escape. Ads wait for actual XOR decode + image
|
||||
// load, resume their queue on another page, and stay dismissed in this session.
|
||||
const context = await browser.newContext({
|
||||
@@ -459,7 +432,6 @@ try {
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(imageRequested, 1);
|
||||
assert.equal(await page.locator("[data-dialog-ad]").isVisible(), false);
|
||||
assert.equal(events.filter((e) => e.method === "adImpression").length, 0);
|
||||
assert.equal(
|
||||
await page.evaluate(() => localStorage.getItem("hg_age_ok")),
|
||||
"1",
|
||||
@@ -515,16 +487,6 @@ try {
|
||||
await page.evaluate(() => sessionStorage.getItem("dialogAdDismissed")),
|
||||
"2|1|2",
|
||||
);
|
||||
assert.equal(
|
||||
events.filter((e) => e.method === "adImpression" && e.payload.ad_id === "1")
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
events.filter((e) => e.method === "adClick" && e.payload.ad_id === "1")
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
await page.goto(origin + "/?ads=1&bad=1");
|
||||
await ready(page);
|
||||
await page.locator("[data-dialog-ad].hk-is-open").waitFor();
|
||||
@@ -532,12 +494,6 @@ try {
|
||||
await page.locator("[data-dialog-ad-id]").getAttribute("data-dialog-ad-id"),
|
||||
"2",
|
||||
);
|
||||
assert.equal(
|
||||
events.some(
|
||||
(e) => e.method === "adImpression" && e.payload.ad_id === "bad",
|
||||
),
|
||||
false,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
// Actual HeaderSearch hooks, keyboard navigation and history; no account entry remains.
|
||||
await page.goto(origin + "/?user=1");
|
||||
@@ -1053,15 +1009,8 @@ try {
|
||||
assert.ok(destinations.includes("/mock/android.apk"));
|
||||
assert.ok(destinations.includes("/mock/fallback"));
|
||||
assert.ok(destinations.includes("/mock/launch"));
|
||||
assert.ok(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.method === "navigation" &&
|
||||
event.payload.navigation_key === "header_download",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
"Native React site smoke passed: age gate/focus lock, encrypted advertisement readiness/failure/queue/cross-page session, footer QR pixels/size/URL/official fallback/empty configuration/restoration, HeaderSearch/history/keyboard/encoded URLs, configured desktop hover/focus/toggle/Escape/outside-click and mobile expandable navigation/canonical/external/download links, no login/user-menu/account entries, PC download and configured/fallback iOS/Android tabs/actions/deep links, drawer/footer/main entries, scroll/focus restoration and analytics. All APIs and outbound destinations isolated.",
|
||||
"Native React site smoke passed: age gate/focus lock, encrypted advertisement readiness/failure/queue/cross-page session, footer QR pixels/size/URL/official fallback/empty configuration/restoration, HeaderSearch/history/keyboard/encoded URLs, configured desktop hover/focus/toggle/Escape/outside-click and mobile expandable navigation/canonical/external/download links, no login/user-menu/account entries, PC download and configured/fallback iOS/Android tabs/actions/deep links, drawer/footer/main entries, scroll/focus restoration. All APIs and outbound destinations isolated.",
|
||||
);
|
||||
} finally {
|
||||
releaseImage?.();
|
||||
|
||||
@@ -39,13 +39,6 @@ async function expectPageStyles(page: Page, required: string[], absent: string[]
|
||||
const canonical = "https://canonical.drama.test";
|
||||
const userAgent =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) NativeSSRSmoke/1.0";
|
||||
const sdkCommon = JSON.stringify({
|
||||
sid: "ssr-sdk-session",
|
||||
device_id: "ssr-sdk-device",
|
||||
device: "iOS",
|
||||
device_fingerprint: "ssr-fingerprint",
|
||||
fp_version: "2",
|
||||
});
|
||||
const indexNow = "native_ssr_verification_20261010";
|
||||
const verifyFilename = `native-ssr-${randomBytes(6).toString("hex")}.html`;
|
||||
const verifyPath = resolve("public", verifyFilename);
|
||||
@@ -189,7 +182,6 @@ const mock = createServer(async (req, res) => {
|
||||
seoNavInfo: {},
|
||||
navPageList: [],
|
||||
advitise: {},
|
||||
reportSdkConfig: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -381,7 +373,6 @@ function headers(caseName: string, token = ""): Headers {
|
||||
"user-agent": userAgent,
|
||||
"x-forwarded-for": "203.0.113.42, 198.51.100.7",
|
||||
"x-user-agent": "DevType=SSR smoke;SysType=h5_ios;sid=original",
|
||||
"x-sdk-common": sdkCommon,
|
||||
...(token
|
||||
? {
|
||||
cookie: `token=${token}; nickName=STALE_COOKIE; userId=1; isRegister=${token.startsWith("guest-") ? "0" : "1"}; channel=smoke-channel; client_cookie=must_forward`,
|
||||
@@ -540,7 +531,6 @@ try {
|
||||
call.headers["x-user-agent"],
|
||||
"DevType=SSR smoke;SysType=h5_ios;sid=original",
|
||||
);
|
||||
assert.equal(call.headers["x-sdk-common"], sdkCommon);
|
||||
assert.equal(
|
||||
Object.keys(call.headers).some((key) => key.startsWith("x-hk-")),
|
||||
false,
|
||||
|
||||
@@ -86,7 +86,7 @@ const encrypted = decryptImageBytes(
|
||||
const entry = join(temp, "fixture.tsx");
|
||||
await writeFile(
|
||||
entry,
|
||||
`import {useState} from 'react';import {createRoot} from 'react-dom/client';import {SiteProvider} from '${cwd}/src/components/providers/SiteProvider';import {TrackerProvider} from '${cwd}/src/components/analytics/TrackerProvider';import {DramaWatchPage} from '${cwd}/src/features/playback/DramaWatchPage';import {SeriesWatchPage} from '${cwd}/src/features/playback/SeriesWatchPage';import {EncryptedImage} from '${cwd}/src/components/media/EncryptedImage';function Fixture(){const [current,setCurrent]=useState(window.fixtureData);window.changePlaybackFixture=setCurrent;return <TrackerProvider boot={{appId:'JHA-4001',encryptedConfig:'mock',uid:'mock_1001',pageKey:'play',pageName:'短剧播放页'}}><SiteProvider data={current} dialogs={false}>{current.series?<SeriesWatchPage key={current.fixtureKey} data={current}/>:<DramaWatchPage key={current.fixtureKey} data={current}/>}<EncryptedImage id="encrypted-image-test" src="/encrypted.png" eager style={{width:32,height:32}} onResolved={()=>window.imageResolved=(window.imageResolved||0)+1} onResolveError={()=>window.imageErrors=(window.imageErrors||0)+1}/><EncryptedImage id="failed-image-test" src="/missing-image" eager style={{width:32,height:32}} onResolved={()=>window.failedImageResolved=true} onResolveError={()=>window.failedImageError=true}/></SiteProvider></TrackerProvider>};createRoot(document.getElementById('root')).render(<Fixture/>);`,
|
||||
`import {useState} from 'react';import {createRoot} from 'react-dom/client';import {SiteProvider} from '${cwd}/src/components/providers/SiteProvider';import {DramaWatchPage} from '${cwd}/src/features/playback/DramaWatchPage';import {SeriesWatchPage} from '${cwd}/src/features/playback/SeriesWatchPage';import {EncryptedImage} from '${cwd}/src/components/media/EncryptedImage';function Fixture(){const [current,setCurrent]=useState(window.fixtureData);window.changePlaybackFixture=setCurrent;return <SiteProvider data={current} dialogs={false}>{current.series?<SeriesWatchPage key={current.fixtureKey} data={current}/>:<DramaWatchPage key={current.fixtureKey} data={current}/>}<EncryptedImage id="encrypted-image-test" src="/encrypted.png" eager style={{width:32,height:32}} onResolved={()=>window.imageResolved=(window.imageResolved||0)+1} onResolveError={()=>window.imageErrors=(window.imageErrors||0)+1}/><EncryptedImage id="failed-image-test" src="/missing-image" eager style={{width:32,height:32}} onResolved={()=>window.failedImageResolved=true} onResolveError={()=>window.failedImageError=true}/></SiteProvider>};createRoot(document.getElementById('root')).render(<Fixture/>);`,
|
||||
);
|
||||
const navStub = join(temp, "navigation.ts");
|
||||
await writeFile(
|
||||
@@ -159,18 +159,6 @@ const baseData = () => ({
|
||||
{ id: 102, currentEpisode: 2, payType: 1, needUnlock: true },
|
||||
],
|
||||
},
|
||||
trackerDrama: {
|
||||
DramaID: 10,
|
||||
DramaTitle: "播放测试",
|
||||
VideoID: 101,
|
||||
Episode: 1,
|
||||
TotalEpisode: 2,
|
||||
DramaDuration: 12,
|
||||
DramaTypeID: "2",
|
||||
DramaTypeName: "都市",
|
||||
DramaTagKey: "3",
|
||||
DramaTagName: "剧情",
|
||||
},
|
||||
hotList: [],
|
||||
guessLike: [
|
||||
{ id: 30, slug: "guess", playName: "猜你喜欢测试", cover: "/static/brand/placeholder.svg", totalEpisode: 3, updateStatus: 2 },
|
||||
@@ -194,13 +182,6 @@ const server = createServer(async (req, res) => {
|
||||
res.end(playbackStyles.css);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/static/common/web-sdk-v1.2.0.js") {
|
||||
res.setHeader("content-type", "application/javascript");
|
||||
res.end(
|
||||
'window.sdkEvents=[];window.WebSDK={init:options=>{window.sdkInit=options},track:new Proxy({},{get:(_,method)=>payload=>{window.sdkEvents.push({method,payload});return Promise.resolve({ok:true})}}),getCommonFields:()=>Promise.resolve({sid:"mock-session",device_id:"mock-device",device:"PC",device_fingerprint:"mock-fp",fp_version:"1"}),flush:()=>Promise.resolve(),setUid:()=>{}};',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
res.setHeader("content-type", "application/json");
|
||||
let data: unknown = {};
|
||||
@@ -378,35 +359,6 @@ try {
|
||||
return video.play();
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector("video")!.ended);
|
||||
const events = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
sdkEvents: { method: string; payload: Record<string, unknown> }[];
|
||||
}
|
||||
).sdkEvents,
|
||||
);
|
||||
for (const behavior of [
|
||||
"ep_view",
|
||||
"ep_play",
|
||||
"ep_pause",
|
||||
"ep_forward",
|
||||
"ep_complete",
|
||||
])
|
||||
assert(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.method === "dramaEvent" &&
|
||||
event.payload.play_behavior_key === behavior,
|
||||
),
|
||||
behavior,
|
||||
);
|
||||
assert(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.method === "dramaEvent" && event.payload.play_speed === 150,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator("#failed-image-test")
|
||||
@@ -440,30 +392,11 @@ try {
|
||||
),
|
||||
true,
|
||||
);
|
||||
const beforePreview = events.filter(
|
||||
(event) =>
|
||||
event.method === "dramaEvent" &&
|
||||
event.payload.play_behavior_key === "ep_play",
|
||||
).length;
|
||||
await page.getByRole("button", { name: "预告片", exact: true }).click();
|
||||
await page.waitForFunction(() => {
|
||||
const video = document.querySelector("video");
|
||||
return !!video && !video.paused && video.currentTime > 0.1;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const afterPreview = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as unknown as {
|
||||
sdkEvents: { method: string; payload: Record<string, unknown> }[];
|
||||
}
|
||||
).sdkEvents.filter(
|
||||
(event) =>
|
||||
event.method === "dramaEvent" &&
|
||||
event.payload.play_behavior_key === "ep_play",
|
||||
).length,
|
||||
);
|
||||
assert.equal(afterPreview, beforePreview);
|
||||
assert.equal(
|
||||
await page.locator(".hk-playback-player").getAttribute("data-play-mode"),
|
||||
"preview",
|
||||
@@ -493,18 +426,8 @@ try {
|
||||
const watchReq = requests.find(
|
||||
(req) => req.url === "/api/playlet/chapter/watch/report",
|
||||
);
|
||||
assert.equal(
|
||||
watchReq?.headers["x-sdk-common"],
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
sid: "mock-session",
|
||||
device_id: "mock-device",
|
||||
device: "PC",
|
||||
device_fingerprint: "mock-fp",
|
||||
fp_version: "1",
|
||||
}),
|
||||
).toString("base64"),
|
||||
);
|
||||
assert(watchReq, "watch report request");
|
||||
assert.equal(watchReq.headers["x-sdk-common"], undefined);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.screenshot({ path: join(output, device + "-player.png") });
|
||||
const paid = {
|
||||
@@ -667,7 +590,7 @@ try {
|
||||
fullscreen: true,
|
||||
errorRetry: true,
|
||||
realMp4: true,
|
||||
recommendationsAndTracking: true,
|
||||
recommendations: true,
|
||||
encryptedImageAndFallback: true,
|
||||
lockedAppDownloadOnly: true,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
type CatalogRecord,
|
||||
} from "@/features/catalog/model";
|
||||
function adProps(item: CatalogRecord, type: "banner" | "icon") {
|
||||
const location = str(item, "Location"),
|
||||
link = str(item, "Link");
|
||||
function adProps(item: CatalogRecord) {
|
||||
const link = str(item, "Link");
|
||||
return {
|
||||
href: safeHref(link),
|
||||
"data-track-ad": "external",
|
||||
"data-ad-clickable": link ? "1" : undefined,
|
||||
"data-ad-id": first(get(item, "AdvertiseCode"), get(item, "ID")),
|
||||
"data-creative-id": "",
|
||||
"data-ad-location": location,
|
||||
"data-ad-slot-key": first(get(item, "AdvertiseLocationCode"), location),
|
||||
"data-ad-slot-name": first(
|
||||
get(item, "AdvertiseLocationName"),
|
||||
location ? `广告位${location}` : "",
|
||||
),
|
||||
"data-ad-title": first(get(item, "Title"), "推荐"),
|
||||
"data-ad-type": type,
|
||||
...(link ? { target: "_blank", rel: "sponsored nofollow noopener" } : {}),
|
||||
};
|
||||
}
|
||||
@@ -37,7 +24,7 @@ export function AdBanners({ items }: { items: unknown }) {
|
||||
<a
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
className="hk-promotion-card"
|
||||
{...adProps(item, "banner")}
|
||||
{...adProps(item)}
|
||||
>
|
||||
<EncryptedImage
|
||||
loading="eager"
|
||||
@@ -73,9 +60,8 @@ export function PromoGrid({ items }: { items: unknown }) {
|
||||
<a
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
className="hk-promotion-grid__item"
|
||||
{...adProps(item, "icon")}
|
||||
{...adProps(item)}
|
||||
tabIndex={clone ? -1 : undefined}
|
||||
data-ad-clone={clone ? "1" : undefined}
|
||||
>
|
||||
<span className="hk-promotion-grid__thumb">
|
||||
<EncryptedImage
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { Data } from "@/server/services/model";
|
||||
import { SiteProvider } from "@/components/providers/SiteProvider";
|
||||
import { TrackerProvider } from "@/components/analytics/TrackerProvider";
|
||||
import { presentations } from "@/server/react/presentation";
|
||||
import { SiteHeader } from "./SiteHeader";
|
||||
import { SiteFooter } from "./SiteFooter";
|
||||
@@ -20,23 +19,9 @@ export function SiteShell({
|
||||
return (
|
||||
<SiteProvider data={data} dialogs={presentation.header}>
|
||||
<PagePresentation view={view} />
|
||||
<TrackerProvider data={data}>
|
||||
{presentation.header && <SiteHeader data={data} />} {children}
|
||||
{presentation.footer && <SiteFooter data={data} />}{" "}
|
||||
{presentation.header && <MobileTabbar data={data} />}
|
||||
</TrackerProvider>
|
||||
<noscript>
|
||||
<div>
|
||||
<img
|
||||
src={
|
||||
"https://mc.yandex.ru/watch/" +
|
||||
(data.isNavPage ? "112952789" : "112951899")
|
||||
}
|
||||
style={{ position: "absolute", left: -9999 }}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</noscript>
|
||||
{presentation.header && <SiteHeader data={data} />} {children}
|
||||
{presentation.footer && <SiteFooter data={data} />}{" "}
|
||||
{presentation.header && <MobileTabbar data={data} />}
|
||||
</SiteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
resolvePlayURL,
|
||||
SHELL_BROWSER,
|
||||
} from "@/lib/media/playback";
|
||||
import { bindPlaybackAnalytics } from "./usePlaybackAnalytics";
|
||||
import "xgplayer/dist/index.min.css";
|
||||
import "@/styles/components/video-player.css";
|
||||
export interface PlayerHandle {
|
||||
@@ -23,14 +22,13 @@ export interface PlayerHandle {
|
||||
}
|
||||
export interface VideoPlayerProps {
|
||||
url: string;
|
||||
trackDrama?: boolean;
|
||||
onPlaying?: () => void;
|
||||
onEnded?: () => void;
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
||||
function VideoPlayer(
|
||||
{ url, trackDrama = false, onPlaying, onEnded, autoPlay = false },
|
||||
{ url, onPlaying, onEnded, autoPlay = false },
|
||||
ref,
|
||||
) {
|
||||
const host = useRef<HTMLDivElement>(null),
|
||||
@@ -58,8 +56,7 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
||||
);
|
||||
useEffect(() => {
|
||||
let disposed = false,
|
||||
instance: Player | null = null,
|
||||
unbind = () => undefined as void;
|
||||
instance: Player | null = null;
|
||||
setError("");
|
||||
void Promise.all([import("xgplayer"), import("xgplayer-hls")])
|
||||
.then(([{ default: XGPlayer }, { default: HlsPlugin }]) => {
|
||||
@@ -112,7 +109,6 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
||||
void instance.play()?.catch(() => undefined);
|
||||
}
|
||||
const video = instance.video as HTMLVideoElement;
|
||||
unbind = bindPlaybackAnalytics(video, trackDrama);
|
||||
video.addEventListener("playing", () => {
|
||||
setError("");
|
||||
callbacks.current.onPlaying?.();
|
||||
@@ -125,11 +121,10 @@ export const VideoPlayer = forwardRef<PlayerHandle, VideoPlayerProps>(
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
unbind();
|
||||
instance?.destroy();
|
||||
if (player.current === instance) player.current = null;
|
||||
};
|
||||
}, [url, trackDrama, autoPlay, retry]);
|
||||
}, [url, autoPlay, retry]);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
dramaEvent,
|
||||
setDramaContext,
|
||||
tracker,
|
||||
type Fields,
|
||||
} from "@/components/analytics/tracker";
|
||||
export function useDramaContext(context: Fields | null) {
|
||||
const viewed = useRef("");
|
||||
useEffect(() => {
|
||||
setDramaContext(context);
|
||||
const key = context
|
||||
? String(context.drama_id) + ":" + String(context.video_id)
|
||||
: "";
|
||||
if (context && viewed.current !== key) {
|
||||
viewed.current = key;
|
||||
void dramaEvent("ep_view");
|
||||
}
|
||||
return () => setDramaContext(null);
|
||||
}, [context]);
|
||||
}
|
||||
const reporters = new WeakMap<HTMLVideoElement, (behavior: string) => void>();
|
||||
export function reportPlaybackAction(
|
||||
behavior: string,
|
||||
video?: HTMLVideoElement | null,
|
||||
) {
|
||||
const report = video && reporters.get(video);
|
||||
if (report) report(behavior);
|
||||
else void dramaEvent(behavior, video);
|
||||
}
|
||||
/** Count wall-clock playing time, excluding buffering and seeking, as the original SDK expects. */
|
||||
export function bindPlaybackAnalytics(
|
||||
video: HTMLVideoElement,
|
||||
enabled: boolean,
|
||||
): () => void {
|
||||
if (!enabled) return () => undefined;
|
||||
let started = 0,
|
||||
elapsed = 0,
|
||||
position = 0,
|
||||
seekFrom: number | null = null;
|
||||
const settle = () => {
|
||||
if (started) elapsed += Math.max(0, Date.now() - started);
|
||||
started = 0;
|
||||
};
|
||||
const begin = () => {
|
||||
if (!started) started = Date.now();
|
||||
};
|
||||
const emit = (behavior: string, keep = false, forceProgress?: number) => {
|
||||
const played =
|
||||
(elapsed + (started ? Math.max(0, Date.now() - started) : 0)) / 1000;
|
||||
void dramaEvent(behavior, video, { played, forceProgress });
|
||||
if (!keep) {
|
||||
elapsed = 0;
|
||||
if (started) started = Date.now();
|
||||
}
|
||||
};
|
||||
reporters.set(video, emit);
|
||||
const events: Record<string, () => void> = {
|
||||
play: () => emit("ep_play"),
|
||||
playing: begin,
|
||||
waiting: settle,
|
||||
pause: () => {
|
||||
const pending = started || elapsed > 0;
|
||||
settle();
|
||||
if (!video.ended && !video.seeking && pending) emit("ep_pause");
|
||||
},
|
||||
timeupdate: () => {
|
||||
if (!video.seeking) position = video.currentTime;
|
||||
},
|
||||
seeking: () => {
|
||||
if (seekFrom == null) {
|
||||
settle();
|
||||
seekFrom = position;
|
||||
}
|
||||
},
|
||||
seeked: () => {
|
||||
if (seekFrom == null) return;
|
||||
const from = seekFrom;
|
||||
seekFrom = null;
|
||||
position = video.currentTime;
|
||||
if (Math.abs(position - from) >= 0.5)
|
||||
emit(position > from ? "ep_forward" : "ep_rewind");
|
||||
if (!video.paused && !video.ended) begin();
|
||||
},
|
||||
ended: () => {
|
||||
settle();
|
||||
emit("ep_complete", true);
|
||||
emit("ep_play", false, 100);
|
||||
},
|
||||
};
|
||||
Object.entries(events).forEach(([name, handler]) =>
|
||||
video.addEventListener(name, handler),
|
||||
);
|
||||
const report = () => {
|
||||
if (elapsed + (started ? Date.now() - started : 0) >= 500) {
|
||||
settle();
|
||||
emit("ep_play");
|
||||
void tracker.flush();
|
||||
setTimeout(() => void tracker.flush(), 50);
|
||||
setTimeout(() => void tracker.flush(), 200);
|
||||
}
|
||||
};
|
||||
const visibility = () => {
|
||||
if (document.visibilityState === "hidden") report();
|
||||
if (!video.paused && !video.ended) begin();
|
||||
};
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
window.addEventListener("pagehide", report);
|
||||
window.addEventListener("beforeunload", report);
|
||||
return () => {
|
||||
reporters.delete(video);
|
||||
const pending = started || elapsed;
|
||||
settle();
|
||||
if (pending && !video.ended) emit("ep_pause");
|
||||
Object.entries(events).forEach(([name, handler]) =>
|
||||
video.removeEventListener(name, handler),
|
||||
);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
window.removeEventListener("pagehide", report);
|
||||
window.removeEventListener("beforeunload", report);
|
||||
};
|
||||
}
|
||||
@@ -439,17 +439,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
||||
const timer = setTimeout(onClose, 12000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
useEffect(() => {
|
||||
if (ready && ref.current)
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("external-ad:track", {
|
||||
detail: {
|
||||
element: ref.current.querySelector("[data-track-ad]"),
|
||||
eventType: "show",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [ready, ref]);
|
||||
const image = (
|
||||
<EncryptedImage
|
||||
eager
|
||||
@@ -461,15 +450,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
||||
onError={onClose}
|
||||
/>
|
||||
);
|
||||
const click = () =>
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("external-ad:track", {
|
||||
detail: {
|
||||
element: ref.current?.querySelector("[data-track-ad]"),
|
||||
eventType: "click",
|
||||
},
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -488,16 +468,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
||||
className="hk-advertisement-dialog-panel"
|
||||
data-dialog-ad-item=""
|
||||
data-dialog-ad-id={id}
|
||||
data-track-ad="external-dialog"
|
||||
data-ad-id={id}
|
||||
data-creative-id=""
|
||||
data-ad-location={ad.location}
|
||||
data-ad-slot-key={ad.advertiseLocationCode || ad.location || ""}
|
||||
data-ad-slot-name={
|
||||
ad.advertiseLocationName || "弹窗广告位" + (ad.location || "")
|
||||
}
|
||||
data-ad-type="interstitial"
|
||||
data-ad-clickable={ad.link ? "1" : undefined}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ad.title || "广告"}
|
||||
@@ -511,7 +481,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
data-dialog-ad-link=""
|
||||
onClick={click}
|
||||
>
|
||||
{image}
|
||||
</a>
|
||||
@@ -541,7 +510,6 @@ function AdItem({ ad, onClose }: { ad: Ad; onClose: () => void }) {
|
||||
target="_blank"
|
||||
rel="sponsored nofollow noopener"
|
||||
data-dialog-ad-link=""
|
||||
onClick={click}
|
||||
>
|
||||
<Icon
|
||||
name="play_hollow"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { EncryptedImage } from "@/components/media/EncryptedImage";
|
||||
import { BackLink } from "@/components/catalog/Interactions";
|
||||
import { RecommendationCard } from "@/components/catalog/Cards";
|
||||
import { Pagination } from "@/components/catalog/Pagination";
|
||||
import { track } from "@/lib/tracker";
|
||||
import {
|
||||
array,
|
||||
first,
|
||||
@@ -192,16 +191,6 @@ export function SearchPage({ data }: CatalogPageProps) {
|
||||
keyword = str(data, "query"),
|
||||
works = array(data.works);
|
||||
useEffect(() => setQuery(keyword), [keyword]);
|
||||
useEffect(() => {
|
||||
if (keyword && num(data, "pageNum") <= 1)
|
||||
void track("keywordSearch", {
|
||||
keyword,
|
||||
search_result_count: num(data, "resultCount"),
|
||||
search_content_type: "",
|
||||
search_trace_id: "",
|
||||
search_id: "",
|
||||
});
|
||||
}, [keyword, data.pageNum, data.resultCount]);
|
||||
return (
|
||||
<>
|
||||
<SearchHeader
|
||||
@@ -245,20 +234,6 @@ export function SearchPage({ data }: CatalogPageProps) {
|
||||
<RecommendationCard
|
||||
key={`${str(item, "ID")}-${index}`}
|
||||
item={item}
|
||||
onClick={() => {
|
||||
void track("keywordClick", {
|
||||
keyword,
|
||||
click_item_id: str(item, "ID"),
|
||||
click_item_type_key: "short_drama",
|
||||
click_item_type_name: "短剧",
|
||||
click_position:
|
||||
(Math.max(1, num(data, "pageNum")) - 1) *
|
||||
Math.max(1, num(data, "pageSize")) +
|
||||
index +
|
||||
1,
|
||||
search_trace_id: "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function CompliancePage({ data }: PageProps) {
|
||||
value(config, "TGGroup") ||
|
||||
value(config, "Telegram") ||
|
||||
value(config, "TelegramAdd") ||
|
||||
"https://t.me/huangguosan";
|
||||
"https://t.me/jbd125";
|
||||
const ref = useLegalNavigation();
|
||||
return (
|
||||
<div ref={ref}>
|
||||
|
||||
@@ -9,7 +9,7 @@ export function DMCAPage({ data }: PageProps) {
|
||||
value(config, "TGGroup") ||
|
||||
value(config, "Telegram") ||
|
||||
value(config, "TelegramAdd") ||
|
||||
"https://t.me/huangguosan";
|
||||
"https://t.me/jbd125";
|
||||
const ref = useLegalNavigation();
|
||||
return (
|
||||
<div ref={ref}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import { useSite } from "@/components/providers/SiteProvider";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
@@ -7,7 +7,6 @@ import { BackLink } from "@/components/catalog/Interactions";
|
||||
import { AdBanners } from "@/components/catalog/Promotions";
|
||||
import { PlayLinks } from "@/components/catalog/PlayLinks";
|
||||
import type { PlayerHandle } from "@/components/player/VideoPlayer";
|
||||
import { useDramaContext } from "@/components/player/usePlaybackAnalytics";
|
||||
import {
|
||||
array,
|
||||
chapterPlayURL,
|
||||
@@ -23,8 +22,7 @@ import {
|
||||
externalProps,
|
||||
type Data,
|
||||
} from "@/features/catalog/model";
|
||||
import { dramaStatusChange } from "@/lib/tracker";
|
||||
import { trackingContext, coinGate } from "./model";
|
||||
import { coinGate } from "./model";
|
||||
import { PlaybackStage } from "./PlaybackStage";
|
||||
import { AppOnlyDialog } from "./AppOnlyDialog";
|
||||
import { GuessLike, HotList, PlaybackShare, Related, Synopsis } from "./Shared";
|
||||
@@ -47,12 +45,7 @@ export function DramaWatchPage({ data }: { data: Data }) {
|
||||
watchSent = useRef(false);
|
||||
const site = useSite(),
|
||||
loggedIn = !!site.user,
|
||||
name = displayName(p),
|
||||
context = useMemo(
|
||||
() => trackingContext(data.trackerDrama),
|
||||
[data.trackerDrama],
|
||||
);
|
||||
useDramaContext(context);
|
||||
name = displayName(p);
|
||||
const themes = namedThemeTags(
|
||||
get(p, "ThemeIDs"),
|
||||
get(p, "ThemeNames"),
|
||||
@@ -103,11 +96,7 @@ export function DramaWatchPage({ data }: { data: Data }) {
|
||||
<header className="hk-page-layout-subheader hk-mobile-only" aria-label="播放页顶栏">
|
||||
<BackLink />
|
||||
<div className="hk-page-layout-subheader__title">{name}</div>
|
||||
<PlaybackShare
|
||||
getVideo={() =>
|
||||
mode === "main" ? player.current?.video() || null : null
|
||||
}
|
||||
/>
|
||||
<PlaybackShare />
|
||||
</header>
|
||||
<div className="hk-page-layout hk-playback-page">
|
||||
<main className="hk-page-main hk-playback-main">
|
||||
@@ -208,9 +197,6 @@ export function DramaWatchPage({ data }: { data: Data }) {
|
||||
<PlaybackShare
|
||||
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
|
||||
label
|
||||
getVideo={() =>
|
||||
mode === "main" ? player.current?.video() || null : null
|
||||
}
|
||||
/>
|
||||
<a
|
||||
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
|
||||
@@ -272,7 +258,6 @@ export function DramaWatchPage({ data }: { data: Data }) {
|
||||
key={chid}
|
||||
className={`hk-playback-ep${chid === chapterID ? " hk-is-active" : ""}${coinGate(chapter) ? " hk-playback-ep--coin" : ""}`}
|
||||
href={chapterPlayURL(get(p, "Slug"), id, chid)}
|
||||
onClick={() => void dramaStatusChange(ep, chid)}
|
||||
>
|
||||
{ep}
|
||||
</a>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type Data,
|
||||
} from "@/features/catalog/model";
|
||||
import { mediaTime } from "./model";
|
||||
import { dramaStatusChange } from "@/lib/tracker";
|
||||
export interface PlaybackStageProps {
|
||||
data: Data;
|
||||
series?: boolean;
|
||||
@@ -84,7 +83,6 @@ export function PlaybackStage({
|
||||
<VideoPlayer
|
||||
ref={playerRef}
|
||||
url={url}
|
||||
trackDrama={!series && !preview}
|
||||
onPlaying={onPlaying}
|
||||
autoPlay={autoPlay}
|
||||
/>
|
||||
@@ -183,9 +181,6 @@ export function PlaybackStage({
|
||||
className="hk-playback-player__icon"
|
||||
href={nextURL}
|
||||
aria-label="下一集"
|
||||
onClick={() =>
|
||||
void dramaStatusChange(ep + 1, Number(data.nextChapterID))
|
||||
}
|
||||
>
|
||||
<Icon name="chevron-right" />
|
||||
</a>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { BackLink } from "@/components/catalog/Interactions";
|
||||
import { AdBanners } from "@/components/catalog/Promotions";
|
||||
import { PlayLinks } from "@/components/catalog/PlayLinks";
|
||||
import type { PlayerHandle } from "@/components/player/VideoPlayer";
|
||||
import { useDramaContext } from "@/components/player/usePlaybackAnalytics";
|
||||
import {
|
||||
array,
|
||||
first,
|
||||
@@ -24,7 +23,6 @@ export function SeriesWatchPage({ data }: { data: Data }) {
|
||||
title = str(video, "Title") || "视频",
|
||||
player = useRef<PlayerHandle>(null),
|
||||
[playing, setPlaying] = useState(false);
|
||||
useDramaContext(null);
|
||||
const play = () => player.current?.play(),
|
||||
actions = "btn btn--ghost play-actions__btn play-actions__btn--icon";
|
||||
return (
|
||||
|
||||
@@ -20,15 +20,12 @@ import {
|
||||
type Data,
|
||||
type NamedTag,
|
||||
} from "@/features/catalog/model";
|
||||
import { reportPlaybackAction } from "@/components/player/usePlaybackAnalytics";
|
||||
export function PlaybackShare({
|
||||
className = "page-subheader__btn",
|
||||
label = false,
|
||||
getVideo,
|
||||
}: {
|
||||
className?: string;
|
||||
label?: boolean;
|
||||
getVideo?: () => HTMLVideoElement | null;
|
||||
}) {
|
||||
const site = useSite();
|
||||
return (
|
||||
@@ -37,7 +34,6 @@ export function PlaybackShare({
|
||||
type="button"
|
||||
aria-label="分享"
|
||||
onClick={async () => {
|
||||
reportPlaybackAction("ep_share", getVideo?.());
|
||||
try {
|
||||
if (navigator.share)
|
||||
await navigator.share({
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
import { get, number, type CatalogRecord } from "@/features/catalog/model";
|
||||
import type { Fields } from "@/components/analytics/tracker";
|
||||
export const integer = (value: unknown) => Math.trunc(number(value));
|
||||
export function trackingContext(input: unknown): Fields | null {
|
||||
if (!input || !number(get(input, "DramaID"))) return null;
|
||||
const string = (key: string, fallback = "") =>
|
||||
String(get(input, key) ?? "").trim() || fallback;
|
||||
return {
|
||||
media_id: string("MediaID"),
|
||||
drama_id: string("DramaID"),
|
||||
drama_title: string("DramaTitle", "未命名短剧"),
|
||||
episode: Math.max(1, integer(get(input, "Episode"))),
|
||||
total_episode: Math.max(1, integer(get(input, "TotalEpisode"))),
|
||||
video_id: string("VideoID"),
|
||||
drama_type_id: string("DramaTypeID", "default"),
|
||||
drama_type_name: string("DramaTypeName", "默认分类"),
|
||||
drama_content_type: "short_drama",
|
||||
recommend_trace_id: string("RecommendTraceID"),
|
||||
drama_tag_key: string("DramaTagKey", "default"),
|
||||
drama_tag_name: string("DramaTagName", "默认标签"),
|
||||
drama_duration: Math.max(0, number(get(input, "DramaDuration"))),
|
||||
};
|
||||
}
|
||||
export function coinGate(chapter: CatalogRecord): boolean {
|
||||
return (
|
||||
!chapter.canWatch &&
|
||||
|
||||
+4
-126
@@ -10,35 +10,14 @@ export class ApiError extends Error {
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
export interface DramaContext {
|
||||
media_id?: string | number;
|
||||
drama_id?: string | number;
|
||||
episode?: number;
|
||||
video_id?: string | number;
|
||||
recommend_trace_id?: string;
|
||||
}
|
||||
export interface RequestOptions {
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
trackerContext?: DramaContext;
|
||||
trackerEvent?: string | false;
|
||||
signal?: AbortSignal;
|
||||
skipRecovery?: boolean;
|
||||
}
|
||||
type Tracker = {
|
||||
getSDKCommonHeader?: () => string | Promise<string>;
|
||||
getCommonFields?: () =>
|
||||
| Record<string, unknown>
|
||||
| Promise<Record<string, unknown>>;
|
||||
getDramaContext?: () => DramaContext;
|
||||
setUid?: (uid: string) => void;
|
||||
};
|
||||
const tracker = () =>
|
||||
typeof window === "undefined"
|
||||
? undefined
|
||||
: (window as unknown as { tracker?: Tracker }).tracker;
|
||||
export function getCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const value =
|
||||
@@ -103,7 +82,6 @@ export function setAuthSession(user: Record<string, unknown>) {
|
||||
} catch {
|
||||
/* storage disabled */
|
||||
}
|
||||
if (user.userId != null) tracker()?.setUid?.(String(user.userId));
|
||||
document.dispatchEvent(new CustomEvent("auth:login", { detail: { user } }));
|
||||
}
|
||||
export function getDeviceID(): string {
|
||||
@@ -149,111 +127,11 @@ export function getChannel(): string {
|
||||
setCookie("channel", value, 365);
|
||||
return value;
|
||||
}
|
||||
const events: Record<string, string> = {
|
||||
"/api/playlet/chapter/like/set": "drama_like",
|
||||
"/api/playlet/like/set": "drama_like",
|
||||
"/api/playlet/chapter/collect/set": "drama_collect",
|
||||
"/api/playlet/collect/set": "drama_collect",
|
||||
"/api/comment/send": "drama_comment",
|
||||
"/api/playlet/chapter/unlock": "drama_purchase",
|
||||
};
|
||||
const base64 = (value: unknown) =>
|
||||
btoa(
|
||||
Array.from(new TextEncoder().encode(JSON.stringify(value)), (n) =>
|
||||
String.fromCharCode(n),
|
||||
).join(""),
|
||||
)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const clean = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^\x20-\x7e]|[;=]/g, " ")
|
||||
.trim();
|
||||
async function deadline<T>(
|
||||
promise: Promise<T>,
|
||||
fallback: T,
|
||||
ms = 1500,
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise.catch(() => fallback),
|
||||
new Promise<T>((resolve) => {
|
||||
timer = setTimeout(() => resolve(fallback), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
async function requestHeaders(
|
||||
path: string,
|
||||
method: string,
|
||||
options: RequestOptions,
|
||||
): Promise<Headers> {
|
||||
const headers = new Headers({
|
||||
function requestHeaders(options: RequestOptions): Headers {
|
||||
return new Headers({
|
||||
Accept: "application/json",
|
||||
...options.headers,
|
||||
});
|
||||
if (new URL(path, location.href).origin !== location.origin) return headers;
|
||||
const sdk = tracker();
|
||||
const commonHeader = deadline(
|
||||
Promise.resolve().then(() => sdk?.getSDKCommonHeader?.() || ""),
|
||||
"",
|
||||
);
|
||||
const event =
|
||||
options.trackerEvent === false
|
||||
? ""
|
||||
: options.trackerEvent || events[new URL(path, location.href).pathname];
|
||||
const context = options.trackerContext || sdk?.getDramaContext?.();
|
||||
const common =
|
||||
event && !["GET", "HEAD"].includes(method) && context?.drama_id
|
||||
? await deadline(
|
||||
Promise.resolve().then(() => sdk?.getCommonFields?.() || {}),
|
||||
{},
|
||||
)
|
||||
: null;
|
||||
if (common && context) {
|
||||
const requestID = Array.from(
|
||||
crypto.getRandomValues(new Uint8Array(16)),
|
||||
(n) => n.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const drama = {
|
||||
media_id: String(context.media_id ?? "").trim(),
|
||||
drama_id: String(context.drama_id ?? "").trim(),
|
||||
episode: Math.max(1, context.episode || 1),
|
||||
video_id: String(context.video_id ?? "").trim(),
|
||||
recommend_trace_id: String(context.recommend_trace_id ?? "").trim(),
|
||||
};
|
||||
headers.set("X-Tracker-Request-Id", requestID);
|
||||
headers.set("X-Tracker-Event-Name", event);
|
||||
headers.set(
|
||||
"X-DC-Request-Context",
|
||||
base64({ v: 1, request_id: requestID, event_name: event, common, drama }),
|
||||
);
|
||||
headers.set(
|
||||
"X-User-Agent",
|
||||
[
|
||||
"DevID=" + clean(common.device_id),
|
||||
"DevType=" + clean(common.device || "OTHER"),
|
||||
"SysType=" +
|
||||
(common.device === "Android"
|
||||
? "h5_android"
|
||||
: common.device === "iOS"
|
||||
? "h5_ios"
|
||||
: "h5_pc"),
|
||||
"Ver=" + clean(common.app_version || "1.0.0"),
|
||||
"BuildID=web",
|
||||
"device_brand=" + clean(common.device_brand),
|
||||
"device_model=" + clean(common.device_model),
|
||||
"sid=" + clean(common.sid),
|
||||
].join(";"),
|
||||
);
|
||||
}
|
||||
const value = await commonHeader;
|
||||
if (value && !headers.has("X-SDK-Common")) headers.set("X-SDK-Common", value);
|
||||
return headers;
|
||||
}
|
||||
let guestPromise: Promise<boolean> | null = null;
|
||||
export async function ensureGuestSession(): Promise<boolean> {
|
||||
@@ -294,7 +172,7 @@ async function recoverSession(): Promise<boolean> {
|
||||
});
|
||||
return recovery;
|
||||
}
|
||||
/** Same-origin BFF request; prepared tracking headers and request_id survive one guest retry. */
|
||||
/** Same-origin BFF request; retries once after guest session recovery. */
|
||||
export async function apiRequest<T = Record<string, unknown>>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
@@ -304,7 +182,7 @@ export async function apiRequest<T = Record<string, unknown>>(
|
||||
const url = new URL(path, location.href);
|
||||
for (const [key, value] of Object.entries(options.params || {}))
|
||||
if (value != null) url.searchParams.set(key, String(value));
|
||||
const headers = await requestHeaders(url.href, method, options);
|
||||
const headers = requestHeaders(options);
|
||||
const payload =
|
||||
body == null
|
||||
? undefined
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ const navigationFields = new Set([
|
||||
// Raw API/SDK/payment/media objects must retain their request and signed source addresses.
|
||||
const opaqueFields = new Set([
|
||||
"appConfig", "config", "advitise", "advertise", "chapter", "rawMedia", "videoInfo", "video", "media", "images",
|
||||
"reportSdkConfig", "ReportSDKConfig", "installGuide", "deposit", "payment", "payMethods",
|
||||
"installGuide", "deposit", "payment", "payMethods",
|
||||
"jsonLD", "seoInfo", "canonicalURL", "paginationPrevURL", "paginationNextURL",
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"use client";
|
||||
export {
|
||||
track,
|
||||
tracker,
|
||||
dramaEvent,
|
||||
dramaStatusChange,
|
||||
setDramaContext,
|
||||
externalAd,
|
||||
} from "@/components/analytics/tracker";
|
||||
export type { Fields, TrackerBoot } from "@/components/analytics/tracker";
|
||||
@@ -15,7 +15,7 @@ export function installationURL(value: string | undefined, deepLink = false): st
|
||||
export function serverConfig() {
|
||||
const environment = (process.env.APP_ENV || 'development') as AppEnvironment;
|
||||
if (!['development', 'test', 'production'].includes(environment)) throw new Error('Invalid APP_ENV');
|
||||
const upstreamURL = (process.env.UPSTREAM_API_URL || (environment === 'development' ? 'http://172.104.181.99:30331' : '')).trim().replace(/\/+$/, '');
|
||||
const upstreamURL = (process.env.UPSTREAM_API_URL || (environment === 'development' ? 'https://webhuakuiapi.mfcg.me' : '')).trim().replace(/\/+$/, '');
|
||||
if (upstreamURL && !/^https?:\/\//.test(upstreamURL)) throw new Error('UPSTREAM_API_URL must be an HTTP(S) URL');
|
||||
return {
|
||||
environment, upstreamURL,
|
||||
@@ -23,10 +23,10 @@ export function serverConfig() {
|
||||
siteURL: (process.env.SITE_URL || '').trim().replace(/\/+$/, ''),
|
||||
imageCDNURL: (process.env.IMAGE_CDN_URL || '').trim(),
|
||||
timeout: Math.max(100, Number(process.env.UPSTREAM_TIMEOUT_MS) || 20000),
|
||||
trackerAppID: process.env.TRACKER_APP_ID || 'JHA-4001',
|
||||
trackerEncryptedConfig: process.env.TRACKER_ENCRYPTED_CONFIG || '',
|
||||
m3u8KeyFile: process.env.M3U8_KEY_FILE || './config/secrets/m3u8.key',
|
||||
navigationOnly: process.env.NAVIGATION_ONLY === 'true',
|
||||
/** Every Telegram contact entry points here, overriding the upstream tgGroup/telegram/telegramAdd. */
|
||||
contactTelegramURL: installationURL(process.env.CONTACT_TELEGRAM_URL) || 'https://t.me/jbd125',
|
||||
installation: {
|
||||
iosStoreURL: installationURL(process.env.APP_IOS_STORE_URL),
|
||||
iosProfileURL: installationURL(process.env.APP_IOS_PROFILE_URL),
|
||||
|
||||
@@ -12,13 +12,6 @@ async function guessData(ctx:RequestContext,id:number,page:number):Promise<Data>
|
||||
if(!arr(raw.list).length&&page>1)raw=await apiData(ctx,'/api/app/playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:6},{public:true});
|
||||
raw.pageNum=num(raw.pageNum)||page;raw.pageSize=num(raw.pageSize)||6;return raw;
|
||||
}
|
||||
export function dramaTracking(playlet:Data,chapter:Data,chapterID:number,episode:number):Data{
|
||||
const data:Data={MediaID:'',DramaID:num(playlet.id),DramaTitle:first(playlet.playName,playlet.title),Episode:Math.max(1,episode),TotalEpisode:num(playlet.totalEpisode)>0?num(playlet.totalEpisode):arr(playlet.playletChapterInfo).length||1,VideoID:num(chapter.id)>0?num(chapter.id):chapterID,DramaTypeID:'default',DramaTypeName:'默认分类',DramaTagKey:'default',DramaTagName:'默认标签',DramaDuration:Math.max(0,num(chapter.mediaTime)),RecommendTraceID:''};
|
||||
const ids=Array.isArray(playlet.themeIds)?playlet.themeIds:[],names=Array.isArray(playlet.themeNames)?playlet.themeNames:[];
|
||||
for(let i=0;i<ids.length;i++)if(num(ids[i])>0&&text(names[i])){data.DramaTypeID=String(ids[i]);data.DramaTypeName=text(names[i]);break;}
|
||||
const tags=Array.isArray(playlet.tagIds)?playlet.tagIds:[],tagNames=Array.isArray(playlet.tagNames)?playlet.tagNames:[],good=tags.map((id:number,i:number)=>({id,name:text(tagNames[i])})).filter((tag:Data)=>num(tag.id)>0&&tag.name);
|
||||
if(good.length){data.DramaTagKey=good.map((tag:Data)=>tag.id).join(',');data.DramaTagName=good.map((tag:Data)=>tag.name).join(',');}return data;
|
||||
}
|
||||
export async function play(ctx:RequestContext,p:Params):Promise<Data|null>{
|
||||
if(p.ep&&integer(p.ep)<=0)return null;
|
||||
await ensureGuestOnEnter(ctx);
|
||||
@@ -32,7 +25,7 @@ export async function play(ctx:RequestContext,p:Params):Promise<Data|null>{
|
||||
let unlockCost=num(chapter.costCoins)>0?num(chapter.costCoins):Math.max(0,num(chapter.singleCoins));if(unlockCost<=0){const paid=chapters.find(item=>!item.AllowWatch&&(item.needUnlock||num(item.payType)===1));unlockCost=num(paid?.costCoins)>0?num(paid?.costCoins):num(paid?.singleCoins);}
|
||||
const [hot,guess]=await Promise.all([hotListData(ctx),guessData(ctx,num(playlet.id),positive(p.guessPage))]);
|
||||
const seo=resolvePlayPageSEO(chapter,playlet,episode);
|
||||
const hotList=arr(hot.list),data:Data={title:seo.title,navActive:'list',seoInfo:seo,playlet,chapter,chapterID,episode,canWatch,mainURL,previewURL,hasPreview:!!previewURL,unlockCost,balance:num(chapter.balance),balanceText:count(chapter.balance),nextChapterID:num(chapters[index+1]?.id),advitise:playlet.advitise,related:arr(playlet.relatedRecommendData),guessLike:arr(guess.list),guessPage:num(guess.pageNum),guessPageSize:num(guess.pageSize),guessTotal:num(guess.totalCount),hotList,hotMoreURL:'/list/all/0/2/',trackerDrama:dramaTracking(playlet,chapter,chapterID,episode),canonicalPath:playURL(playlet.slug,playlet.id)};
|
||||
const hotList=arr(hot.list),data:Data={title:seo.title,navActive:'list',seoInfo:seo,playlet,chapter,chapterID,episode,canWatch,mainURL,previewURL,hasPreview:!!previewURL,unlockCost,balance:num(chapter.balance),balanceText:count(chapter.balance),nextChapterID:num(chapters[index+1]?.id),advitise:playlet.advitise,related:arr(playlet.relatedRecommendData),guessLike:arr(guess.list),guessPage:num(guess.pageNum),guessPageSize:num(guess.pageSize),guessTotal:num(guess.totalCount),hotList,hotMoreURL:'/list/all/0/2/',canonicalPath:playURL(playlet.slug,playlet.id)};
|
||||
data.playResponse={playlet,chapter,chapterID,canWatch,mainURL,previewURL,hasPreview:!!previewURL,guessLike:guess,hotList};
|
||||
return model(data);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@ export function searchTrends(items: unknown): Data[] {
|
||||
}
|
||||
export async function appConfigData(ctx:RequestContext):Promise<Data>{
|
||||
const raw=await apiData(ctx,'/api/app/config/info',{}, {public:true,ttl:60000,stale:true,fallback:{config:{},newAddressInfo:{},advitise:{},seoInfo:{},seoNavInfo:{},navPageList:[]}});
|
||||
const clean=(v:unknown)=>text(v).replace(/\\+$/,'').trim(), cfg:Data={...raw.config},addr=raw.newAddressInfo||{};
|
||||
const clean=(v:unknown)=>text(v).replace(/\\+$/,'').trim(), cfg:Data={...raw.config},addr:Data={...raw.newAddressInfo};
|
||||
for(const [key,v] of Object.entries(cfg))if(typeof v==='string')cfg[key]=clean(v);
|
||||
const email=(v:unknown)=>{let value=text(v);while(/^(?:mailto:|https?:\/\/)/i.test(value))value=value.replace(/^(?:mailto:|https?:\/\/)/i,'').trim();return value;};
|
||||
cfg.qq=text(raw.config?.qq);cfg.weiChart=text(raw.config?.weiChart);
|
||||
cfg.gmailAddr=email(cfg.gmailAddr);
|
||||
const vm:Data={config:cfg,seoInfo:normalizeSEO(raw.seoInfo),seoNavInfo:raw.seoNavInfo||{},navPageList:arr(raw.navPageList).map(item=>({line:num(item.line),url:clean(item.url)})).filter(item=>item.url),reportSdkConfig:first(raw.reportSdkConfig,process.env.TRACKER_ENCRYPTED_CONFIG),advitise:raw.advitise||{}};
|
||||
// All Telegram contact entries, including a Telegram customer-service link, use one configured account.
|
||||
const telegram=serverConfig().contactTelegramURL,isTelegram=(v:unknown)=>/^https?:\/\/(?:www\.)?(?:t|telegram)\.me\//i.test(clean(v));
|
||||
for(const key of ['tgGroup','telegram','telegramAdd'])cfg[key]=addr[key]=telegram;
|
||||
if(isTelegram(cfg.customerServiceHost)||isTelegram(addr.customerServiceHost))cfg.customerServiceHost=addr.customerServiceHost=telegram;
|
||||
const vm:Data={config:cfg,seoInfo:normalizeSEO(raw.seoInfo),seoNavInfo:raw.seoNavInfo||{},navPageList:arr(raw.navPageList).map(item=>({line:num(item.line),url:clean(item.url)})).filter(item=>item.url),advitise:raw.advitise||{}};
|
||||
for(const key of ['foreverUrlAddr','newUrlAddr','newUrlAddr1','newUrlAddr2','gmailAddr','qq','tgGroup','telegram','potaoGroup','customerServiceHost','appAddr','officialAppAddr','pdfAddr','twitterAddr'])vm[key]=first(cfg[key],key==='gmailAddr'?email(addr[key]):clean(addr[key]));
|
||||
const siteURL=serverConfig().siteURL;
|
||||
if(siteURL)cfg.foreverUrlAddr=vm.foreverUrlAddr=siteURL;
|
||||
@@ -47,7 +51,7 @@ export async function loadSharedData(ctx:RequestContext,data:Data={}):Promise<Da
|
||||
if(!data.categoryList.length)data.categoryList=model(defaultCategories.map(item=>({...item})));
|
||||
data.themeList??=themes;data.homeConfigPlaylets??=activeConfigs(home).filter(config=>num(config.type)===1);
|
||||
data.searchTrends??=model(searchTrends(hot.hotSearch).slice(0,8));
|
||||
data.requestPath??=ctx.pathname;data.trackerAppId=serverConfig().trackerAppID;
|
||||
data.requestPath??=ctx.pathname;
|
||||
const user=ctx.user as Data|null;
|
||||
data.isLoggedIn=!!ctx.token;
|
||||
data.isRegistered=isRegistered(ctx);
|
||||
@@ -64,10 +68,10 @@ export async function loadSharedData(ctx:RequestContext,data:Data={}):Promise<Da
|
||||
data.recharge??=num(user?.recharge, num(ctx.cookies.get('recharge')));
|
||||
data.rechargeText??=count(data.recharge);data.vipType??=num(user?.vipType,num(ctx.getCookie('vipType')));
|
||||
}else{for(const key of ['userName','avatar','email','userId','recharge','rechargeText','vipType'])delete data[key];}
|
||||
const rule=trackerPage(ctx.pathname);data.pageKey??=rule[0];data.pageName??=rule[1];
|
||||
const rule=pageIdentity(ctx.pathname);data.pageKey??=rule[0];data.pageName??=rule[1];
|
||||
return model(applyBrandCopy(data));
|
||||
}
|
||||
export function trackerPage(path:string):[string,string]{
|
||||
export function pageIdentity(path:string):[string,string]{
|
||||
path=toLegacyPath(path);
|
||||
const routes:[string,string,string][]=[['series-play','series_play','AI成人剧集播放页'],['series','series','AI成人剧集'],['play','play','短剧播放页'],['list','list','短剧列表'],['recently-added','recently_added','最新短剧'],['popular-recommend','popular_recommend','热门推荐'],['rank','rank','排行榜'],['topic-detail','topic_detail','专题详情'],['topic','topic','专题列表'],['tags','tags','标签详情'],['more-series-list','more_series_list','成人剧集列表'],['more-list','more_list','短剧扩展列表'],['search-mobile','search','搜索结果页'],['search','search','搜索结果页'],['history','history','观看历史'],['favorites','favorites','我的收藏'],['purchased','purchased','已购剧集'],['profile','profile','个人中心'],['setting','setting','设置'],['recharge','recharge','充值中心'],['feedback','feedback','问题反馈'],['home-road','home_road','回家的路'],['help','help','帮助中心'],['about','about','关于我们'],['terms','terms','用户协议'],['privacy','privacy','隐私政策'],['2257','compliance_2257','2257合规声明'],['dmca','dmca','DMCA版权声明'],['login','login','登录'],['register','register','注册']];
|
||||
if(path==='/')return ['home','首页'];const rule=routes.find(([prefix])=>path==='/'+prefix||path.startsWith('/'+prefix+'/'));return rule?[rule[1],rule[2]]:['internal_other','站内其他页面'];
|
||||
|
||||
@@ -31,160 +31,6 @@ const excluded = new Set([
|
||||
"x-tracker-request-id",
|
||||
"x-tracker-event-name",
|
||||
]);
|
||||
const trackerEvents: Record<string, string> = {
|
||||
"/api/playlet/chapter/like/set": "drama_like",
|
||||
"/api/playlet/like/set": "drama_like",
|
||||
"/api/playlet/chapter/collect/set": "drama_collect",
|
||||
"/api/playlet/collect/set": "drama_collect",
|
||||
"/api/comment/send": "drama_comment",
|
||||
"/api/playlet/chapter/unlock": "drama_purchase",
|
||||
};
|
||||
const commonKeys = [
|
||||
"app_id",
|
||||
"uid",
|
||||
"channel",
|
||||
"sid",
|
||||
"client_ts",
|
||||
"device",
|
||||
"device_id",
|
||||
"device_brand",
|
||||
"device_model",
|
||||
"device_fingerprint",
|
||||
"fp_version",
|
||||
"user_agent",
|
||||
"sdk_version",
|
||||
"app_version",
|
||||
];
|
||||
const dramaKeys = [
|
||||
"media_id",
|
||||
"drama_id",
|
||||
"episode",
|
||||
"video_id",
|
||||
"recommend_trace_id",
|
||||
];
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function exactKeys(
|
||||
value: unknown,
|
||||
keys: string[],
|
||||
): value is Record<string, unknown> {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Object.keys(value).length === keys.length &&
|
||||
keys.every((k) => Object.hasOwn(value, k))
|
||||
);
|
||||
}
|
||||
export function canonicalTrackerContext(
|
||||
ctx: RequestContext,
|
||||
): Record<string, string> {
|
||||
const id = ctx.headers.get("x-tracker-request-id") || "";
|
||||
const event = ctx.headers.get("x-tracker-event-name") || "";
|
||||
const packed = ctx.headers.get("x-dc-request-context") || "";
|
||||
if (
|
||||
ctx.request.method !== "POST" ||
|
||||
trackerEvents[ctx.pathname] !== event ||
|
||||
!/^[a-f\d]{32}$/i.test(id) ||
|
||||
!/^[A-Za-z\d_-]{1,8192}$/.test(packed)
|
||||
)
|
||||
return {};
|
||||
try {
|
||||
const raw = Buffer.from(packed, "base64url");
|
||||
if (raw.length > 6144) return {};
|
||||
const data: unknown = JSON.parse(raw.toString());
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
data.v !== 1 ||
|
||||
data.request_id !== id ||
|
||||
data.event_name !== event ||
|
||||
!exactKeys(data.common, commonKeys) ||
|
||||
!exactKeys(data.drama, dramaKeys)
|
||||
)
|
||||
return {};
|
||||
const c = data.common;
|
||||
const d = data.drama;
|
||||
const stringField = (
|
||||
o: Record<string, unknown>,
|
||||
k: string,
|
||||
max: number,
|
||||
required = false,
|
||||
) =>
|
||||
typeof o[k] === "string" &&
|
||||
[...(o[k] as string)].length <= max &&
|
||||
(!required || o[k] !== "");
|
||||
if (
|
||||
!stringField(c, "app_id", 50, true) ||
|
||||
!/^[A-Z]{1,6}-\d{1,6}$/.test(String(c.app_id)) ||
|
||||
c.app_id !== serverConfig().trackerAppID
|
||||
)
|
||||
return {};
|
||||
if (
|
||||
!stringField(c, "uid", 32) ||
|
||||
(c.uid &&
|
||||
(!/^[A-Za-z\d_]{4,32}$/.test(String(c.uid)) ||
|
||||
["u_1001", "stress_user", "u_lab"].includes(String(c.uid))))
|
||||
)
|
||||
return {};
|
||||
if (
|
||||
!stringField(c, "channel", 50) ||
|
||||
(c.channel &&
|
||||
(!/^[A-Za-z\d_.-]+$/.test(String(c.channel)) ||
|
||||
["{}", "0", "-", "unknown"].includes(
|
||||
String(c.channel).toLowerCase(),
|
||||
)))
|
||||
)
|
||||
return {};
|
||||
if (
|
||||
!Number.isInteger(c.client_ts) ||
|
||||
Number(c.client_ts) < 1767196800 ||
|
||||
Number(c.client_ts) > 9999999999
|
||||
)
|
||||
return {};
|
||||
if (
|
||||
!stringField(c, "device", 30) ||
|
||||
(c.device &&
|
||||
!["Android", "iOS", "PC", "OTHER"].includes(String(c.device)))
|
||||
)
|
||||
return {};
|
||||
for (const [key, max, required] of [
|
||||
["sid", 50, false],
|
||||
["device_id", 50, true],
|
||||
["device_brand", 100, false],
|
||||
["device_model", 100, false],
|
||||
["device_fingerprint", 128, false],
|
||||
["fp_version", 128, false],
|
||||
["user_agent", 4096, false],
|
||||
["sdk_version", 128, false],
|
||||
["app_version", 128, false],
|
||||
] as const)
|
||||
if (!stringField(c, key, max, required)) return {};
|
||||
for (const [key, max, required] of [
|
||||
["media_id", 512, false],
|
||||
["drama_id", 512, true],
|
||||
["video_id", 512, false],
|
||||
["recommend_trace_id", 2048, false],
|
||||
] as const)
|
||||
if (!stringField(d, key, max, required)) return {};
|
||||
if (!Number.isSafeInteger(d.episode) || Number(d.episode) <= 0) return {};
|
||||
const canonical = Buffer.from(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
request_id: id,
|
||||
event_name: event,
|
||||
common: c,
|
||||
drama: d,
|
||||
}),
|
||||
);
|
||||
if (canonical.length > 6144) return {};
|
||||
return {
|
||||
"X-DC-Request-Context": canonical.toString("base64url"),
|
||||
"X-Tracker-Request-Id": id,
|
||||
"X-Tracker-Event-Name": event,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
export function buildXUserAgent(ctx: RequestContext): string {
|
||||
const supplied = ctx.headers.get("x-user-agent")?.trim();
|
||||
if (supplied) return supplied;
|
||||
@@ -289,8 +135,6 @@ export function buildUpstreamHeaders(
|
||||
result.delete("Cookie");
|
||||
}
|
||||
if (options.forwardCookies === false) result.delete("Cookie");
|
||||
for (const [name, value] of Object.entries(canonicalTrackerContext(ctx)))
|
||||
result.set(name, value);
|
||||
return result;
|
||||
}
|
||||
export async function upstreamFetch(
|
||||
|
||||
+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>();
|
||||
globalThis.fetch=async(input)=>{
|
||||
const path=new URL(String(input)).pathname;calls.set(path,(calls.get(path)||0)+1);let data:unknown={list:[],totalCount:0};
|
||||
if(path==='/api/app/config/info')data={config:{foreverUrlAddr:'https://canonical.drama.test',gmailAddr:'support@huangguo.com',brandName:'黄果短剧'},seoInfo:{title:'黄果短剧首页',description:'黄果短剧介绍',webSiteName:'黄果短剧',keywords:['黄果短剧']},seoNavInfo:{dhTitle:'黄果短剧导航',dhDescription:'黄果短剧最新线路',dhSiteName:'黄果短剧'},reportSdkConfig:'黄果短剧-signed-sdk'};
|
||||
if(path==='/api/app/config/info')data={config:{foreverUrlAddr:'https://canonical.drama.test',gmailAddr:'support@huangguo.com',brandName:'黄果短剧'},seoInfo:{title:'黄果短剧首页',description:'黄果短剧介绍',webSiteName:'黄果短剧',keywords:['黄果短剧']},seoNavInfo:{dhTitle:'黄果短剧导航',dhDescription:'黄果短剧最新线路',dhSiteName:'黄果短剧'}};
|
||||
if(path==='/api/app/translation/category/list')data={categoryList:[{name:'黄果短剧首页',urlValue:'/',categoryType:1,seoInfo:{title:'黄果短剧首页',description:'黄果短剧介绍'}}]};
|
||||
if(path==='/api/app/playlet/home')data={seoInfo:{title:'黄果短剧首页'},announcements:[{content:'欢迎来到黄果短剧'}],homeConfigData:[{name:'黄果短剧精选',type:1,status:true}],homeBannerData:[{id:1,playName:'黄果恋人'}]};
|
||||
return Response.json({code:200,data});
|
||||
};
|
||||
try{
|
||||
const page=await buildPage(context('/'));const config=page.data.appConfig;
|
||||
assert.equal(page.data.title,'花魁短剧首页');assert.equal(config.config.brandName,BRAND_NAME);assert.equal(config.seoInfo.webSiteName,BRAND_NAME);assert.equal(config.seoNavInfo.dhTitle,'花魁短剧导航');assert.equal(page.data.categoryList[0].name,'花魁短剧首页');assert.equal(page.data.announcements[0].content,'欢迎来到花魁短剧');assert.equal(page.data.homeConfigPlaylets[0].name,'花魁短剧精选');assert.equal(page.data.homeBannerData[0].playName,'黄果恋人');assert.equal(config.gmailAddr,'support@huangguo.com');assert.equal(config.reportSdkConfig,'黄果短剧-signed-sdk');
|
||||
assert.equal(page.data.title,'花魁短剧首页');assert.equal(config.config.brandName,BRAND_NAME);assert.equal(config.seoInfo.webSiteName,BRAND_NAME);assert.equal(config.seoNavInfo.dhTitle,'花魁短剧导航');assert.equal(page.data.categoryList[0].name,'花魁短剧首页');assert.equal(page.data.announcements[0].content,'欢迎来到花魁短剧');assert.equal(page.data.homeConfigPlaylets[0].name,'花魁短剧精选');assert.equal(page.data.homeBannerData[0].playName,'黄果恋人');assert.equal(config.gmailAddr,'support@huangguo.com');
|
||||
const cached=await apiData(context('/'),'/api/app/config/info',{}, {public:true,ttl:60000});assert.equal(cached.seoInfo.webSiteName,BRAND_NAME);assert.equal(cached.config.brandName,BRAND_NAME);assert.equal(calls.get('/api/app/config/info'),1);
|
||||
const rendered=enrichSEO(context('/'),page.data);assert.equal(rendered.siteName,BRAND_NAME);assert.equal(rendered.canonicalURL,'https://canonical.drama.test/');assert.equal(rendered.metaTitle,'花魁短剧首页');
|
||||
const help=await buildPage(context('/help/'));assert.equal(help.data.faqItems[0].Question,'花魁短剧网页版可以看全集吗?');
|
||||
|
||||
@@ -112,9 +112,8 @@ test("React list advertising keeps independent desktop and mobile row positions
|
||||
3,
|
||||
);
|
||||
const promo = renderToStaticMarkup(<PromoGrid items={ads} />);
|
||||
assert.equal((promo.match(/data-track-ad="external"/g) || []).length, 2);
|
||||
assert.equal((promo.match(/data-ad-clone="1"/g) || []).length, 1);
|
||||
assert.match(promo, /rel="sponsored nofollow noopener"/);
|
||||
assert.equal((promo.match(/rel="sponsored nofollow noopener"/g) || []).length, 2);
|
||||
assert.equal((promo.match(/tabindex="-1"/g) || []).length, 1);
|
||||
});
|
||||
|
||||
test("React filter and pagination links preserve the source path-only canonical route contracts", () => {
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ test('missing upstream fields retain legacy Go zero values rather than blank att
|
||||
assert.equal(detail.CountLike,0);assert.equal(detail.IsLike,0);assert.equal(detail.Preview,'');assert.equal(detail.ThemeNames,null);assert.equal(detail.PlayletChapterInfo[0].MediaTime,0);assert.equal(detail.PlayletChapterInfo[0].NeedUnlock,false);assert.equal(detail.PlayletChapterInfo[0].AllowWatch,true);
|
||||
});
|
||||
|
||||
test('list and playback page loaders preserve filters, canonical paths, locked media and tracking',async()=>{
|
||||
test('list and playback page loaders preserve filters, canonical paths and locked media',async()=>{
|
||||
const originalFetch=globalThis.fetch,originalUpstream=process.env.UPSTREAM_API_URL;process.env.UPSTREAM_API_URL='http://page-contract-test';const calls:{url:URL;headers:Headers}[]=[];
|
||||
globalThis.fetch=async(input,init)=>{
|
||||
const url=new URL(String(input));calls.push({url,headers:new Headers(init?.headers)});let data:any={list:[],totalCount:0};
|
||||
@@ -78,7 +78,7 @@ test('list and playback page loaders preserve filters, canonical paths, locked m
|
||||
const listed=await list(ctx('/list/12/2/3/page/2/'),{theme:'12',updateStatus:'2',sortType:'3',page:'2'});
|
||||
assert.equal(listed.themeId,12);assert.equal(listed.themeSlug,'romance');assert.equal(listed.canonicalPath,'/list/romance/2/3/page/2/');assert.equal(listed.pagination.NextURL,'/list/romance/2/3/page/3/');assert.equal(listed.pageHeading,'爱情短剧');
|
||||
const catalogue=calls.find(call=>call.url.pathname==='/api/app/playlet/all/list')!;assert.equal(catalogue.url.searchParams.get('themeSlug'),'romance');assert.equal(catalogue.headers.has('authorization'),false);
|
||||
const played=await play(ctx('/play/drama/102/'),{slug:'drama',ep:'102'});assert.ok(played);assert.equal(played.chapterID,102);assert.equal(played.episode,2);assert.equal(played.canWatch,false);assert.equal(played.mainURL,'');assert.equal(played.previewURL,'preview.m3u8');assert.equal(played.balanceText,'12,345');assert.equal(played.seoInfo.title,'剧名 - 第二集');assert.equal(played.canonicalPath,'/drama/drama/');assert.equal(played.trackerDrama.VideoID,102);assert.equal(played.trackerDrama.DramaTypeID,'12');assert.equal(played.comments,undefined);assert.ok(!calls.some(call=>call.url.pathname==='/api/app/comment/list'));
|
||||
const played=await play(ctx('/play/drama/102/'),{slug:'drama',ep:'102'});assert.ok(played);assert.equal(played.chapterID,102);assert.equal(played.episode,2);assert.equal(played.canWatch,false);assert.equal(played.mainURL,'');assert.equal(played.previewURL,'preview.m3u8');assert.equal(played.balanceText,'12,345');assert.equal(played.seoInfo.title,'剧名 - 第二集');assert.equal(played.canonicalPath,'/drama/drama/');assert.equal(played.comments,undefined);assert.ok(!calls.some(call=>call.url.pathname==='/api/app/comment/list'));
|
||||
const chapter=calls.find(call=>call.url.pathname==='/api/app/playlet/chapter/detail')!;assert.equal(chapter.url.searchParams.get('chapterId'),'102');assert.equal(chapter.headers.get('authorization'),'test-session');
|
||||
assert.equal(await play(ctx('/play/drama/999/'),{slug:'drama',ep:'999'}),null);
|
||||
for(const path of ['/search/?q=legacy','/search/?page=2','/play/drama/?guessPage=2','/favorites/?filter=all','/favorites/invalid/','/history/page/10001/','/settings//'])assert.equal((await buildPage(ctx(path))).status,404,path);
|
||||
|
||||
@@ -6,11 +6,6 @@ import {
|
||||
shouldDecryptImage,
|
||||
} from "../src/lib/media/images";
|
||||
import { isHLSURL, nativeHLS, resolvePlayURL } from "../src/lib/media/playback";
|
||||
import { trackingContext } from "../src/features/playback/model";
|
||||
import {
|
||||
normalizeChannel,
|
||||
normalizeUID,
|
||||
} from "../src/components/analytics/tracker";
|
||||
|
||||
test("legacy image XOR is immutable, symmetric and changes only the first 100 bytes", () => {
|
||||
const plain = Uint8Array.from({ length: 150 }, (_, index) => index),
|
||||
@@ -75,24 +70,3 @@ test("native HLS only uses Safari and shell browsers keep the inline MSE player"
|
||||
])
|
||||
assert(!nativeHLS(video, "Mozilla iPhone Safari/604.1 " + browser));
|
||||
});
|
||||
test("drama tracking and timestamps preserve upstream field meaning", () => {
|
||||
assert.equal(trackingContext({ DramaID: 0 }), null);
|
||||
const context = trackingContext({
|
||||
DramaID: 42,
|
||||
VideoID: 101,
|
||||
Episode: 2,
|
||||
TotalEpisode: 8,
|
||||
DramaTypeID: 3,
|
||||
DramaTypeName: "都市",
|
||||
DramaTagKey: "1,2",
|
||||
DramaTagName: "剧情,原创",
|
||||
});
|
||||
assert.equal(context?.drama_id, "42");
|
||||
assert.equal(context?.episode, 2);
|
||||
assert.equal(context?.drama_content_type, "short_drama");
|
||||
assert.equal(context?.drama_tag_key, "1,2");
|
||||
assert.equal(normalizeChannel("official"), "organic");
|
||||
assert.equal(normalizeChannel("channel_A-2"), "channel_A-2");
|
||||
assert.equal(normalizeUID("stress_user"), "");
|
||||
assert.equal(normalizeUID("real_1001"), "real_1001");
|
||||
});
|
||||
|
||||
+3
-11
@@ -44,13 +44,13 @@ after(async () => { await new Promise<void>(resolve => server.close(() => resolv
|
||||
const request = (path: string, init: RequestInit = {}) => new Request('http://local.example' + path, init);
|
||||
const post = (path: string, body: unknown, headers: HeadersInit = {}) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...Object.fromEntries(new Headers(headers)) }, body: JSON.stringify(body) });
|
||||
|
||||
test('SSR/upstream preserve client cookies, cookie-token priority, IP, custom UA and SDK headers', async () => {
|
||||
test('SSR/upstream preserve client cookies, cookie-token priority, IP, and custom UA headers', async () => {
|
||||
scenario = ''; received.length = 0;
|
||||
const ctx = createContext(request('/list/', { headers: { Cookie: 'token=cookie-token; extra=keep%20me', Authorization: 'header-token', 'User-Agent': 'browser-agent', 'X-User-Agent': 'client-UA', 'X-Real-IP': '203.0.113.12', 'X-SDK-Common': 'sdk-snapshot' } }));
|
||||
const ctx = createContext(request('/list/', { headers: { Cookie: 'token=cookie-token; extra=keep%20me', Authorization: 'header-token', 'User-Agent': 'browser-agent', 'X-User-Agent': 'client-UA', 'X-Real-IP': '203.0.113.12'} }));
|
||||
await upstreamGet(ctx, '/api/app/config/info');
|
||||
const headers = received[0].headers;
|
||||
assert.equal(headers.authorization, 'cookie-token'); assert.equal(headers.cookie, 'token=cookie-token; extra=keep%20me');
|
||||
assert.equal(headers['user-agent'], 'browser-agent'); assert.equal(headers['x-user-agent'], 'client-UA'); assert.equal(headers['x-real-ip'], '203.0.113.12'); assert.equal(headers['x-sdk-common'], 'sdk-snapshot');
|
||||
assert.equal(headers['user-agent'], 'browser-agent'); assert.equal(headers['x-user-agent'], 'client-UA'); assert.equal(headers['x-real-ip'], '203.0.113.12');
|
||||
});
|
||||
test('public data requests isolate both stale Authorization and auth cookies', async () => {
|
||||
scenario = ''; received.length = 0;
|
||||
@@ -67,14 +67,6 @@ test('hop-by-hop, Connection-nominated and internal headers never cross the BFF'
|
||||
const headers = buildUpstreamHeaders(ctx);
|
||||
for (const name of ['connection', 'x-internal', 'x-origin-head', 'x-tracker-request-id', 'x-dc-request-context']) assert.equal(headers.has(name), false);
|
||||
});
|
||||
test('canonical attribution snapshot forwards only on the original allowed action', () => {
|
||||
const id = 'a'.repeat(32);
|
||||
const envelope = { v: 1, request_id: id, event_name: 'drama_like', common: { app_id: 'JHA-4001', uid: '12345', channel: 'campaign', sid: 'session', client_ts: 1791550000, device: 'PC', device_id: 'device', device_brand: '', device_model: '', device_fingerprint: '', fp_version: '', user_agent: 'browser', sdk_version: '1.2.0', app_version: '1.0.0' }, drama: { media_id: '', drama_id: '123', episode: 1, video_id: '', recommend_trace_id: '' } };
|
||||
const headers = { 'X-Tracker-Request-Id': id, 'X-Tracker-Event-Name': 'drama_like', 'X-DC-Request-Context': Buffer.from(JSON.stringify(envelope)).toString('base64url') };
|
||||
const allowed = buildUpstreamHeaders(createContext(post('/api/playlet/like/set', { id: 123, isLike: true }, headers)));
|
||||
assert.deepEqual(JSON.parse(Buffer.from(allowed.get('X-DC-Request-Context')!, 'base64url').toString()), envelope);
|
||||
assert.equal(buildUpstreamHeaders(createContext(post('/api/video/like/set', {}, headers))).has('X-DC-Request-Context'), false);
|
||||
});
|
||||
test('Set-Cookie values remain separate and updated same-name cookies are written once', async () => {
|
||||
scenario = 'cookies'; received.length = 0;
|
||||
const ctx = createContext(request('/api/auth/login'));
|
||||
|
||||
@@ -107,8 +107,8 @@ test("React tags and list pages preserve the original Go catalog content and nav
|
||||
name,
|
||||
);
|
||||
assert.equal(
|
||||
(actual.match(/data-track-ad="external"/g) || []).length,
|
||||
(expected.match(/data-track-ad="external"/g) || []).length,
|
||||
(actual.match(/rel="sponsored nofollow noopener"/g) || []).length,
|
||||
(expected.match(/rel="sponsored nofollow noopener"/g) || []).length,
|
||||
name + " ad placement",
|
||||
);
|
||||
assert.match(actual, /花魁 & 第一部/);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { canonicalRedirect } from "../src/server/redirects";
|
||||
import { matchPageRoute } from "../src/server/pages";
|
||||
import { pageRoutes, publicPageRoutes } from "../src/server/pages/routes";
|
||||
import { preparePage } from "../src/server/react/page-data";
|
||||
import { trackerPage } from "../src/server/services/shared";
|
||||
import { pageIdentity } from "../src/server/services/shared";
|
||||
|
||||
test("Huakui route prefixes preserve encoded slugs, raw queries and fragments", () => {
|
||||
const suffix = "/%E7%9F%AD%E5%89%A7%2F%E9%9B%86%252F1/?x=a+b&x=%2b&token=a%2FB#episode%202";
|
||||
@@ -262,7 +262,7 @@ test("navigation-only home road links use a configured main-site origin, then th
|
||||
});
|
||||
|
||||
|
||||
test("semantic page paths retain their original analytics classification", () => {
|
||||
test("semantic page paths retain their original page classification", () => {
|
||||
const paths: [string, string, string][] = [
|
||||
["/list/romance/", "/dramas/romance/", "list"],
|
||||
["/play/a%2Fb/123/", "/drama/a%2Fb/123/", "play"],
|
||||
@@ -279,8 +279,8 @@ test("semantic page paths retain their original analytics classification", () =>
|
||||
["/home-road/", "/return-home/", "home_road"],
|
||||
];
|
||||
for (const [legacy, current, key] of paths) {
|
||||
assert.deepEqual(trackerPage(current), trackerPage(legacy), current);
|
||||
assert.equal(trackerPage(current)[0], key, current);
|
||||
assert.deepEqual(pageIdentity(current), pageIdentity(legacy), current);
|
||||
assert.equal(pageIdentity(current)[0], key, current);
|
||||
}
|
||||
assert.equal(trackerPage("/unknown/")[0], "internal_other");
|
||||
assert.equal(pageIdentity("/unknown/")[0], "internal_other");
|
||||
});
|
||||
|
||||
@@ -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