commit d2ddb1c321a0ab4048b203ee6cb46d3666c6b610 Author: 谢宇宁 Date: Fri Oct 16 11:33:31 2026 +0700 [Ai] feat: 花魁短剧 Next.js 站点初始提交 - 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。 - 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。 - 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。 - 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。 - 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。 - 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。 Co-Authored-By: Claude Opus 5.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9837dcb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +node_modules +.next* +dist +dist.zip +dist.zip.sha256 +web_hkdj_h5.tar.gz +web_hkdj_h5.md5 +config/*.local.env +config/secrets +test-results +playwright-report +*.log +*.tsbuildinfo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a3a73e --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +.next*/ +*.tsbuildinfo +.env.local +.env.*.local +config/*.local.env +config/secrets/ +coverage/ +test-results/ +playwright-report/ +.DS_Store +*.log +dist/ +dist.zip +dist.zip.sha256 +web_hkdj_h5.tar.gz +web_hkdj_h5.md5 +/template/pages/sitemaps/*.xml +/template/pages/sitemap.xml +/data/sitemaps/*.xml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5b0c1b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + + +## 花魁短剧重构记忆 + +- 本仓库是花魁短剧原生 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` 中的公共实现。 +- 业务 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。 +- 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 协议并验证图片文件头,错误响应不能缓存为图片。 +- 提交遵循用户全局 AGENTS 规则:无前缀请求使用 `[Ai] : <中文主题>`,正文具体说明文件/模块/资源;仅确有 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。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c110de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +ARG APP_ENV=production +RUN npx tsx scripts/next-env.ts "$APP_ENV" build && npx tsx scripts/package-standalone.ts "$APP_ENV" && mv "dist/$APP_ENV" /release + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 +COPY --from=build --chown=node:node /release ./ +USER node +EXPOSE 3000 +CMD ["node", "start.mjs"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad8276e --- /dev/null +++ b/README.md @@ -0,0 +1,364 @@ +# 花魁短剧 · Next.js / TypeScript + +从 `jha-1045-hgdjnet-web` 迁移的独立 Next.js 16 项目。运行时仅需要 Node.js,不依赖 Go、原项目目录或原 Go 服务。保留内容浏览页面、原 URL、游客会话、播放器和统计交互(登录、账户与充值功能已移除,旧账户地址 301 到首页);PC/H5 界面按花魁短剧 Figma 设计更新。 + +## 启动 + +推荐 Node.js 22,最低 Node.js 20.9。 + +```bash +npm ci +npm run dev +``` + +默认开发地址为 `http://localhost:25121`,使用原项目开发 API。通过 `PORT=其他端口 npm run dev` 可以更换端口。开发及测试开发模式默认使用 Webpack 热更新;可用 `NEXT_DEV_BUNDLER=turbopack npm run dev` 选择 Turbopack。优化构建保持 Next 默认 Turbopack。 + +## PWA 全屏 + +通过 HTTPS 访问站点并安装到主屏幕后,从桌面图标启动。Android 优先采用 `fullscreen`,不支持时回退 `standalone`;iOS 使用独立窗口与透明状态栏,系统状态栏的显示由 Safari/iOS 决定。普通浏览器标签页保持浏览器本身的显示模式。 + +`src/app/manifest.ts` 提供安装清单,共用设置位于 `src/lib/pwa/config.ts`,安装入口为 `/`,不添加影响渠道或 canonical 的 query。全局 viewport 使用 `viewport-fit=cover`;安装窗口的页头、底部导航、弹窗与播放器按安全区布局,支持横竖屏。缺少原生 Fullscreen API 的安装窗口使用 xgplayer CSS 全屏。 + +`PwaProvider` 只在安全上下文注册 `/sw.js`,不弹安装提示;localhost 可用于开发验证,手机通过普通局域网 HTTP 访问时无法注册 Service Worker。Worker 源码为 TypeScript,开发启动与构建时自动编译,不拦截或离线缓存页面、接口、签名媒体和密钥;`/manifest.webmanifest` 与 `/sw.js` 绕过游客会话和 SSR Proxy,Worker 禁止 HTTP 缓存。standalone 包包含清单路由、Worker 和安装图标。 + +安装图标由现有花魁 SVG favicon 等比例生成,另提供保留安全留白的 maskable 图标及 180px Apple 图标。更新源图后运行: + +```bash +npm run assets:pwa +npm run assets:pwa:check +npm run test:pwa +``` + +`test:pwa` 验证实际 React 组件、Worker 注册、模拟安全区和播放器全屏进出;安装与系统状态栏还需在真实 Android/iOS 设备验证。已安装的旧桌面入口可能保留旧清单,更新后可移除旧入口再安装。 + +## 本地 Charles 抓包 + +从 `hgdj-official-website` 移植 `dev:charles`,在 Node 启动前设置原生环境代理和 Charles 根证书,让服务端上游 API 和媒体的原生 `fetch` 请求经过 Charles。沿用普通开发的配置优先级、React 热更新和默认端口 `25121`;sitemap 读取本地后端产物,不发起远程下载。 + +1. 使用 Node **24.5+**(或 **22.21+**);Node 22.17 不支持此代理开关。 +2. 打开 Charles,确认 HTTP Proxy 端口为 `8888`,在 Proxy → SSL Proxying Settings 中启用实际 `UPSTREAM_API_URL` 对应的域名及端口,例如 `api.example.com:443`。 +3. 在 Help → SSL Proxying → Save Charles Root Certificate 中导出 PEM,保存到 `~/.charles-ca.pem`。Charles 重置根证书后需要重新导出。 + +```bash +nvm use 24 +npm run dev:charles +# 可通过 PORT 或传递 Next 参数指定端口 +npm run dev:charles -- --port 3001 +``` + +默认代理为 `http://127.0.0.1:8888`,本机回环请求通过 `NO_PROXY` 排除,并保留既有排除项。自定义代理地址或证书: + +```bash +CHARLES_PROXY_URL=http://127.0.0.1:8889 \ +NODE_EXTRA_CA_CERTS="$HOME/certs/charles.pem" \ +npm run dev:charles +``` + +代理变量只作用于本次抓包进程;普通开发仍使用 `npm run dev`。这些 Node 启动变量不能仅放进 Next 的 `.env.local` 或 `config/development.local.env`。浏览器直接发出的请求需要浏览器/系统 Charles 代理;缓存命中时不会产生新的上游请求。 + +Node 原生代理说明:[Node.js HTTP 文档](https://nodejs.org/download/release/v24.17.0/docs/api/http.html#built-in-proxy-support)。 + +## 开发、测试、生产 + +| 环境 | 配置文件 | 开发启动 | 构建 | 运行构建 | 构建目录 | +| --- | --- | --- | --- | --- | --- | +| 开发 | `config/development.env` | `npm run dev` | `npm run build:dev` | `npm run start:dev` | `.next-dev` | +| 测试 | `config/test.env` | `npm run dev:test` | `npm run build:test` | `npm run start:test` | `.next-test` | +| 生产 | `config/production.env` | — | `npm run build` | `npm start` | `.next` | + +配置优先级:进程环境变量 > `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` 覆盖。构建不访问上游,启动时检查地址。 + +```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 页面。 + +图片 CDN 与 API 独立配置。上游 `config/info.config.imgCdn`、`imgAppUsCdn` 按顺序提供图片来源,`IMAGE_CDN_URL` 提供环境级备用来源。开发与测试共用 API 30331,该接口返回裸 `v3/image/...` 且 CDN 配置为空,因此两套环境均显式配置原站已验证可用的 CloudFront 图片线路;生产独立配置。修改环境配置后需重启服务。 + +公共图片组件统一解析相对路径,覆盖封面、横幅、背景和头像;相对路径不会随页面目录变化。图片保持原首 100 字节 XOR 协议,也兼容未加密的有效图片;校验文件头后才缓存,HTTP、证书、JSON 错误或超时会尝试配置的备用 CDN。完整外域 URL 保持原签名,只有属于已配置 CDN 的资源才允许切换相同对象路径;图片请求不向外域发送本站 Cookie/Authorization。 + +## 目录和渲染边界 + +| 目录 | 职责 | +| --- | --- | +| `src/app/` | 原生 App Router TSX 页面、Metadata 与 BFF/媒体 Route Handlers | +| `src/features/` | 短剧目录、播放、账号与内容页面组件 | +| `src/components/` | 页头、页尾、卡片、播放器、图片、弹窗、认证、支付、埋点共用组件 | +| `src/styles/` | 基础、公共组件、页面和 PWA 覆盖样式,通过 TSX 导入并由 Next 编译 | +| `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 | + +所有业务页面由 TSX 组件服务端渲染,交互使用 React hooks 和 Client Components。App Router 的 `generateMetadata` 输出完整首屏 SEO;JSON-LD 使用安全序列化的独立脚本。没有 HTML 模板运行时或注入整页 HTML 的适配层。 + +54 份业务样式统一在 `src/styles/{foundations,components,pages,overrides}`,由根 `layout.tsx` 顶层导入,播放器同时保留 npm `xgplayer` 样式。Next 输出带内容哈希的 CSS 文件,无需手工版本参数或 `/static/css` 链接。业务类名使用 `hk-` 命名空间和组件/业务名称;第三方播放器类名保留其包合同。页面样式通过零特异性的 `html[data-hk-page-styles]` 作用域隔离,基础和公共组件样式共享,PWA 安全区覆盖最后加载。`PagePresentation` 在客户端切页时同步作用域及页面 body class,同时保留 PWA、弹窗滚动锁等临时状态。 + +页面代码目录和公开 URL 按用户最新确认统一使用语义路径。`src/app` 使用 `dramas`、`drama`、`account` 等目录,旧有效页面 GET/HEAD 一次 301 到新地址,规则由 `src/lib/routes.ts` 管理,保留 slug 编码、原始 query 和 hash。站内链接、canonical、分页、JSON-LD、llms 和默认 App 下载入口同步新地址;同产品后端的 sitemap、canonical 与 SEO 推送生成新路径,Next 继续原字节服务后端 XML。API、媒体签名、图片 CDN、支付和外链保持各自合同。 + +| 场景 | 当前页面路径 | 301 兼容旧路径 | +| --- | --- | --- | +| 短剧浏览/播放 | `/dramas/`、`/drama/:slug/` | `/list/`、`/play/:slug/` | +| 剧集浏览/播放 | `/series/`、`/series/watch/:slug/` | `/series-play/:slug/` | +| 精选/最新/榜单 | `/featured/`、`/latest/`、`/rankings/` | `/popular-recommend/`、`/recently-added/`、`/rank/` | +| 专题列表/详情 | `/collections/`、`/collection/:slug/` | `/topic/`、`/topic-detail/:slug/` | +| 推荐栏目 | `/curated/dramas/:id/`、`/curated/series/:id/` | `/more-list/:id/`、`/more-series-list/:id/` | +| 移动搜索/导航/下载 | `/discover/search/`、`/navigation/`、`/app/download/` | `/search-mobile/`、`/nav/`、`/dh/`、`/download-app/` | + +已移除的账户页面 `/account/**` 及旧地址 `/login/`、`/register/`、`/profile/`、`/history/`、`/favorites/`、`/purchased/`、`/setting/`、`/settings/`、`/recharge/` 的 GET/HEAD 一律 301 到首页 `/`,规则为 `src/lib/routes.ts` 的 `REMOVED_ACCOUNT_PATHS`。 + +分页和筛选保留原参数结构;搜索结果、标签、关于、帮助和法律声明沿用原路径。业务页面组件使用 `HomePage`、`BrowsePages`、`DiscoveryPages`、`DramaWatchPage`、`SeriesWatchPage`、`SiteNavigationPage`,服务端播放模块为 `src/server/pages/playback.ts`。公共组件封装、源码 CSS 的 TSX 导入和类名保持。 + +`src/app/` 页面目录按上表同名迁移,原动态参数目录保留;`nav`/`dh` 合并到同一个入口。其他代码文件的新旧对应如下: + +| 所在目录 | 原文件 | 当前文件 | +| --- | --- | --- | +| `src/features/catalog/` | `IndexPage.tsx` | `HomePage.tsx` | +| `src/features/catalog/` | `ListingPages.tsx` | `BrowsePages.tsx` | +| `src/features/catalog/` | `SearchPages.tsx` | `DiscoveryPages.tsx` | +| `src/features/playback/` | `PlayPage.tsx` | `DramaWatchPage.tsx` | +| `src/features/playback/` | `SeriesPlayPage.tsx` | `SeriesWatchPage.tsx` | +| `src/features/content/` | `NavigationPage.tsx` | `SiteNavigationPage.tsx` | +| `src/server/pages/` | `play.ts` | `playback.ts` | + +后台 `hkdj-web-admin` 的导航分类 URL 支持搜索、选择当前固定页面入口及直接输入具体路径或外链;历史自定义值保留,提交仍使用原 `urlValue` 字符串。完整清单源自实际路由注册,执行 `npm run routes:export -- --admin ../hkdj-web-admin` 同步本仓库 `src/lib/page-route-catalog.json`、下方对照和后台 `src/constants/hkdj-web-routes.json`;使用 `npm run routes:check -- --admin ../hkdj-web-admin` 验证一致性。动态详情、筛选和分页需要真实参数,因此只列入对照,不将未填写参数的模板作为后台可选链接。 + +本次路径更新涉及 Web、后台管理端和 Go 后端三个仓库。部署后端并由原 sitemap 任务重新生成 XML 后,文件内链接才会更新;已有 XML 仍按原字节返回,其中的旧页面地址通过 301 兼容。 + + +以下清单由 `npm run routes:export` 从实际路由注册生成,包含全部页面、筛选和分页模式。含 `:参数` 的路径需要填写真实值;后台导航下拉只展示可直接访问的固定入口,详情页和外链可手动输入。 + +| 页面 | 原路径 | 当前路径 | +| --- | --- | --- | +| 首页 | `/` | `/` | +| 移动端搜索 | `/search-mobile/` | `/discover/search/` | +| 搜索结果 | `/search/` | `/search/` | +| 搜索结果 | `/search/:q/` | `/search/:q/` | +| 搜索结果 | `/search/:q/page/:page/` | `/search/:q/page/:page/` | +| 关于我们 | `/about/` | `/about/` | +| 帮助中心 | `/help/` | `/help/` | +| 服务条款 | `/terms/` | `/terms/` | +| 隐私政策 | `/privacy/` | `/privacy/` | +| 2257 声明 | `/2257/` | `/2257/` | +| 版权声明 | `/dmca/` | `/dmca/` | +| 下载 App | `/download-app/` | `/app/download/` | +| 回家的路 | `/home-road/` | `/return-home/` | +| 官方导航 | `/nav/`、`/dh/` | `/navigation/` | +| 短剧列表 | `/list/` | `/dramas/` | +| 短剧列表 | `/list/:theme/` | `/dramas/:theme/` | +| 短剧列表 | `/list/:theme/:updateStatus/:sortType/` | `/dramas/:theme/:updateStatus/:sortType/` | +| 短剧列表 | `/list/:theme/:updateStatus/:sortType/page/:page/` | `/dramas/:theme/:updateStatus/:sortType/page/:page/` | +| 短剧推荐栏目 | `/more-list/:configId/:timeRange/:theme/page/:page/` | `/curated/dramas/:configId/:timeRange/:theme/page/:page/` | +| 短剧推荐栏目 | `/more-list/:configId/:timeRange/:theme/` | `/curated/dramas/:configId/:timeRange/:theme/` | +| 短剧推荐栏目 | `/more-list/:configId/page/:page/` | `/curated/dramas/:configId/page/:page/` | +| 短剧推荐栏目 | `/more-list/:configId/` | `/curated/dramas/:configId/` | +| 剧集推荐栏目 | `/more-series-list/:configId/:timeRange/:theme/page/:page/` | `/curated/series/:configId/:timeRange/:theme/page/:page/` | +| 剧集推荐栏目 | `/more-series-list/:configId/:timeRange/:theme/` | `/curated/series/:configId/:timeRange/:theme/` | +| 剧集推荐栏目 | `/more-series-list/:configId/page/:page/` | `/curated/series/:configId/page/:page/` | +| 剧集推荐栏目 | `/more-series-list/:configId/` | `/curated/series/:configId/` | +| 剧集列表 | `/series/` | `/series/` | +| 剧集列表 | `/series/:media/:topic/:sortType/` | `/series/:media/:topic/:sortType/` | +| 剧集列表 | `/series/:media/:topic/:sortType/page/:page/` | `/series/:media/:topic/:sortType/page/:page/` | +| 专题列表 | `/topic/` | `/collections/` | +| 专题列表 | `/topic/page/:page/` | `/collections/page/:page/` | +| 专题列表 | `/topic/sort/:sortType/` | `/collections/sort/:sortType/` | +| 专题列表 | `/topic/sort/:sortType/page/:page/` | `/collections/sort/:sortType/page/:page/` | +| 专题详情 | `/topic-detail/:slug/sort/:sortType/page/:page/` | `/collection/:slug/sort/:sortType/page/:page/` | +| 专题详情 | `/topic-detail/:slug/sort/:sortType/` | `/collection/:slug/sort/:sortType/` | +| 专题详情 | `/topic-detail/:slug/page/:page/` | `/collection/:slug/page/:page/` | +| 专题详情 | `/topic-detail/:slug/` | `/collection/:slug/` | +| 排行榜 | `/rank/` | `/rankings/` | +| 排行榜 | `/rank/page/:page/` | `/rankings/page/:page/` | +| 排行榜 | `/rank/:sortOrder/` | `/rankings/:sortOrder/` | +| 排行榜 | `/rank/:sortOrder/page/:page/` | `/rankings/:sortOrder/page/:page/` | +| 标签详情 | `/tags/:slug/sort/:sortType/page/:page/` | `/tags/:slug/sort/:sortType/page/:page/` | +| 标签详情 | `/tags/:slug/sort/:sortType/` | `/tags/:slug/sort/:sortType/` | +| 标签详情 | `/tags/:slug/page/:page/` | `/tags/:slug/page/:page/` | +| 标签详情 | `/tags/:slug/` | `/tags/:slug/` | +| 最新更新 | `/recently-added/` | `/latest/` | +| 最新更新 | `/recently-added/page/:page/` | `/latest/page/:page/` | +| 热门推荐 | `/popular-recommend/` | `/featured/` | +| 热门推荐 | `/popular-recommend/page/:page/` | `/featured/page/:page/` | +| 热门推荐 | `/popular-recommend/:rankType/` | `/featured/:rankType/` | +| 热门推荐 | `/popular-recommend/:rankType/page/:page/` | `/featured/:rankType/page/:page/` | +| 短剧播放 | `/play/:slug/` | `/drama/:slug/` | +| 短剧播放 | `/play/:slug/:ep/` | `/drama/:slug/:ep/` | +| 短剧播放 | `/play/:slug/recommendations/page/:guessPage/` | `/drama/:slug/recommendations/page/:guessPage/` | +| 短剧播放 | `/play/:slug/:ep/recommendations/page/:guessPage/` | `/drama/:slug/:ep/recommendations/page/:guessPage/` | +| 剧集播放 | `/series-play/:slug/` | `/series/watch/:slug/` | +| 剧集播放 | `/series-play/:slug/recommendations/page/:guessPage/` | `/series/watch/:slug/recommendations/page/:guessPage/` | +| 意见反馈 | `/feedback/` | `/feedback/` | + + + +SSR 在 Proxy 中完成上游数据、游客恢复和 Cookie 写回,再交给 React 渲染,避免首屏流式响应开始后丢失 Cookie。小数据使用压缩内部请求头,较大数据暂存于操作系统临时目录下的私有目录,绑定 URL/Cookie、权限 0700/0600、60 秒过期。适用于当前单 Node 实例或同一文件系统的 standalone 部署;若未来改为分布式 Proxy 与渲染节点,需要更换交接存储。 + +公共配置可短期复用;登录用户内容仅在单次请求内复用,页面响应使用 `private, no-store`。必要内容接口临时不可用返回 503、`Retry-After` 和 noindex;真实缺失的实体及越界分页仍返回 404。 + +播放器通过 `package.json` 管理 `xgplayer`、`xgplayer-hls`,按需加载官方模块和样式,支持 MP4/HLS、加密密钥、预告切换、章节播放、全屏、解锁、评论及播放埋点。原 DPlayer、全局 Hls 和浏览器业务 JS 已删除。加密图片、二维码分别封装为 TSX 组件,二维码使用 npm `qrcode`;占位图 Logo 透明度保持 0.2。 + +## SEO 保留范围 + +- 上游站点/导航/栏目/内容 SEO、筛选维度和分页独立 TDK。 +- 原收录白名单、私有页面与章节 noindex、无效实体和越界分页真实 404。 +- 永久域名优先,代理协议/Host 回退;slug canonical、尾斜杠、去查询参数。 +- OG/Twitter、Google/Yandex/Bing 验证、可配置百度验证和 IndexNow 文件。 +- Organization、WebSite、WebPage、Breadcrumb、ItemList、FAQ、TVSeries/TVEpisode、VideoObject 等原 JSON-LD 条件和真实元数据。 +- robots.txt、llms.txt、sitemap 索引/分片、纯渠道首页 302、下载地址 302。 + +上游 TDK、导航、公告和 JSON-LD 中的完整旧品牌词「黄果短剧」统一显示为「花魁短剧」。永久域名、canonical、真实联系地址、普通影片名称、验证值和 SDK 配置保留原值。 + +抓取文件遵循原 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 地址。 + +**sitemap 由原后端生成,Next 只提供文件服务。** 开发环境的 `SITEMAP_DIR` 默认 `./template/pages/sitemaps`;测试和生产默认 `../template/pages/sitemaps`,均相对 Node 工作目录解析。测试 Node 运行于 `/server/hkdj_net/h5/next`,后端持续写入 `/server/hkdj_net/h5/template/pages/sitemaps`;正式环境同样按 `h5/next` 与 `h5/template/pages/sitemaps` 的父子目录关系读取,目录不同可通过对应 `.local.env` 或进程变量覆盖。`/sitemap.xml` 先读取该目录的 `sitemap.xml`,再读取 `./template/pages/sitemap.xml`;`/sitemaps/:name` 仅读取该目录中通过原文件名校验的分片。成功响应保留原 XML 字节、loc、priority、lastmod 和文件响应语义,不改域名、不重写新旧路径,也不从分片生成索引。缺失索引、非法或缺失分片均走原站完整 404 页面及其共享数据加载,不生成静态页面 sitemap、空 XML 或远程 API 兜底。 + +成功的抓取文件通过原生 Route Handler 输出,保留 `text/xml; charset=utf-8`、Last-Modified、Content-Length、Accept-Ranges、条件请求和单段/多段 Range;HEAD 不返回 body。Proxy 提前将缺失文件、非法分片和不支持的方法交给完整 TSX 404;导航模式按 Go `DHInit` 不开放这些路由。Next 应用压缩关闭以保持原 Go 文件字节长度与 Range 语义,公网代理可沿用原有传输压缩配置。 + +新旧服务应读取同一份持续更新的后端产物;文件来源目录不要放进前端发布时会清空的构建目录。也可通过 `SITEMAP_DIR=/绝对路径/template/pages/sitemaps` 指向后端目录,或使用保留该关系的目录挂载。公网代理仍需正确转发 `Host`、`X-Forwarded-Host` 与 `X-Forwarded-Proto`;不提供额外公网 origin 开关或 IP 限制来改变原站地址规则。 + +本地需要真实 XML 时,显式执行只读同步命令;页面请求、开发启动和构建不会连接 SSH: + +```bash +npm run sitemap:sync +``` + +该命令始终按开发配置读取,优先级为进程变量 > `config/development.local.env` > `config/development.env`;已设置 `APP_ENV=test/production` 时拒绝执行,避免继承服务端目录。开发默认 SSH 来源为 `root@172.104.181.99:22889` 的 `/server/hkdj_net/h5/template/pages/sitemaps`,可覆盖 `SITEMAP_SYNC_HOST`、`SITEMAP_SYNC_PORT`、`SITEMAP_SYNC_DIR` 与本地 `SITEMAP_DIR`。需提前配置 SSH 密钥及可信 known_hosts;命令不交互输入密码,也不关闭主机校验。仅下载原索引与其引用的有效分片,校验 XML 和索引稳定性后保留原字节、域名、路径和修改时间落盘,最后替换索引;不生成 sitemap、不写远端、不删除未引用的本地文件。同步 XML 及旧本地 `data/sitemaps` 兼容文件均忽略提交;页面 trace 排除 XML,最终发布包还会定向清理 Proxy trace 带入的副本。 + +`SEO_INDEXNOW_KEY` 在三套环境配置中使用同一份重新生成的 Key,并与代码默认值一致;站点根路径 `/{key}.txt` 自动返回该 Key。后端推送进程的 `HKDJNET_INDEXNOW_KEY` 需使用相同值。百度可通过 `SEO_BAIDU_SITE_VERIFICATION`、`SEO_BAIDU_HTML_FILE` 配置,验证文件放入 `config/seo/`。搜索引擎实际收录取决于真实域名、后端内容和站长平台状态。 + +## Figma 与界面资源 + +设计来源:[花魁短剧 WEB / H5](https://www.figma.com/design/1vUq4wf5xOs8ZOlmt26Q3O/?node-id=0-1)。页头、页尾、徽标、favicon 和匹配图标均通过 Figma REST API 导出;资源保存在本地,运行和构建不依赖 Figma。SVG 保存 REST 返回的完整原始字节与 Figma 原始尺寸,显示尺寸由 CSS 或 HTML 的宽高属性决定。导出节点、尺寸和校验信息见 `public/static/brand/manifest.json` 与 `scripts/figma-icon-manifest.json`。没有精确对应节点的原有功能图标保留,清单记录原因。 + +导航、登录及 SEO 使用 `/static/brand/huakui-20261013/` 下的版本化品牌资源,路径统一在 `src/lib/brand-assets.ts` 管理,避免旧站同名静态资源和 CDN 缓存继续显示旧 logo。`assets:sync` 同步生成这些资源并写入校验清单;原路径保留兼容,PWA 图标也从新版品牌源图生成。 + +```bash +# 仅维护设计资源时需要本机 FIGMA_TOKEN;不将 token 写入仓库。 +npm run assets:sync +# 离线检查资源完整性,不访问 Figma。 +npm run assets:check +``` + +首页、列表、播放器和静态页面分别适配 1440px 桌面与 375px 手机设计。H5 底部导航按设计页面显示。影片、广告、余额、套餐、社交地址和二维码继续使用真实上游配置,不将设计示例数据当作业务内容。 + +设置页的头像、昵称、简介、隐私模式和密码使用现有上传与资料接口。通知偏好、邮箱修改及注销没有对应上游能力,保留设计位置并明确禁用,避免显示保存成功却未生效。 + +下载入口共用桌面下载弹窗与 H5 安装引导;iOS 支持 App Store、轻量版两个页签,Android 展示安装步骤。以下服务端变量可分别配置真实安装地址,三套环境默认均留空: + +```dotenv +APP_IOS_STORE_URL= +APP_IOS_PROFILE_URL= +APP_ANDROID_APK_URL= +APP_DEEP_LINK_URL= +``` + +平台地址未配置时,按钮显示“前往下载页”,沿用上游 `AppAddr` 或 `/app/download/` 跳转;未配置 App 深链时隐藏打开 App 入口。链接保留原始跳转地址与导航埋点,不生成设计示例中的安装包、描述文件或 App Store 地址。 + +## 接口和媒体 + +保留原 BFF 路由、别名、参数校验、业务响应壳、上传和 CSV 导出。`Cookie`、cookie token 优先的 `Authorization`、客户端 IP、浏览器 UA、`X-User-Agent`、`X-SDK-Common` 透传;过滤 hop-by-hop 和内部控制头。多个 `Set-Cookie` 独立回传。埋点上下文按原白名单校验并保留 SDK 原字段。 + +公共配置隔离过期登录凭证;个人资料刷新与游客恢复写回当前请求,避免首屏重复建立会话。正式账号失效和内容允许降级游客的规则分别保留。 + +`/h5/m3u8/*`、`/enkey`、`/newenkey` 保留查询签名、密钥和媒体响应,媒体请求不强制携带登录凭证。`/enkey` 上游不可用时支持 `M3U8_KEY_FILE` 本地兜底;原本地密钥已复制到忽略提交的 `config/secrets/m3u8.key`。部署时提供同一文件或挂载其路径,文件不要放入 `public`。 + +## 验证 + +```bash +npm run check +npm run assets:check +npm run build:dev +npm run build:test +npm run build +npm run test:catalog +npm run test:popups +npm run test:playback +npm run test:ssr +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、透传和并发隔离,不访问真实业务接口。 + +全站界面截图默认输出 `/tmp/hkdj-figma/implementation`,浏览器基础检查输出 `/tmp/hkdj-browser-smoke`。完整真实内容回归需要原上游剧集、专题与标签数据;空环境可设置 `UI_ALLOW_EMPTY_CONTENT=1`,缺失实体仍必须返回 404 与 noindex。 + +原 Go 输出夹具保留为 SEO、链接、卡片、预览/可播/锁定场景的迁移参照。签名时间、票据和后端实时热度属于动态值。Next.js 会先规范化连续斜杠和 `..` 路径,此类异常 URL 与 Gin 可能存在差异,正常站内链接、canonical 和原筛选/分页规则保留。 + +## 独立部署 + +```bash +npm run build +npm run bundle +cd dist/production +UPSTREAM_API_URL=https://your-api.example node start.mjs +``` + +`bundle` 包含 standalone 服务、完整静态资源、对应环境公共配置,以及 robots 和站点验证文件,无需在部署机器安装依赖。后端生成的 sitemap XML(包括开发同步副本)不打入前端包;发布程序必须保留并挂载服务器持续更新的源目录。生产机可以通过进程变量或 `config/production.local.env` 提供配置;sitemap 和密钥可外部挂载。`SITEMAP_DIR` 指向的外部目录不会被构建或打包扫描、生成或覆盖。测试包使用 `npm run bundle:test`。不要在构建机器本地覆盖文件中放入需要打包的生产凭证。 + +Next 16.4 当前仍可能通过 Node Proxy 的 `middleware.js.nft.json` 将开发 XML 复制到原始 `.next*/standalone`,页面级 tracing 排除不能保证该目录无 XML。发布应使用 `npm run bundle` / `bundle:test` 生成的 `dist`:打包脚本只在复制后的独立目录清理 `template/pages/sitemaps`、`data/sitemaps` 内的合法 sitemap 文件及备用 `template/pages/sitemap.xml`。清理前逐层检查父目录,不接受符号链接或异常文件;只 unlink 已确认的文件,不递归删目录,也不跟随后端挂载。robots、密钥及未知文件保留。 + +也提供 Dockerfile,构建参数 `APP_ENV=production|test|development` 选择环境,启动时传入 `UPSTREAM_API_URL`,容器默认端口 3000。 + +## 测试服上传 + +参考 `naixin-web-nuxt` 的直连测试发布流程,入口为仓库根目录 `deploy_test.sh`: + +```bash +# 只核对流程和目标,不构建、不连接服务器 +DEPLOY_DRY_RUN=1 npm run deploy:test + +# 检查、构建、打包、上传并更新测试服 +npm run deploy:test +# 等价于 ./deploy_test.sh +``` + +默认目标为 `root@172.104.181.99`,SSH 端口 `22889`,上传目录 `/server/hkdj_net/h5/`。可通过 `DEPLOY_HOST`、`DEPLOY_SSH_PORT`、`DEPLOY_DIR` 临时覆盖。发布后默认检查公网 `https://hkdjneth5.remoces.com`,可通过 `DEPLOY_CHECK_URL` 覆盖为另一个 HTTP(S) 站点根地址;dry-run 仅显示该地址,不访问域名。需要本机已安装依赖、zip/unzip、SSH/SCP,并提供忽略提交的 `config/secrets/m3u8.key`。 + +脚本依次执行 `npm run check`、`build:test`、`bundle:test`,生成 `dist.zip` 和 `dist.zip.sha256`,连同 `deploy/up_next_h5.sh` 上传。包保留配置、页面和静态资源,排除 `node_modules` 与本地环境覆盖文件;服务器在暂存目录按 lockfile 执行 `npm ci --omit=dev --include=optional --ignore-scripts`,为 Linux 安装对应架构的运行依赖。服务器需要 Node.js **20.9+**(推荐 22)、npm、可访问 npm 包源,以及 Bash、unzip、sha256sum、curl 和 Supervisor。运行时 `dotenv` 属于正式依赖。 + +服务器脚本校验包后更新 `/server/hkdj_net/h5/next/`,上一版留在 `next.prev/`,通过独立 Supervisor 程序 `hkdj_next_h5` 运行 `node start.mjs`。保留服务器的 `next/config/test.local.env`、密钥和后端生成的 sitemap:运行目录内默认或自定义目录中的原 XML 优先于包内文件,第二索引 `template/pages/sitemap.xml` 和四个可选 robots 覆盖文件同样保留;新目录不存在时可迁入旧版本 `data/sitemaps`,已有空目录也视为权威,不恢复旧 XML。已配置的外置目录不复制、不清空、不覆盖,默认目录的符号链接及其运行目录内目标也保留。首次部署可读取原 Go 目录的 XML;后台持续写原目录时应配置外置 `SITEMAP_DIR`,避免只使用一次复制的快照。密钥首次可复用原目录 `config/secrets/m3u8.key` 或 `conf/m3u8.key`。后续私密配置放服务器的 `next/config/test.local.env`,不要放入上传包。启动与健康检查失败会返回非零状态,切换后的失败会尝试恢复上一版。本地包及失败时的服务器上传包留存供排查。 + +**测试站 Nginx 必须代理到 `127.0.0.1:30334`**(若服务器覆盖 `PORT`,使用实际端口),包括页面、`/_next/`、`/static/`、`/api/` 和媒体路径;上传脚本不修改 Nginx 配置。此前 `/etc/nginx/conf.d/hkdj_net-h5.conf` 将该域名代理到 API 端口 `30331`,导致首页和 `/healthz` 都返回 `{"code":1000,"msg":""}`,本机服务健康也无法证明域名已切流。 + +服务器健康检查覆盖 `/healthz`、首页及 `/enkey`;随后本地脚本通过公网域名检查 `/healthz` 必须为 HTTP 200 且 JSON `status=ok`、`environment=test`,首页必须为 HTTP 200、`text/html` 并含 Next 静态资源标记。公网检查最多重试 3 次,每次请求超时 4 秒,失败会明确提示“服务器服务已更新,但域名检查失败”,返回非零状态;检查不通过不会误报完整发布成功,也不会自动回滚已经健康的新服务。完成后仍应通过测试站域名检查 PC/H5 页面和真实媒体链路。 + +## 正式服上传 + +使用既有跳板机 `running.sh` 链路: + +```bash +npm run deploy:prod -- --dry-run # 只核对配置,不构建、不连接 +npm run deploy:prod -- --package-only # 检查、构建、生成运行包与 MD5,不上传 +npm run deploy:prod # 构建、上传并执行正式服 running.sh +``` + +脚本执行 `check` → `build` → `bundle` → `bundle:linux`,生成根目录 `web_hkdj_h5.tar.gz` 和 `web_hkdj_h5.md5`(仅一行 MD5 hash)。tar 根部直接包含 `start.mjs`、`server.js`、`.next/`(含静态资源)、`public/`(含 PWA Worker/图标)、`config/production.env`、媒体密钥、必要数据及 `node_modules/`,不套 `dist/production/` 外层目录。 + +本机最低 Node.js 20.9、npm **10.9.2**,推荐 Node.js 22。正式包保留 Next standalone 已跟踪并裁剪的 `node_modules`;只在临时目录安装与 Linux 目标匹配的 Sharp/libvips 两个原生包,以替换 macOS 原生文件,版本、下载地址和 integrity 均取仓库 lockfile。最终输出为 `dist/production-linux-x64-glibc/`,包含裁剪后的 Next/React/Sharp 运行文件、`dotenv`、`@swc/helpers` 和已跟踪的 WASM 回退,不携带完整 Next 安装树、SWC 原生编译器、Playwright 或未跟踪的客户端依赖。包内无需执行 `npm install`,直接 `node start.mjs`;临时安装树不会进入发布包。 + +默认目标为 Linux **x64/glibc**;可设置 `DEPLOY_TARGET_ARCH=x64|arm64`、`DEPLOY_TARGET_LIBC=glibc|musl`。仓库 package/lock、本机依赖及 `dist/production/` 保持独立。运行包排除 `.env*`、`*.local.env`、日志及 macOS 元数据,密钥随包提供;生产私密覆盖配置需在服务器提供,并由服务器发布脚本保留。 + +默认上传及触发命令严格对应两种端口约定: + +```bash +scp -P 22889 web_hkdj_h5.tar.gz web_hkdj_h5.md5 \ + server@116.212.120.2:/home/server/web_hkdj/h5/nuxt/ +ssh -p 22 server@116.212.120.2 /home/server/web_hkdj/h5/nuxt/running.sh +``` + +可通过 `DEPLOY_JUMP_HOST`、`DEPLOY_JUMP_DIR`、`DEPLOY_SCP_PORT`、`DEPLOY_SSH_PORT` 覆盖连接参数。跳板机脚本只能按完整路径执行,不带参数或额外 shell 命令;脚本和正式机 `up_node_h5.sh` 由既有部署链路管理。同仓库使用发布锁防止并发构建/上传覆盖同名包,构建后显示压缩包体积。任何构建、上传或远端执行失败均返回非零,本地包留存;正式脚本输出保存到 `dist/production-running.*`,即使远端返回 0,出现 `BACKOFF`、`FATAL`、`ERROR` 或 `spawn error` 也会判定失败。端口和健康检查由服务器发布脚本负责。正式站 Nginx 应将页面及 Next/BFF/媒体资源代理至 `26935`,公网域名需在发布后另行验证。 + +正式机 `up_node_h5.sh` 必须完整复制隐藏目录,例如 `cp -rf "$STAGING/." "$APP_DIR/"`;使用 `$STAGING/*` 会漏掉必要的 `.next/`。覆盖前保留 `.local.env`、媒体密钥、`template/pages/sitemaps` 和 `template/pages/sitemap.xml`,服务器已有 XML 优先于包内同名文件;外置 `SITEMAP_DIR` 或挂载目录不得随版本目录删除。复制后应检查 `.next/BUILD_ID` 和 `start.mjs`,启动一次并检查 Supervisor 状态,复制或启动失败须返回非零状态。该远端脚本由既有链路管理,不包含在本仓库。 + +正式 Next 运行目录使用 `/server/web_hkdj/h5/next/`,Supervisor 的 `directory` 与更新脚本的 `APP_DIR` 必须一致;更新脚本先创建目录,再检查 `cd` 是否成功。不要直接运行在旧 Go 的目录里:Next 会优先读取根部遗留的 `static/`,遮盖新包的 `public/static/`。`start.mjs` 检测到这种混用会拒绝启动;迁移时保留服务器的本地环境覆盖和 sitemap 数据。 diff --git a/config/development.env b/config/development.env new file mode 100644 index 0000000..44d1914 --- /dev/null +++ b/config/development.env @@ -0,0 +1,26 @@ +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. +# 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 +# Explicit development-only SSH pull; request handlers never contact this host. +SITEMAP_SYNC_HOST=root@172.104.181.99 +SITEMAP_SYNC_PORT=22889 +SITEMAP_SYNC_DIR=/server/hkdj_net/h5/template/pages/sitemaps +M3U8_KEY_FILE=./config/secrets/m3u8.key +SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee +SEO_BAIDU_SITE_VERIFICATION= +SEO_BAIDU_HTML_FILE= +APP_IOS_STORE_URL= +APP_IOS_PROFILE_URL= +APP_ANDROID_APK_URL= +APP_DEEP_LINK_URL= diff --git a/config/production.env b/config/production.env new file mode 100644 index 0000000..6be786f --- /dev/null +++ b/config/production.env @@ -0,0 +1,21 @@ +APP_ENV=production +# Set your production API in config/production.local.env or deployment environment. +UPSTREAM_API_URL=http://127.0.0.1:26931 +# Optional fallback when config/info does not supply an image CDN. +# 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=26935 +# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps. +SITEMAP_DIR=../template/pages/sitemaps +M3U8_KEY_FILE=./config/secrets/m3u8.key +SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee +SEO_BAIDU_SITE_VERIFICATION= +SEO_BAIDU_HTML_FILE= +APP_IOS_STORE_URL= +APP_IOS_PROFILE_URL= +APP_ANDROID_APK_URL= +APP_DEEP_LINK_URL= diff --git a/config/test.env b/config/test.env new file mode 100644 index 0000000..da94c9a --- /dev/null +++ b/config/test.env @@ -0,0 +1,21 @@ +APP_ENV=test +# Test API runs on the same server, using the development API port. +UPSTREAM_API_URL=http://127.0.0.1:30331 +# The API on 30331 does not supply an image CDN. +# 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=30334 +# Node runs in h5/next; the backend generates files in h5/template/pages/sitemaps. +SITEMAP_DIR=../template/pages/sitemaps +M3U8_KEY_FILE=./config/secrets/m3u8.key +SEO_INDEXNOW_KEY=d8e5b742d13809c10865fc3b29de24ee +SEO_BAIDU_SITE_VERIFICATION= +SEO_BAIDU_HTML_FILE= +APP_IOS_STORE_URL= +APP_IOS_PROFILE_URL= +APP_ANDROID_APK_URL= +APP_DEEP_LINK_URL= diff --git a/data/sitemaps/.gitkeep b/data/sitemaps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/up_next_h5.sh b/deploy/up_next_h5.sh new file mode 100755 index 0000000..229db5b --- /dev/null +++ b/deploy/up_next_h5.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# 测试服专用:dist.zip 为 dist/test 内容(不含 node_modules),与校验文件放在本脚本旁。 +# 安装到 next/,保留原 Go 文件;仅管理独立的 hkdj_next_h5 Supervisor 程序。 +set -Eeuo pipefail + +DIR=$(cd "$(dirname "$0")" && pwd) +PACKAGE="$DIR/dist.zip" +CHECKSUM="$DIR/dist.zip.sha256" +CURRENT="$DIR/next" +PREVIOUS="$DIR/next.prev" +PROGRAM=hkdj_next_h5 +SUP_CONF_DIR=${SUP_CONF_DIR:-/etc/supervisor/conf.d} +SUP_LOG_DIR=${SUP_LOG_DIR:-/var/log/supervisor} +CONF="$SUP_CONF_DIR/$PROGRAM.conf" +LOCK="$DIR/.$PROGRAM.lock" +STAMP=$(date +%Y%m%d%H%M%S)-$$ +STAGING= UPLOAD_COPY= HEALTH_FILE= CONF_BACKUP= NEW_CONF= +LOCKED=0 SWITCHED=0 OLD_MOVED=0 INSTALLED=0 HAD_CONF=0 CONF_CHANGED=0 WAS_RUNNING=0 + +fail() { printf '发布失败:%s\n' "$*" >&2; exit 1; } +rollback() { + echo '检查失败,恢复上一版应用与 Supervisor 配置。' >&2 + supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true + if [ "$INSTALLED" = 1 ] && [ -d "$CURRENT" ]; then + mv "$CURRENT" "$DIR/.${PROGRAM}.failed.$STAMP" || return 1 + fi + if [ "$OLD_MOVED" = 1 ]; then mv "$PREVIOUS" "$CURRENT" || return 1; fi + if [ "$CONF_CHANGED" = 1 ]; then + if [ "$HAD_CONF" = 1 ]; then cp -p "$CONF_BACKUP" "$CONF" || return 1 + else rm -f "$CONF" || return 1; fi + fi + supervisorctl reread >/dev/null && supervisorctl update >/dev/null || return 1 + if [ "$WAS_RUNNING" = 1 ]; then + supervisorctl restart "$PROGRAM" >/dev/null || return 1 + else supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true; fi + echo '上一版已恢复;失败版本和上传包已保留。' >&2 +} +finish() { + local rc=$? + trap - EXIT + if [ "$rc" -ne 0 ]; then + if [ "$SWITCHED" = 1 ]; then + rollback || { + echo "自动回滚未完成,请检查 ${CURRENT}、${PREVIOUS} 和 ${CONF_BACKUP}。" >&2 + printf '手动恢复:supervisorctl stop %s; mv %q %q; mv %q %q\n' \ + "$PROGRAM" "$CURRENT" "$DIR/.${PROGRAM}.failed.manual.$STAMP" "$PREVIOUS" "$CURRENT" >&2 + [ -z "$CONF_BACKUP" ] || printf 'cp -p %q %q\n' "$CONF_BACKUP" "$CONF" >&2 + echo "然后 supervisorctl reread && supervisorctl update && supervisorctl start $PROGRAM" >&2 + } + fi + echo "发布未完成,保留 $PACKAGE 便于排查。" >&2 + fi + [ -z "$STAGING" ] || rm -rf "$STAGING" + [ -z "$UPLOAD_COPY" ] || rm -rf "$UPLOAD_COPY" + [ -z "$HEALTH_FILE" ] || rm -f "$HEALTH_FILE" + [ -z "$NEW_CONF" ] || rm -f "$NEW_CONF" + [ "$LOCKED" = 0 ] || rmdir "$LOCK" 2>/dev/null || true + exit "$rc" +} +trap finish EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +for command_name in sha256sum unzip supervisorctl curl mktemp; do + command -v "$command_name" >/dev/null || fail "缺少 $command_name" +done +mkdir "$LOCK" 2>/dev/null || fail "另一次发布正在执行($LOCK);确认无发布进程后才能移除此锁" +LOCKED=1 +[ -f "$PACKAGE" ] && [ -f "$CHECKSUM" ] || fail '缺少 dist.zip 或 dist.zip.sha256' +# SCP 本身不持有发布锁:先冻结上传文件,后续校验与解包始终使用同一份快照。 +UPLOAD_COPY=$(mktemp -d "$DIR/.${PROGRAM}.upload.XXXXXX") +cp "$PACKAGE" "$UPLOAD_COPY/dist.zip" +cp "$CHECKSUM" "$UPLOAD_COPY/dist.zip.sha256" +EXPECTED=$(awk 'NR == 1 { print tolower($1) }' "$UPLOAD_COPY/dist.zip.sha256") +[[ "$EXPECTED" =~ ^[[:xdigit:]]{64}$ ]] || fail 'SHA-256 校验文件格式无效' +ACTUAL=$(sha256sum "$UPLOAD_COPY/dist.zip" | awk '{print $1}') +[ "$EXPECTED" = "$ACTUAL" ] || fail 'dist.zip SHA-256 不匹配' + +node_ok() { + "$1" -e 'const [a,b]=process.versions.node.split(".").map(Number); process.exit(a>20 || a===20 && b>=9 ? 0 : 1)' 2>/dev/null +} +candidates=("${NODE_BIN:-}" /usr/local/bin/node /usr/bin/node /opt/node/bin/node) +for candidate in /usr/local/node*/bin/node /opt/node*/bin/node \ + /usr/local/lib/nodejs/*/bin/node "${HOME:-/root}"/.nvm/versions/node/v*/bin/node; do + [ ! -x "$candidate" ] || candidates+=("$candidate") +done +candidates+=("$(command -v node || true)") +NODE_BIN= +for candidate in "${candidates[@]}"; do + if [ -x "$candidate" ] && node_ok "$candidate"; then NODE_BIN=$candidate; break; fi +done +[ -n "$NODE_BIN" ] || fail '需要 Node.js >=20.9,推荐 Node.js 22;可用 NODE_BIN 指定路径' +export PATH="$(dirname "$NODE_BIN"):$PATH" +NPM_BIN="$(dirname "$NODE_BIN")/npm" +[ -x "$NPM_BIN" ] || NPM_BIN=$(command -v npm || true) +[ -n "$NPM_BIN" ] && [ -x "$NPM_BIN" ] || fail "选定 Node 缺少 npm:$NODE_BIN" +supervisorctl pid >/dev/null || fail 'Supervisor 未运行或无法连接' +STATUS=$(supervisorctl status "$PROGRAM" 2>/dev/null || true) +[[ "$STATUS" != *RUNNING* ]] || WAS_RUNNING=1 +[ ! -L "$CURRENT" ] && [ ! -L "$PREVIOUS" ] || fail 'next/next.prev 不允许是符号链接' +echo "使用 Node $("$NODE_BIN" -v),在 Linux 暂存目录安装运行依赖。" + +STAGING=$(mktemp -d "$DIR/.${PROGRAM}.staging.XXXXXX") +# 不接受绝对路径、父目录跳转或 node_modules;运行依赖必须由目标机器安装。 +unzip -Z1 "$UPLOAD_COPY/dist.zip" | while IFS= read -r entry; do + case "$entry" in + /*|../*|*/../*|*/..|node_modules|node_modules/*|*/node_modules/*) + echo "包内包含不允许的路径:$entry" >&2; exit 1 ;; + esac +done || fail 'ZIP 路径检查失败' +unzip -q "$UPLOAD_COPY/dist.zip" -d "$STAGING" +for required in start.mjs server.js package.json package-lock.json .next-test/BUILD_ID \ + config/test.env config/secrets/m3u8.key; do + [ -s "$STAGING/$required" ] || fail "包中缺少非空文件 $required" +done +for required in .next-test/static public; do + [ -d "$STAGING/$required" ] || fail "包中缺少目录 $required" +done +[ ! -L "$STAGING/config" ] && [ ! -L "$STAGING/node_modules" ] || fail '包内配置或依赖目录不能是符号链接' + +# 服务器的运行配置、密钥和已生成 sitemap 优先,始终不覆盖原 Go 应用目录。 +cp "$STAGING/config/secrets/m3u8.key" "$UPLOAD_COPY/packaged-m3u8.key" +if [ -f "$CURRENT/config/test.local.env" ]; then + cp -p "$CURRENT/config/test.local.env" "$STAGING/config/test.local.env" +fi +if [ -d "$CURRENT/config/secrets" ]; then + cp -a "$CURRENT/config/secrets/." "$STAGING/config/secrets/" +fi +# 空旧密钥不覆盖包中有效密钥,随后再选择服务器已有的非空密钥。 +cp "$UPLOAD_COPY/packaged-m3u8.key" "$STAGING/config/secrets/m3u8.key" +for existing_key in "$CURRENT/config/secrets/m3u8.key" "$DIR/config/secrets/m3u8.key" "$DIR/conf/m3u8.key"; do + if [ -s "$existing_key" ]; then cp -p "$existing_key" "$STAGING/config/secrets/m3u8.key"; break; fi +done +(cd "$STAGING" && "$NPM_BIN" ci --omit=dev --include=optional --ignore-scripts --no-audit --no-fund) +# BEGIN preserve backend sitemaps +(cd "$STAGING" && "$NODE_BIN" --input-type=module - "$CURRENT" "$STAGING" "$DIR" <<'NODE' +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { parse } from 'dotenv'; +const [current, staging, original] = process.argv.slice(2).map(value => resolve(value)); +const read = file => existsSync(file) ? parse(readFileSync(file)) : {}; +const config = { ...read(join(staging, 'config/test.env')), ...read(join(staging, 'config/test.local.env')), ...process.env }; +const configured = (config.SITEMAP_DIR || './template/pages/sitemaps').trim() || './template/pages/sitemaps'; +const source = resolve(current, configured), suffix = relative(current, source); +const insideCurrent = suffix !== '..' && !suffix.startsWith('..' + '/') && !isAbsolute(suffix); +const nameAllowed = name => name === 'sitemap.xml' || /^sitemap-[a-z0-9-]+\.xml$/.test(name); +const info = file => { try { return lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }; +const withinCurrent = target => { const part = relative(current, target); return part !== '..' && !part.startsWith('../') && !isAbsolute(part); }; +const ensureParent = destination => { + if (destination === staging) return; + const suffix = relative(staging, dirname(destination)); + if (suffix === '..' || suffix.startsWith('../') || isAbsolute(suffix)) throw new Error('Sitemap destination escapes staging'); + let cursor = staging; + for (const segment of suffix.split('/').filter(Boolean)) { + cursor = join(cursor, segment); + if (info(cursor)?.isSymbolicLink()) throw new Error('Sitemap destination parent cannot be a symbolic link'); + mkdirSync(cursor, { recursive: true }); + } +}; +const available = directory => { + const value = info(directory); + // An explicitly empty current directory is authoritative; do not revive older XML. + return value?.isSymbolicLink() || value?.isDirectory(); +}; +const visited = new Set(); +const preserveDirectory = (existing, destination) => { + if (visited.has(existing)) throw new Error('Sitemap mount contains a symbolic-link cycle'); + visited.add(existing); + ensureParent(destination); + if (info(existing)?.isSymbolicLink()) { + const link = readlinkSync(existing), target = resolve(dirname(existing), link); + // Keep release-local link targets as well as their link; external writers stay external. + if (withinCurrent(target) && available(target)) preserveDirectory(target, resolve(staging, relative(current, target))); + rmSync(destination, { recursive: true, force: true }); + symlinkSync(link, destination); + return; + } + if (info(destination)?.isSymbolicLink()) throw new Error('Packaged sitemap destination cannot be a symbolic link'); + mkdirSync(destination, { recursive: true }); + // Server files, including intentional absence, take precedence over stale build copies. + for (const name of readdirSync(destination).filter(nameAllowed)) { + const target = join(destination, name); + if (info(target)?.isFile() || info(target)?.isSymbolicLink()) rmSync(target); + } + for (const name of readdirSync(existing).filter(nameAllowed)) { + const file = join(existing, name); + if (!statSync(file, { throwIfNoEntry: false })?.isFile()) continue; + cpSync(file, join(destination, name), { dereference: true, preserveTimestamps: true }); + } +}; +// External mounts remain untouched. Absolute paths inside next/ rebase into the new release. +if (insideCurrent) { + const destination = resolve(staging, suffix); + const candidates = [source, join(current, 'template/pages/sitemaps'), join(current, 'data/sitemaps'), join(original, 'template/pages/sitemaps')]; + const existing = candidates.find(available); + if (existing && existing !== destination) preserveDirectory(existing, destination); +} +// The original router's second index candidate is independent of SITEMAP_DIR. +const fallback = [join(current, 'template/pages/sitemap.xml'), join(original, 'template/pages/sitemap.xml')].find(file => existsSync(file) && statSync(file).isFile()); +if (fallback) { + const target = join(staging, 'template/pages/sitemap.xml'); + ensureParent(target); + if (info(target)?.isSymbolicLink()) rmSync(target); + cpSync(fallback, target, { dereference: true, preserveTimestamps: true }); +} +// Preserve the same optional robots overrides that the original Go helper reads. +for (const file of ['template/pages/robots.txt', 'templates/pages/robots.txt', 'app/template/pages/robots.txt', 'app/templates/pages/robots.txt']) { + const existing = [join(current, file), join(original, file)].find(source => existsSync(source) && statSync(source).isFile()); + if (!existing) continue; + const target = join(staging, file); + ensureParent(target); + if (info(target)?.isSymbolicLink()) rmSync(target); + cpSync(existing, target, { dereference: true, preserveTimestamps: true }); +} +NODE +) +# END preserve backend sitemaps +APP_PORT=$(cd "$STAGING" && "$NODE_BIN" --input-type=module <<'NODE' +import { existsSync, readFileSync } from 'node:fs'; +import { parse } from 'dotenv'; +const read = path => existsSync(path) ? parse(readFileSync(path)) : {}; +const config = { ...read('config/test.env'), ...read('config/test.local.env'), ...process.env }; +const port = config.PORT || '30334'; +if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('Invalid PORT'); +if (!/^https?:\/\//.test(config.UPSTREAM_API_URL?.trim() || '')) throw new Error('UPSTREAM_API_URL is required'); +console.log(Number(port)); +NODE +) + +mkdir -p "$SUP_CONF_DIR" "$SUP_LOG_DIR" +NEW_CONF=$(mktemp "$DIR/.${PROGRAM}.supervisor.XXXXXX") +# Supervisor 会解析百分号;路径中的引号与反斜杠同时按其 command 语法转义。 +supervisor_quote() { local value=${1//\\/\\\\}; value=${value//\"/\\\"}; printf '%s' "${value//%/%%}"; } +cat > "$NEW_CONF" </dev/null || true) + if [ "$CODE" = 200 ] && "$NODE_BIN" -e ' + try { const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); + process.exit(value.status==="ok" && value.environment==="test" ? 0 : 1); + } catch { process.exit(1); }' "$HEALTH_FILE"; then HEALTH_OK=1; break; fi + sleep 1 +done +[ "$HEALTH_OK" = 1 ] || fail '/healthz 未返回测试环境的 HTTP 200 健康响应' +CODE=$(curl -sS --connect-timeout 2 --max-time 45 -o /dev/null -w '%{http_code}' "$BASE/" || true) +[ "$CODE" = 200 ] || fail "首页检查失败,HTTP $CODE" +CODE=$(curl -sS --connect-timeout 2 --max-time 25 -o "$HEALTH_FILE" -w '%{http_code}' "$BASE/enkey" || true) +[ "$CODE" = 200 ] && [ -s "$HEALTH_FILE" ] || fail "/enkey 检查失败,HTTP $CODE 或密钥为空" +supervisorctl status "$PROGRAM" +SWITCHED=0 +# 新上传若已覆盖固定文件名,留给下一次发布;清理失败也不改变已通过检查的结果。 +LATEST=$(sha256sum "$PACKAGE" 2>/dev/null | awk '{print $1}' || true) +if [ "$LATEST" = "$ACTUAL" ]; then + rm -f "$PACKAGE" "$CHECKSUM" || echo '提示:服务已更新,但上传包清理失败。' >&2 +else echo '提示:上传包已变更或不存在,保留当前文件供下次发布。'; fi +echo "测试更新完成:${CURRENT};/healthz、首页、/enkey 检查通过。" +echo "请确认 NGINX 将页面及 /enkey、/newenkey、/h5/m3u8/ 代理到 127.0.0.1:${APP_PORT};脚本未修改 NGINX。" diff --git a/deploy_prod.sh b/deploy_prod.sh new file mode 100755 index 0000000..e3f33e4 --- /dev/null +++ b/deploy_prod.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# 花魁短剧正式服:检查 → 构建 → Linux 运行包 + MD5 → 跳板机 running.sh。 +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +cd "$SCRIPT_DIR" +JUMP_HOST="${DEPLOY_JUMP_HOST:-server@116.212.120.2}" +JUMP_DIR="${DEPLOY_JUMP_DIR:-/home/server/web_hkdj/h5/nuxt}" +# 按正式服约定分别设置:SCP 为 22889,触发 SSH 为默认 22。 +SCP_PORT="${DEPLOY_SCP_PORT:-22889}" +SSH_PORT="${DEPLOY_SSH_PORT:-22}" +TARGET_ARCH="${DEPLOY_TARGET_ARCH:-x64}" +TARGET_LIBC="${DEPLOY_TARGET_LIBC:-glibc}" +PACKAGE_NAME=web_hkdj_h5.tar.gz +MD5_NAME=web_hkdj_h5.md5 +PACKAGE="$SCRIPT_DIR/$PACKAGE_NAME" +MD5_FILE="$SCRIPT_DIR/$MD5_NAME" +RELEASE="$SCRIPT_DIR/dist/production-linux-$TARGET_ARCH-$TARGET_LIBC" +PACKAGE_ONLY=0 + +fail() { printf '错误:%s\n' "$*" >&2; exit 1; } +step() { printf '\n▸ %s\n' "$*"; } +for argument in "$@"; do + case "$argument" in + --dry-run) DEPLOY_DRY_RUN=1 ;; + --package-only) PACKAGE_ONLY=1 ;; + -h|--help) + printf '用法:%s [--dry-run | --package-only]\n' "$0" + printf '默认上传 server@116.212.120.2:/home/server/web_hkdj/h5/nuxt/,SCP 22889,SSH 22。\n' + printf '可覆盖 DEPLOY_JUMP_HOST、DEPLOY_JUMP_DIR、DEPLOY_SCP_PORT、DEPLOY_SSH_PORT、DEPLOY_TARGET_ARCH、DEPLOY_TARGET_LIBC。\n' + exit 0 ;; + *) fail "不支持的参数 $argument;使用 --help 查看用法" ;; + esac +done +[[ "$JUMP_HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail 'DEPLOY_JUMP_HOST 格式无效' +[[ "$JUMP_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail 'DEPLOY_JUMP_DIR 必须是普通绝对路径' +JUMP_DIR="${JUMP_DIR%/}" +for port in "$SCP_PORT" "$SSH_PORT"; do + [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || fail 'SCP/SSH 端口必须在 1–65535 内' +done +case "$TARGET_ARCH" in x64|arm64) ;; *) fail 'DEPLOY_TARGET_ARCH 仅支持 x64 或 arm64' ;; esac +case "$TARGET_LIBC" in glibc|musl) ;; *) fail 'DEPLOY_TARGET_LIBC 仅支持 glibc 或 musl' ;; esac +for tool in node npm tar; do + command -v "$tool" >/dev/null || fail "缺少命令 $tool" +done +node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || a===20 && b<9) process.exit(1)' || fail '需要 Node.js >=20.9,推荐 22' +node -e 'const [a,b,c]=process.argv[1].split(".").map(Number); if(a<10 || a===10 && (b<9 || b===9 && c<2)) process.exit(1)' "$(npm --version)" || fail '跨平台运行依赖打包需要 npm >=10.9.2' +command -v md5sum >/dev/null || command -v md5 >/dev/null || fail '缺少 md5sum 或 macOS md5' +for file in config/production.env config/secrets/m3u8.key package-lock.json scripts/prepare-production-runtime.ts; do + [ -s "$file" ] || fail "缺少 $file" +done +[ -x node_modules/.bin/tsx ] || fail '请先执行 npm ci 安装本地依赖' + +if [ "${DEPLOY_DRY_RUN:-0}" = 1 ]; then + printf 'DRY RUN:正式环境;Linux %s/%s;只核对,不构建、不连接服务器。\n' "$TARGET_ARCH" "$TARGET_LIBC" + node --input-type=module -e 'import {readFileSync} from "node:fs"; import {parse} from "dotenv"; const c=parse(readFileSync("config/production.env")); console.log(` 服务端口 PORT=${c.PORT};仅打包 config/production.env,不上传本地覆盖。`)' + printf ' npm run check → build → bundle → bundle:linux -- %s %s\n' "$TARGET_ARCH" "$TARGET_LIBC" + printf ' 保留 standalone 裁剪依赖,仅补目标 Linux Sharp/libvips;不安装完整生产依赖。\n' + printf ' tar 根目录:start.mjs、server.js、node_modules、.next、public、config 等\n' + printf ' 生成 %s + %s(纯 MD5 hash)\n' "$PACKAGE_NAME" "$MD5_NAME" + if [ "$PACKAGE_ONLY" = 1 ]; then + printf ' --package-only:生成后结束,不上传。\n' + else + printf ' scp -P %s %s %s %s:%s/\n' "$SCP_PORT" "$PACKAGE_NAME" "$MD5_NAME" "$JUMP_HOST" "$JUMP_DIR" + printf ' ssh -p %s %s %s/running.sh(不带脚本参数)\n' "$SSH_PORT" "$JUMP_HOST" "$JUMP_DIR" + fi + exit 0 +fi +if [ "$PACKAGE_ONLY" = 0 ]; then + for tool in ssh scp tee; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done +fi + +# 同仓库只能有一次构建/打包/上传,避免并发覆盖同名包或提前触发 running.sh。 +mkdir -p "$SCRIPT_DIR/dist" +DEPLOY_LOCK="$SCRIPT_DIR/dist/.production-deploy.lock" +mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有正式发布进程或残留锁:${DEPLOY_LOCK};请先确认该进程已结束" +trap 'rmdir "$DEPLOY_LOCK" 2>/dev/null || true' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +step '检查类型和单元测试' +npm run check +step '构建并整理正式环境 standalone' +npm run build +npm run bundle +step "准备 standalone 精简运行包,仅补 Linux $TARGET_ARCH/$TARGET_LIBC 图片库" +npm run bundle:linux -- "$TARGET_ARCH" "$TARGET_LIBC" +for file in start.mjs server.js .next/BUILD_ID config/production.env config/secrets/m3u8.key runtime-target.json; do + [ -s "$RELEASE/$file" ] || fail "正式运行包缺少 $file" +done +for directory in node_modules .next/static public; do + [ -d "$RELEASE/$directory" ] || fail "正式运行包缺少目录 $directory" +done + +step "打包 $PACKAGE_NAME" +# -C 保持产物直接位于 tar 根部;禁止 AppleDouble、环境覆盖和日志混入。 +COPYFILE_DISABLE=1 COPY_EXTENDED_ATTRIBUTES_DISABLE=1 tar \ + --no-xattrs \ + --exclude='._*' --exclude='.DS_Store' --exclude='.env*' \ + --exclude='*.local.env' --exclude='*.log' \ + -czf "$PACKAGE" -C "$RELEASE" . +chmod 600 "$PACKAGE" +if command -v md5sum >/dev/null; then + md5sum "$PACKAGE" | awk '{print $1}' > "$MD5_FILE" +else + md5 -q "$PACKAGE" > "$MD5_FILE" +fi +tar -tzf "$PACKAGE" >/dev/null +printf '运行包已生成:%s;校验文件:%s\n' "$PACKAGE_NAME" "$MD5_NAME" +node -e 'console.log(`压缩包体积:${(require("node:fs").statSync(process.argv[1]).size / 1024 / 1024).toFixed(2)} MiB`)' "$PACKAGE" +if [ "$PACKAGE_ONLY" = 1 ]; then exit 0; fi + +step "上传到跳板机 $JUMP_HOST:$JUMP_DIR/" +scp -P "$SCP_PORT" "$PACKAGE" "$MD5_FILE" "$JUMP_HOST:$JUMP_DIR/" +step '执行既有正式服 running.sh' +# gatekeeper 按完整 .sh 路径放行,不能附参数、cd、bash 前缀或其它命令。 +RUN_LOG=$(mktemp "$SCRIPT_DIR/dist/production-running.XXXXXX") +printf '远端执行日志:%s\n' "$RUN_LOG" +if ! ssh -p "$SSH_PORT" "$JUMP_HOST" "$JUMP_DIR/running.sh" 2>&1 | tee "$RUN_LOG"; then + fail "正式服 running.sh 执行失败;日志:${RUN_LOG}" +fi +# 部分既有服务器脚本没有透传 Supervisor 的失败退出码,不能只依据 SSH 返回 0。 +if ! node - "$RUN_LOG" <<'NODE' +const { readFileSync } = require('node:fs'); +const output = readFileSync(process.argv[2], 'utf8').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ''); +if (/\b(?:BACKOFF|FATAL|ERROR)\b|\bspawn\s+error\b/i.test(output)) process.exit(1); +NODE +then + fail "正式服输出包含失败状态,不能确认发布成功;日志:${RUN_LOG}" +fi +printf '\n正式服 running.sh 执行完成,未发现失败状态;日志:%s;本地包已保留供排查。\n' "$RUN_LOG" diff --git a/deploy_test.sh b/deploy_test.sh new file mode 100755 index 0000000..1f02180 --- /dev/null +++ b/deploy_test.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# 花魁短剧测试服:检查 → 构建 → dist.zip → 上传 → Supervisor 重启/健康检查。 +# DEPLOY_DRY_RUN=1 ./deploy_test.sh 仅核对,不构建、不连接服务器。 +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +cd "$SCRIPT_DIR" + +HOST="${DEPLOY_HOST:-root@172.104.181.99}" +SSH_PORT="${DEPLOY_SSH_PORT:-22889}" +REMOTE_DIR="${DEPLOY_DIR:-/server/hkdj_net/h5}" +CHECK_URL="${DEPLOY_CHECK_URL:-https://hkdjneth5.remoces.com}" +PACKAGE="$SCRIPT_DIR/dist.zip" +CHECKSUM="$SCRIPT_DIR/dist.zip.sha256" + +fail() { printf '错误:%s\n' "$*" >&2; exit 1; } +step() { printf '\n▸ %s\n' "$*"; } + +for argument in "$@"; do + case "$argument" in + --dry-run) DEPLOY_DRY_RUN=1 ;; + -h|--help) + printf '用法:%s [--dry-run]\n默认目标 root@172.104.181.99:22889 /server/hkdj_net/h5/\n可覆盖 DEPLOY_HOST、DEPLOY_SSH_PORT、DEPLOY_DIR、DEPLOY_CHECK_URL;DEPLOY_DRY_RUN=1 等同 --dry-run。\n' "$0" + exit 0 ;; + *) fail "不支持的参数 $argument;使用 --help 查看用法" ;; + esac +done + +# 这些值会作为远端路径/命令使用,仅允许普通 SSH 目标和绝对路径。 +[[ "$HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail "DEPLOY_HOST 格式无效" +[[ "$SSH_PORT" =~ ^[0-9]+$ ]] && [ "$SSH_PORT" -ge 1 ] && [ "$SSH_PORT" -le 65535 ] || fail "DEPLOY_SSH_PORT 无效" +[[ "$REMOTE_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail "DEPLOY_DIR 必须是普通绝对路径" + +for tool in node npm zip unzip ssh scp; do + command -v "$tool" >/dev/null || fail "缺少命令 $tool" +done +node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || (a===20 && b<9)) process.exit(1)' || fail "需要 Node.js >=20.9,推荐 Node.js 22" +node -e 'try { const u=new URL(process.argv[1]); if (!["http:","https:"].includes(u.protocol) || u.username || u.password || u.search || u.hash || u.pathname!=="/") process.exit(1); } catch { process.exit(1); }' "$CHECK_URL" || fail "DEPLOY_CHECK_URL 必须是 HTTP(S) 站点根地址" +for file in config/test.env config/secrets/m3u8.key deploy/up_next_h5.sh package-lock.json; do + [ -s "$file" ] || fail "缺少 $file" +done +bash -n deploy/up_next_h5.sh + +if [ "${DEPLOY_DRY_RUN:-0}" = 1 ]; then + printf 'DRY RUN:测试配置 config/test.env;上游与端口可由服务器 config/test.local.env 覆盖。\n' + # 不输出本地覆盖/进程配置中的凭证;只展示仓库公共配置。 + node --input-type=module -e 'import {readFileSync} from "node:fs"; import {parse} from "dotenv"; const c=parse(readFileSync("config/test.env")); console.log(` API=${c.UPSTREAM_API_URL} PORT=${c.PORT}`)' + printf ' npm run check → npm run build:test → npm run bundle:test\n' + printf ' dist.zip(不含 node_modules / 本地覆盖)+ SHA-256 → %s:%s/(SSH %s)\n' "$HOST" "$REMOTE_DIR" "$SSH_PORT" + printf ' ssh 执行 %s/up_next_h5.sh:安装 Linux 运行依赖 → next/ → Supervisor hkdj_next_h5 → 健康检查\n' "$REMOTE_DIR" + printf ' 公网检查 %s:/healthz(test)+ 首页(Next HTML);此模式不访问域名。\n' "$CHECK_URL" + exit 0 +fi + +step '检查类型和单元测试' +npm run check +step '构建并整理测试环境 standalone' +npm run build:test +npm run bundle:test + +for file in start.mjs server.js .next-test/BUILD_ID config/test.env config/secrets/m3u8.key; do + [ -s "dist/test/$file" ] || fail "测试包缺少 $file" +done +[ -d dist/test/.next-test/static ] && [ -d dist/test/public ] || fail '测试包缺少静态资源' + +# Next 本机的原生模块不能直接用于 Linux:远端用 lockfile 安装运行依赖。 +# dotenv 属于 dependencies,start.mjs 在 npm ci --omit=dev 后仍可加载配置。 +cp package.json package-lock.json dist/test/ +step '生成 dist.zip 和 SHA-256(保留本地包便于排查)' +rm -f "$PACKAGE" "$CHECKSUM" +( + cd dist/test + COPYFILE_DISABLE=1 zip -q -r "$PACKAGE" . \ + -x 'node_modules/*' '*/node_modules/*' '*.local.env' '.env*' '*/.env*' '._*' '*/._*' '*.log' +) +chmod 600 "$PACKAGE" +if command -v sha256sum >/dev/null; then + sha256sum dist.zip > "$CHECKSUM" +else + shasum -a 256 dist.zip > "$CHECKSUM" +fi +unzip -tq "$PACKAGE" + +step "上传到 $HOST:$REMOTE_DIR/" +ssh -p "$SSH_PORT" "$HOST" "mkdir -p '$REMOTE_DIR'" +scp -P "$SSH_PORT" "$PACKAGE" "$CHECKSUM" deploy/up_next_h5.sh "$HOST:$REMOTE_DIR/" +step '执行服务器更新脚本' +ssh -p "$SSH_PORT" "$HOST" "/bin/bash '$REMOTE_DIR/up_next_h5.sh'" +step "检查公网测试站 $CHECK_URL" +if ! node --input-type=module - "$CHECK_URL" <<'NODE' +const origin = process.argv[2]; +async function read(path) { + const response = await fetch(new URL(path, origin), { signal: AbortSignal.timeout(4000), redirect: 'error', cache: 'no-store' }); + if (response.status !== 200) throw new Error(`${path} HTTP ${response.status}`); + let text = '', size = 0; + const decoder = new TextDecoder(); + for await (const part of response.body) { + size += part.length; + if (size > 262144) throw new Error(`${path} 响应超过检查上限`); + text += decoder.decode(part, { stream: true }); + if (path === '/' && text.includes('/_next/')) break; + } + return { text, type: response.headers.get('content-type') || '' }; +} +for (let attempt = 1; attempt <= 3; attempt++) { + try { + const [health, home] = await Promise.all([read('/healthz'), read('/')]); + let status; + try { status = JSON.parse(health.text); } catch { throw new Error('/healthz 返回无效 JSON'); } + if (status?.status !== 'ok' || status?.environment !== 'test') throw new Error('/healthz 未命中 Next 测试服务'); + if (!/^text\/html(?:;|$)/i.test(home.type) || !home.text.includes('/_next/')) throw new Error('首页不是 Next HTML'); + console.log('公网 /healthz 与 Next 首页检查通过'); + process.exit(0); + } catch (error) { + console.error(`公网检查 ${attempt}/3 失败:${error.message}`); + if (attempt < 3) await new Promise(resolve => setTimeout(resolve, 1000)); + } +} +process.exit(1); +NODE +then + fail '服务器服务已更新,但域名检查失败,请检查 Nginx 和 Cloudflare 回源配置。' +fi +printf '\n测试服更新完成:%s:%s/next(Supervisor:hkdj_next_h5)\n' "$HOST" "$REMOTE_DIR" diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..6936789 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next-dev/dev/types/routes.d.ts"; +import "./.next-dev/dev/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..96c470b --- /dev/null +++ b/next.config.ts @@ -0,0 +1,60 @@ +import type { NextConfig } from "next"; + +const env = process.env.APP_ENV || "development"; +const config: NextConfig = { + output: "standalone", + distDir: + env === "production" + ? ".next" + : env === "test" + ? ".next-test" + : ".next-dev", + poweredByHeader: false, + // Go serves generated files without application compression; keep file byte lengths and ranges intact. + // Public reverse proxies may apply the same transport compression used for the original Go service. + compress: false, + htmlLimitedBots: /.*/, // Keep complete SEO metadata in the initial HTML for every crawler/client. + // Preserve source canonical redirects in proxy.ts before React rendering. + skipTrailingSlashRedirect: true, + skipProxyUrlNormalize: true, + outputFileTracingIncludes: { + "/*": [ + "./config/secrets/m3u8.key", + "./template/pages/robots.txt", + "./templates/pages/robots.txt", + "./app/template/pages/robots.txt", + "./app/templates/pages/robots.txt", + "./config/seo/**/*", + ], + }, + // Keep XML out of page traces. package-standalone.ts also removes copies from Node Proxy traces. + outputFileTracingExcludes: { + "/*": [ + "./template/pages/sitemaps/**/*", + "./template/pages/sitemap.xml", + "./data/sitemaps/**/*", + ], + }, + async headers() { + return [ + { + source: "/sw.js", + headers: [ + { key: "Content-Type", value: "application/javascript; charset=utf-8" }, + { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" }, + { key: "Service-Worker-Allowed", value: "/" }, + ], + }, + { + source: "/static/:path*", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=604800, stale-while-revalidate=86400", + }, + ], + }, + ]; + }, +}; +export default config; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..688cd03 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2173 @@ +{ + "name": "huakui-drama-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "huakui-drama-web", + "version": "1.0.0", + "dependencies": { + "dotenv": "^17.2.3", + "next": "16.4.0", + "qrcode": "^1.5.4", + "react": "19.3.0", + "react-dom": "19.3.0", + "xgplayer": "3.0.26", + "xgplayer-hls": "3.0.26" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "esbuild": "0.28.2", + "sharp": "0.35.5", + "tsx": "^4.20.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", + "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", + "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", + "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", + "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", + "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", + "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", + "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", + "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", + "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-typed-array": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/concat-typed-array/-/concat-typed-array-1.0.2.tgz", + "integrity": "sha512-aC878bxeWSlrY6h60cCDwBUXpKwovZrB7+C4+VHNO1CIXW2gBLxbQ757jWtOXUscLGgYI8R84N6uy9fTJPe+0g==", + "deprecated": "WARNING: This package has been renamed to typed-array-concat.", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/crypto-es": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/crypto-es/-/crypto-es-2.1.0.tgz", + "integrity": "sha512-C5Dbuv4QTPGuloy5c5Vv/FZHtmK+lobLAypFfuRaBbwCsk3qbCWWESCH3MUcBsrgXloRNMrzwUAiPg4U6+IaKA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/danmu.js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/danmu.js/-/danmu.js-1.2.1.tgz", + "integrity": "sha512-evDEImUBo94c846fC92K//Dzll8jXnZ3zKmYlQHwMzmvw6IW2IyjWL3Ew2SqEAzuqauFnDkwJEgZauu3uW/p1Q==", + "license": "MIT", + "dependencies": { + "event-emitter": "^0.3.5" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delegate": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", + "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", + "license": "MIT", + "dependencies": { + "@next/env": "16.4.0", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.4.0", + "@next/swc-darwin-x64": "16.4.0", + "@next/swc-linux-arm64-gnu": "16.4.0", + "@next/swc-linux-arm64-musl": "16.4.0", + "@next/swc-linux-x64-gnu": "16.4.0", + "@next/swc-linux-x64-musl": "16.4.0", + "@next/swc-win32-arm64-msvc": "16.4.0", + "@next/swc-win32-x64-msvc": "16.4.0", + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xgplayer": { + "version": "3.0.26", + "resolved": "https://registry.npmjs.org/xgplayer/-/xgplayer-3.0.26.tgz", + "integrity": "sha512-F/urLGIrxCa1DI+wtkPyT0TcRS6Wejbo+OZKOTlSVvg0+SGyN3TfSXddNcu6LL876ZTWzlEwhS4C5nKSUV5FZw==", + "license": "MIT", + "dependencies": { + "danmu.js": ">=1.2.1", + "delegate": "^3.2.0", + "eventemitter3": "^4.0.7", + "xgplayer-subtitles": "3.0.26" + }, + "peerDependencies": { + "core-js": ">=3.12.1" + } + }, + "node_modules/xgplayer-hls": { + "version": "3.0.26", + "resolved": "https://registry.npmjs.org/xgplayer-hls/-/xgplayer-hls-3.0.26.tgz", + "integrity": "sha512-F+su9sAGGzZt9MhlrCHxOVhunWT5qy1S8ooKW331lEwueHsCeBR/VFsGsG12cj33FA/+jt0sE2BFpWHxeoEb2g==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.7", + "xgplayer-streaming-shared": "3.0.26", + "xgplayer-transmuxer": "3.0.26" + }, + "peerDependencies": { + "core-js": ">=3.12.1", + "xgplayer": "3.0.26" + } + }, + "node_modules/xgplayer-streaming-shared": { + "version": "3.0.26", + "resolved": "https://registry.npmjs.org/xgplayer-streaming-shared/-/xgplayer-streaming-shared-3.0.26.tgz", + "integrity": "sha512-URZWNZX90Z2BMhpDXFuGrFwphqoNf5jnqb6zUV756ACgrnlHE5nyxDBYutueCaoCaFXH4ueeN67K8Xit8+xijA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.7" + }, + "peerDependencies": { + "core-js": ">=3.12.1" + } + }, + "node_modules/xgplayer-subtitles": { + "version": "3.0.26", + "resolved": "https://registry.npmjs.org/xgplayer-subtitles/-/xgplayer-subtitles-3.0.26.tgz", + "integrity": "sha512-HiHlw3E8emEQCv0xFYHdmNUFgyzyWGBnSNmyI/fkuuvRBb+4IbsCfxg6PF3r1TNCfzOuNzA/OaMbtXBSH8n/Bw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.7" + }, + "peerDependencies": { + "core-js": ">=3.12.1" + } + }, + "node_modules/xgplayer-transmuxer": { + "version": "3.0.26", + "resolved": "https://registry.npmjs.org/xgplayer-transmuxer/-/xgplayer-transmuxer-3.0.26.tgz", + "integrity": "sha512-McJZPMbrS6hGMHM1dQ7AjDhbaHeeIe2ZrUDOShiHhS3AJFibldtH3+w/veprBs9tAMvtPGDq9mztW/qvdyKUNw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.15.3", + "concat-typed-array": "^1.0.2", + "crypto-es": "^2.1.0" + }, + "peerDependencies": { + "core-js": ">=3.12.1" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3c84824 --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "huakui-drama-web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx scripts/next-env.ts development dev", + "dev:charles": "bash scripts/dev-charles.sh", + "dev:test": "tsx scripts/next-env.ts test dev", + "build": "tsx scripts/next-env.ts production build", + "build:dev": "tsx scripts/next-env.ts development build", + "build:test": "tsx scripts/next-env.ts test build", + "start": "tsx scripts/next-env.ts production start", + "start:dev": "tsx scripts/next-env.ts development start", + "start:test": "tsx scripts/next-env.ts test start", + "typecheck": "tsc --noEmit", + "test": "tsx --test tests/*.test.ts tests/*.test.tsx", + "check": "npm run typecheck && npm test", + "test:browser": "tsx scripts/browser-smoke.ts", + "test:ui": "tsx scripts/ui-check.ts", + "test:actions": "tsx scripts/catalog-react-smoke.ts", + "test:popups": "tsx scripts/native-site-smoke.ts", + "test:install": "tsx scripts/native-site-smoke.ts", + "assets:sync": "tsx scripts/sync-figma-assets.ts && tsx scripts/sync-figma-icons.ts && tsx scripts/compose-placeholder.ts", + "assets:check": "tsx scripts/sync-figma-assets.ts --check && tsx scripts/sync-figma-icons.ts --check && tsx scripts/compose-placeholder.ts --check", + "assets:pwa": "tsx scripts/compose-pwa-icons.ts && tsx scripts/build-pwa.ts", + "assets:pwa:check": "tsx scripts/compose-pwa-icons.ts --check && tsx scripts/build-pwa.ts --check", + "bundle": "tsx scripts/package-standalone.ts production", + "bundle:test": "tsx scripts/package-standalone.ts test", + "bundle:linux": "tsx scripts/prepare-production-runtime.ts", + "deploy:test": "bash deploy_test.sh", + "deploy:prod": "bash deploy_prod.sh", + "test:catalog": "tsx scripts/catalog-react-smoke.ts", + "test:playback": "tsx scripts/playback-smoke.ts", + "test:pwa": "tsx scripts/pwa-smoke.ts", + "test:presentation": "tsx scripts/page-presentation-smoke.ts", + "test:ssr": "tsx scripts/native-ssr-smoke.ts", + "sitemap:sync": "tsx scripts/sync-sitemap.ts", + "routes:export": "tsx scripts/export-page-routes.ts", + "routes:check": "tsx scripts/export-page-routes.ts --check" + }, + "dependencies": { + "dotenv": "^17.2.3", + "next": "16.4.0", + "qrcode": "^1.5.4", + "react": "19.3.0", + "react-dom": "19.3.0", + "xgplayer": "3.0.26", + "xgplayer-hls": "3.0.26" + }, + "devDependencies": { + "@playwright/test": "^1.56.0", + "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "esbuild": "0.28.2", + "sharp": "0.35.5", + "tsx": "^4.20.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "type": "module" +} diff --git a/public/static/brand/footer.svg b/public/static/brand/footer.svg new file mode 100644 index 0000000..644c4d7 --- /dev/null +++ b/public/static/brand/footer.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/brand/header.svg b/public/static/brand/header.svg new file mode 100644 index 0000000..b502f41 --- /dev/null +++ b/public/static/brand/header.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/brand/huakui-20261013/auth.png b/public/static/brand/huakui-20261013/auth.png new file mode 100644 index 0000000..acf1f78 Binary files /dev/null and b/public/static/brand/huakui-20261013/auth.png differ diff --git a/public/static/brand/huakui-20261013/favicon.ico b/public/static/brand/huakui-20261013/favicon.ico new file mode 100644 index 0000000..ef8fbc7 Binary files /dev/null and b/public/static/brand/huakui-20261013/favicon.ico differ diff --git a/public/static/brand/huakui-20261013/footer.png b/public/static/brand/huakui-20261013/footer.png new file mode 100644 index 0000000..4c82acd Binary files /dev/null and b/public/static/brand/huakui-20261013/footer.png differ diff --git a/public/static/brand/huakui-20261013/header.png b/public/static/brand/huakui-20261013/header.png new file mode 100644 index 0000000..4912763 Binary files /dev/null and b/public/static/brand/huakui-20261013/header.png differ diff --git a/public/static/brand/huakui-20261013/icon.png b/public/static/brand/huakui-20261013/icon.png new file mode 100644 index 0000000..94fd7e1 Binary files /dev/null and b/public/static/brand/huakui-20261013/icon.png differ diff --git a/public/static/brand/huakui-20261013/icon.svg b/public/static/brand/huakui-20261013/icon.svg new file mode 100644 index 0000000..0cbff4a --- /dev/null +++ b/public/static/brand/huakui-20261013/icon.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/brand/manifest.json b/public/static/brand/manifest.json new file mode 100644 index 0000000..8b42df4 --- /dev/null +++ b/public/static/brand/manifest.json @@ -0,0 +1,132 @@ +{ + "fileKey": "1vUq4wf5xOs8ZOlmt26Q3O", + "source": "https://www.figma.com/design/1vUq4wf5xOs8ZOlmt26Q3O/?node-id=0-1", + "assets": [ + { + "id": "1:414", + "name": "header", + "file": "public/static/brand/header.svg", + "width": 104, + "height": 36, + "sha256": "1e58739a5d3253b2dd6f5e5903609bf228445397cb9532f695e7d4b60a67c84e" + }, + { + "id": "1:706", + "name": "footer", + "file": "public/static/brand/footer.svg", + "width": 124, + "height": 44, + "sha256": "5268c270113a1e55e6d56e8f934f317ef2e7e357ae472373160a3bc96b61bd6e" + }, + { + "id": "3:20211", + "name": "mobile", + "file": "public/static/brand/mobile.svg", + "width": 88, + "height": 28, + "sha256": "65f185eafddb14c72738b97782b018e6379284989b828500af7f8bfabd161258" + }, + { + "id": "3:15722", + "name": "symbol", + "file": "public/static/favicon.svg", + "width": 34, + "height": 34, + "sha256": "f1f80d0ea052d49d405e5ce3ea80d2f6a511c2b68ac4094e330434f2b883f603" + }, + { + "id": "1:414", + "name": "header-png", + "file": "public/static/logo.png", + "width": 208, + "height": 72, + "sha256": "5791140918967fd5b92718b89f7648a28f8da59a2df76b517cdeb0203f850c5d" + }, + { + "id": "1:706", + "name": "footer-png", + "file": "public/static/logo2.png", + "width": 248, + "height": 88, + "sha256": "db8d678831257b6776cbee9e4cd752c7a360cba098634787879f819920b64c49" + }, + { + "id": "3:21847", + "name": "login-symbol", + "file": "public/static/logo_icon.png", + "width": 128, + "height": 128, + "sha256": "bd08f5199d6d4bc7831f3b7863f6d2ba6496750ff65aff39ea23305131e53403" + }, + { + "id": "3:15722", + "name": "favicon-png", + "file": "public/static/favicon.png", + "width": 68, + "height": 68, + "sha256": "433a136adc0416be48822a3ae9d214beb0096e7a69be1b7803e5e8788f0dc70f" + }, + { + "id": "3:15722", + "name": "favicon-ico", + "file": "public/static/favicon.ico", + "width": 68, + "height": 68, + "sha256": "f9c5f3c9b35123fae480286215c10334b2ea75d35e84770e9aa10a12c8756ff9" + }, + { + "id": "1:414", + "name": "header-png-versioned", + "file": "public/static/brand/huakui-20261013/header.png", + "width": 208, + "height": 72, + "sha256": "5791140918967fd5b92718b89f7648a28f8da59a2df76b517cdeb0203f850c5d", + "source": "public/static/logo.png" + }, + { + "id": "1:706", + "name": "footer-png-versioned", + "file": "public/static/brand/huakui-20261013/footer.png", + "width": 248, + "height": 88, + "sha256": "db8d678831257b6776cbee9e4cd752c7a360cba098634787879f819920b64c49", + "source": "public/static/logo2.png" + }, + { + "id": "3:21847", + "name": "login-symbol-versioned", + "file": "public/static/brand/huakui-20261013/auth.png", + "width": 128, + "height": 128, + "sha256": "bd08f5199d6d4bc7831f3b7863f6d2ba6496750ff65aff39ea23305131e53403", + "source": "public/static/logo_icon.png" + }, + { + "id": "3:15722", + "name": "symbol-versioned", + "file": "public/static/brand/huakui-20261013/icon.svg", + "width": 34, + "height": 34, + "sha256": "f1f80d0ea052d49d405e5ce3ea80d2f6a511c2b68ac4094e330434f2b883f603", + "source": "public/static/favicon.svg" + }, + { + "id": "3:15722", + "name": "favicon-png-versioned", + "file": "public/static/brand/huakui-20261013/icon.png", + "width": 68, + "height": 68, + "sha256": "433a136adc0416be48822a3ae9d214beb0096e7a69be1b7803e5e8788f0dc70f", + "source": "public/static/favicon.png" + }, + { + "id": "3:15722", + "name": "favicon-ico-versioned", + "file": "public/static/brand/huakui-20261013/favicon.ico", + "width": 68, + "height": 68, + "sha256": "f9c5f3c9b35123fae480286215c10334b2ea75d35e84770e9aa10a12c8756ff9", + "source": "public/static/favicon.ico" + } + ] +} diff --git a/public/static/brand/mobile.svg b/public/static/brand/mobile.svg new file mode 100644 index 0000000..4d37de2 --- /dev/null +++ b/public/static/brand/mobile.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/brand/placeholder.manifest.json b/public/static/brand/placeholder.manifest.json new file mode 100644 index 0000000..bf8d502 --- /dev/null +++ b/public/static/brand/placeholder.manifest.json @@ -0,0 +1,27 @@ +{ + "file": "public/static/brand/placeholder.svg", + "width": 480, + "height": 480, + "sha256": "08a739c4d5f374c44a3bee35d32256334b8b79fff2f31c6c78bab10e3afb6284", + "source": "Composition using the unmodified Figma brand export and the design panel background token.", + "background": { + "color": "#26272f", + "source": "Figma global panel token" + }, + "composition": { + "type": "svg-container", + "embeddedImage": { + "file": "public/static/brand/footer.svg", + "source": "https://www.figma.com/design/1vUq4wf5xOs8ZOlmt26Q3O/?node-id=1-706", + "nodeId": "1:706", + "sha256": "5268c270113a1e55e6d56e8f934f317ef2e7e357ae472373160a3bc96b61bd6e", + "encoding": "base64", + "unmodified": true, + "x": 120, + "y": 197.419355, + "width": 240, + "height": 85.16129, + "opacity": 0.2 + } + } +} diff --git a/public/static/brand/placeholder.svg b/public/static/brand/placeholder.svg new file mode 100644 index 0000000..53842cb --- /dev/null +++ b/public/static/brand/placeholder.svg @@ -0,0 +1,5 @@ + +花魁短剧内容占位 + + + diff --git a/public/static/common/web-sdk-v1.2.0.js b/public/static/common/web-sdk-v1.2.0.js new file mode 100644 index 0000000..1114a3b --- /dev/null +++ b/public/static/common/web-sdk-v1.2.0.js @@ -0,0 +1,28 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).WebSDK=t()}(this,function(){"use strict";function e(e){var t,n,r,o,i,a,s,c,u,l,d,_,p,f,h,g,m,y,v,w,k,b,C,S,P=function(e){var t,n,r,o,i,a;for(t=(null==e?"":e)+"",n=[],r=0;t.length>r;r++)128>(o=t.charCodeAt(r))?n.push(o):2048>o?n.push(192|o>>6,128|63&o):55296>o||o>56319||r+1>=t.length||56320>(i=t.charCodeAt(r+1))||i>57343?n.push(224|o>>12,128|o>>6&63,128|63&o):(n.push(240|(a=i-56320+(o-55296<<10)+65536)>>18,128|a>>12&63,128|a>>6&63,128|63&a),r++);return n}((null==e?"":e)+""),E=8*P.length;for(P.push(128);P.length%64!=56;)P.push(0);for(t=0;8>t;t++)P.push(E>>>8*t&255);for(n=function(e,t){return e<>>32-t},r=function(e,t){return((e>>>0)+(t>>>0)&4294967295)>>>0},o=function(e,t,n,o){return r(r(e,t),r(n,o))},i=[7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21],a=Array(64),s=0;64>s;s++)a[s]=Math.floor(4294967296*Math.abs(Math.sin(s+1)))>>>0;for(c=1732584193,u=4023233417,l=2562383102,d=271733878,_=0;P.length>_;_+=64){for(p=Array(16),f=0;16>f;f++)p[f]=(P[h=_+4*f]|P[h+1]<<8|P[h+2]<<16|P[h+3]<<24)>>>0;for(g=c,m=u,y=l,v=d,w=0;64>w;w++)16>w?(k=m&y|~m&v,b=w):32>w?(k=v&m|~v&y,b=(5*w+1)%16):48>w?(k=m^y^v,b=(3*w+5)%16):(k=y^(m|~v),b=7*w%16),C=v,v=y,y=m,m=r(m,n(o(g,k,a[w],p[b]),i[w])),g=C;c=r(c,g),u=r(u,m),l=r(l,y),d=r(d,v)}return S=function(e){for(var t="",n=0;4>n;n++)t+=(256+(e>>>8*n&255)).toString(16).slice(1);return t},S(c)+S(u)+S(l)+S(d)}function t(e,t){return(e>>>t|e<<32-t)>>>0}function n(e,t,n){return(e&t^~e&n)>>>0}function r(e,t,n){return(e&t^e&n^t&n)>>>0}function o(e){return(t(e,2)^t(e,13)^t(e,22))>>>0}function i(e){return(t(e,6)^t(e,11)^t(e,25))>>>0}function a(e){return(t(e,7)^t(e,18)^e>>>3)>>>0}function s(e){return(t(e,17)^t(e,19)^e>>>10)>>>0}function c(e){for(var t=(e>>>0).toString(16);8>t.length;)t="0"+t;return t}function u(e,t){return((255&e[t])<<24|(255&e[t+1])<<16|(255&e[t+2])<<8|255&e[t+3])>>>0}function l(e){for(var t=function(e){var t,c,l,d,_,p,f,h,g,m,y,v,w,k,b,C,S,P,E,T,x,D=e.slice(),M=0,I=8*D.length>>>0;for(D.length>=536870912&&(M=Math.floor(D.length/536870912),0==(I=D.length%536870912*8>>>0)&&D.length>0&&M--),D.push(128);D.length%64!=56;)D.push(0);for(D.push(M>>>24&255,M>>>16&255,M>>>8&255,255&M,I>>>24&255,I>>>16&255,I>>>8&255,255&I),t=1779033703,c=3144134277,l=1013904242,d=2773480762,_=1359893119,p=2600822924,f=528734635,h=1541459225,g=0;D.length>g;g+=64){for(y=Array(64),m=0;16>m;m++)y[m]=u(D,g+4*m);for(m=16;64>m;m++)y[m]=s(y[m-2])+y[m-7]+a(y[m-15])+y[m-16]>>>0;for(v=t,w=c,k=l,b=d,C=_,S=p,P=f,E=h,m=0;64>m;m++)T=E+i(C)+n(C,S,P)+zt[m]+y[m]>>>0,x=o(v)+r(v,w,k)>>>0,E=P,P=S,S=C,C=b+T>>>0,b=k,k=w,w=v,v=T+x>>>0;t=t+v>>>0,c=c+w>>>0,l=l+k>>>0,d=d+b>>>0,_=_+C>>>0,p=p+S>>>0,f=f+P>>>0,h=h+E>>>0}return[t,c,l,d,_,p,f,h]}(e||[]),l="",d=0;t.length>d;d++)l+=c(t[d]);return l}function d(e){return l(function(e){var t,n,r,o,i,a;for(t=(null==e?"":e)+"",n=[],r=0;t.length>r;r++)128>(o=t.charCodeAt(r))?n.push(o):2048>o?n.push(192|o>>6,128|63&o):55296>o||o>56319||r+1>=t.length||56320>(i=t.charCodeAt(r+1))||i>57343?n.push(224|o>>12,128|o>>6&63,128|63&o):(n.push(240|(a=i-56320+(o-55296<<10)+65536)>>18,128|a>>12&63,128|a>>6&63,128|63&a),r++);return n}((null==e?"":e)+""))}function _(e){var t,n,r,o,i,a;for(t=(null==e?"":e)+"",n=[],r=0;t.length>r;r++)128>(o=t.charCodeAt(r))?n.push(o):2048>o?n.push(192|o>>6,128|63&o):55296>o||o>56319||r+1>=t.length||56320>(i=t.charCodeAt(r+1))||i>57343?n.push(224|o>>12,128|o>>6&63,128|63&o):(n.push(240|(a=i-56320+(o-55296<<10)+65536)>>18,128|a>>12&63,128|a>>6&63,128|63&a),r++);return n}function p(e){for(var t=(null==e?"":e)+"",n=[],r=0;t.length>r;r+=2)n.push(parseInt(t.substr(r,2),16));return n}function f(e,t){var n,r=Array(e.length+t.length);for(n=0;e.length>n;n++)r[n]=e[n];for(n=0;t.length>n;n++)r[e.length+n]=t[n];return r}function h(e,t){var n,r;return null===e?"__t:null__":"string"==typeof e?"__t:str__:"+e:"number"==typeof e&&isFinite(e)?"__t:num__:"+e:"boolean"==typeof e?"__t:bool__:"+(e?"1":"0"):Array.isArray(e)?-1!==t.indexOf(e)?"__t:circular__":(t.push(e),n="["+e.map(function(e){return h(e,t)}).join(",")+"]",t.splice(t.indexOf(e),1),n):null!==e&&"object"==typeof e?-1!==t.indexOf(e)?"__t:circular__":(t.push(e),r="{"+Object.keys(e).sort().map(function(n){return n+":"+h(e[n],t)}).join(",")+"}",t.splice(t.indexOf(e),1),r):"__t:any__:"+e}function g(e){return h(e,[])}function m(t){var n,r=t||{},o=Object.keys(r).filter(function(e){return"event_id"!==e}).sort();return o.length?(n=o.map(function(t){return e(g(r[t]))}).join(""),e(n)):e("")}function y(){if("undefined"==typeof navigator)return"PC";var e=navigator.userAgent||"";return/iPhone|iPad|iPod/i.test(e)?"iOS":function(e){return/HarmonyOS|OpenHarmony/i.test((null==e?"":e)+"")}(e)||/Android/i.test(e)?"Android":"PC"}function v(e){var t=((null==e?"":e)+"").trim();return t&&/^\d+\.\d+\.\d+$/.test(t)?t:"1.0.0"}function w(e,t){return null==e||""===e?"":"int"===t?parseInt(e,10)+"":(e+"").replace(/^\s+|\s+$/g,"").toLowerCase()}function k(e,t){try{return e()}catch(e){return t}}function b(t){return e((null==t?"":t)+"").substr(0,32)}function C(e){return b((e||[]).slice().sort().join("|"))}function S(){return k(function(){var e,t,n,r,o,i,a,s="undefined"!=typeof OfflineAudioContext?OfflineAudioContext:"undefined"!=typeof webkitOfflineAudioContext?webkitOfflineAudioContext:null;if(!s)return"";if((t=(e=new s(1,44100,44100)).createOscillator()).type="triangle",t.frequency.value=1e4,n=e.createDynamicsCompressor(),t.connect(n),n.connect(e.destination),t.start(0),(r=e.startRendering())&&"function"==typeof r.then)return"";if(!r||!r.getChannelData)return"";for(o=r.getChannelData(0),i=0,a=4500;5e3>a&&o.length>a;a++)i+=Math.abs(o[a]);return b(i+"")},"")}function P(){return k(function(){function e(e){var t,n=document.createElement("span");return n.style.cssText="position:absolute;left:-9999px;top:-9999px;visibility:hidden;font-size:72px;font-family:"+e+",monospace;",n.textContent="mmmmmmmmmmlli",document.body.appendChild(n),t=n.offsetWidth,document.body.removeChild(n),t}var t,n,r,o;if("undefined"==typeof document||!document.body)return{hash:"",installed:[],count:-1};for(t=e("monospace"),n=[],r=0;$t.length>r;r++)e("'"+(o=$t[r])+"'")!==t&&n.push(o);return n.sort(),{hash:C(n.map(function(e){return e.toLowerCase()})),installed:n,count:n.length}},{hash:"",installed:[],count:-1})}function E(){return k(function(){var e,t,n,r,o,i,a;if("undefined"==typeof document)return{hash:"",supported:[]};for(e=document.createElement("video"),t=document.createElement("audio"),n=[],r=0;Zt.length>r;r++){i="video"===(o=Zt[r]).kind?e:t,a="";try{a=i.canPlayType?(i.canPlayType(o.type)||"")+"":""}catch(e){}a&&"no"!==a&&n.push(o.type+":"+a)}return n.sort(),{hash:C(n.map(function(e){return e.toLowerCase()})),supported:n}},{hash:"",supported:[]})}function T(e){var t;try{return"undefined"==typeof window?0:(t="session"===e?window.sessionStorage:window.localStorage)?(t.setItem("__sdk_probe__","1"),t.removeItem("__sdk_probe__"),1):0}catch(e){return 0}}function x(){try{return"undefined"!=typeof indexedDB?1:0}catch(e){return 0}}function D(){try{return"undefined"!=typeof navigator&&navigator.serviceWorker?1:0}catch(e){return 0}}function M(e){return/Trident|MSIE/i.test(e=(e||"")+"")?"trident":/Edge\/\d/i.test(e)&&!/Edg\//i.test(e)?"edgehtml":/Edg\//i.test(e)||/Chrome\//i.test(e)&&!/Edg\//i.test(e)?"chromium":/Firefox\//i.test(e)?"gecko":/Safari\//i.test(e)&&!/Chrome\//i.test(e)?"webkit":"unknown"}function I(e,t){return t=(t||"")+"",/HarmonyOS|OpenHarmony/i.test(e=(e||"")+"")?"harmonyos":/iPhone|iPad|iPod/i.test(e)?"ios":/Android/i.test(e)?"android":/Mac OS X|Macintosh/i.test(e)||/^Mac/i.test(t)?"macos":/Windows/i.test(e)||/^Win/i.test(t)?"windows":/Linux/i.test(e)||/Linux/i.test(t)?"linux":"unknown"}function A(e){return/iPad|Tablet/i.test(e=(e||"")+"")?"tablet":/Mobile|iPhone|Android/i.test(e)?"phone":"desktop"}function O(e){var t,n,r;for(e=(e||"")+"",t=[[/EdgiOS\/([\d.]+)/i,"Edge"],[/CriOS\/([\d.]+)/i,"Chrome"],[/FxiOS\/([\d.]+)/i,"Firefox"],[/Edg\/([\d.]+)/i,"Edge"],[/OPR\/([\d.]+)/i,"Opera"],[/Chrome\/([\d.]+)/i,"Chrome"],[/Firefox\/([\d.]+)/i,"Firefox"],[/Version\/([\d.]+).*Safari/i,"Safari"]],n=0;t.length>n;n++)if(r=e.match(t[n][0]))return{browser_name:t[n][1],browser_version:r[1]||"unknown"};return{browser_name:"unknown",browser_version:"unknown"}}function L(){var e=k(function(){var e,t="undefined"!=typeof screen?screen:null;if(t&&t.orientation&&t.orientation.type)return t.orientation.type+"";if("undefined"!=typeof window&&"number"==typeof window.orientation){if(0===(e=window.orientation)||180===e)return"portrait-primary";if(90===e||-90===e)return"landscape-primary"}return t&&null!=t.width&&null!=t.height?t.width>t.height?"landscape-primary":"portrait-primary":"unknown"},"unknown");if(-1!==e.indexOf("landscape"))return"landscape";if(-1!==e.indexOf("portrait"))return"portrait";try{if("undefined"!=typeof screen&&null!=screen.width&&null!=screen.height){if(screen.width>screen.height)return"landscape";if(screen.widthu;u++){l=s[u];try{null!=(d=t[l])&&(c[l]=t.getParameter(d)+"")}catch(e){}}for(_=Object.keys(c).sort(),p=[],f=0;_.length>f;f++)p.push((h=_[f])+"="+c[h]);return g=b(p.join("|")),m=b([n,r,a,g].join("|")),{vendor:n,renderer:r,extensions_hash:a,params_hash:g,webgl_fp:m}},{vendor:"",renderer:"",extensions_hash:"",params_hash:"",webgl_fp:""}),H=F.webgl_fp||"",U=S(),X=k(function(){var e,t,n;if("undefined"==typeof navigator||!navigator.plugins)return"";for(e=[],t=0;t0?Math.round(t):-1},-1),J=(R.platform||"")+"",Q=0;try{Q=R.plugins?R.plugins.length:0}catch(R){}e=G.count,t=H?"available":"unavailable",n=!0===R.webdriver?1:0,r=0,o=0;try{K&&K.documentElement&&(r=K.documentElement.clientWidth||B.innerWidth||0,o=K.documentElement.clientHeight||B.innerHeight||0)}catch(R){}i=0,(1===n||"available"!==t&&0===Q&&e>=0&&2>=e&&(0>=r||0>=o))&&(i=1),a="";try{"undefined"!=typeof Intl&&Intl.DateTimeFormat&&(a=Intl.DateTimeFormat().resolvedOptions().timeZone||"")}catch(R){}s=function(){try{return"undefined"==typeof location?"":(location.protocol||"")+"//"+(location.host||"")+(location.pathname||"")}catch(R){return""}}(),c="",u="",l="";try{"undefined"!=typeof location&&(c=location.hostname||"",u=location.pathname||"",location.search&&(l=b(location.search)))}catch(R){}_=function(e){var t,n=((e||"")+"").trim();if(!n)return{referer_domain:"",referer_path:""};try{return{referer_domain:(t=new URL(n)).hostname||"",referer_path:t.pathname||""}}catch(e){return{referer_domain:"",referer_path:""}}}(d=K&&K.referrer?K.referrer+"":""),p=function(e){var t,n,r,o;if(t=(e=e||{}).userAgent,/iPhone|iPad|iPod/i.test(t=(t||"")+"")&&!/CriOS\/|FxiOS\/|EdgiOS\/|OPiOS\//i.test(t)&&/Version\/[\d.]+.*Safari/i.test(t))return O(e.userAgent);try{if((n=e.userAgentData&&e.userAgentData.brands)&&n.length)for(r=0;n.length>r;r++)if((o=(n[r].brand||"")+"")&&"Chromium"!==o&&0!==o.indexOf("Not")&&"Not.A/Brand"!==o)return{browser_name:o,browser_version:(n[r].version||"unknown")+""}}catch(e){}return O(e.userAgent)}(R),f=z.supported?z.supported.length:-1,g=Math.round(96*(h=B.devicePixelRatio||1)),m=-1;try{null!=R.hardwareConcurrency&&R.hardwareConcurrency>0&&(m=R.hardwareConcurrency)}catch(R){}y=m,v=-1;try{null!=R.deviceMemory&&R.deviceMemory>0&&(v=Math.round(10*R.deviceMemory)/10)}catch(R){}return w=j&&j.type?j.type+"":"unknown",C=R.maxTouchPoints||0,{platform:"web",os_family:I(V,J),device_type:A(V),browser_kernel:M(V),browser_name:p.browser_name,browser_version:p.browser_version,browser_platform:J,display_mode:k(function(){return"undefined"==typeof window||"function"!=typeof window.matchMedia?"unknown":window.matchMedia("(display-mode: standalone)").matches?"standalone":window.matchMedia("(display-mode: fullscreen)").matches?"fullscreen":window.matchMedia("(display-mode: minimal-ui)").matches?"minimal-ui":"browser"},"unknown"),visibility_state:k(function(){if("undefined"==typeof document)return"unknown";var e=document.visibilityState;return"visible"===e||"hidden"===e?e:"unknown"},"unknown"),screen_width:q.width||0,screen_height:q.height||0,screen_dpi:g,device_pixel_ratio:Math.round(100*h),orientation:L(),viewport_width:r>0?Math.round(r):-1,viewport_height:o>0?Math.round(o):-1,color_depth:q.colorDepth||0,max_touch_points:C,touch_support:C>0?1:0,hardware_concurrency:m,cpu_cores:y,device_memory_gb:v,is_debug:0,language:R.language||"",timezone:a,cookie_enabled:R.cookieEnabled?1:0,local_storage_enabled:T("local"),session_storage_enabled:T("session"),indexeddb_enabled:x(),service_worker_enabled:D(),network_type:"unknown",connection_type:w,effective_type:j&&j.effectiveType?j.effectiveType+"":"unknown",downlink:j&&null!=j.downlink?Math.round(1e3*j.downlink):-1,rtt:j&&null!=j.rtt?j.rtt:-1,canvas_fp:N,webgl_fp:H,webgl_vendor:F.vendor||"",webgl_renderer:F.renderer||"",webgl_extensions_hash:F.extensions_hash||"",webgl_params_hash:F.params_hash||"",audio_fp:U,audio_sample_rate:W>0?W:44100,fonts:G.installed||[],fonts_hash:G.hash,plugins:Y,plugins_hash:X,mimes:z.supported||[],mimes_hash:z.hash,mime_count:f,plugin_count:Q,font_count:e,webgl_status:t,has_webdriver:n,is_headless:i,headless_reason:i?n?"webdriver":"composite":"",current_url:s,current_domain:c,current_path:u,current_query_hash:l,referer:d,referer_domain:_.referer_domain,referer_path:_.referer_path}}function q(e){return null==e?"":e+""} +// Keep OS fields in the common event envelope so IDs and signatures include them. +function hkSystemFields() { + var nav = typeof navigator === "undefined" ? {} : navigator; + var ua = nav.userAgent || "", platform = nav.platform || "", match; + var isDesktopIPad = /^Mac/i.test(platform) && nav.maxTouchPoints > 1; + if (/iPhone|iPad|iPod/i.test(ua) || isDesktopIPad) { + match = ua.match(/(?:CPU(?: iPhone)? OS|iPhone OS)\s+(\d+(?:[._]\d+)*)/i); + return { system_name: "iOS", system_version: match ? match[1].replace(/_/g, ".") : "" }; + } + if (/Android/i.test(ua)) { + match = ua.match(/Android\s+(\d+(?:[._]\d+)*)/i); + return { system_name: "Android", system_version: match ? match[1].replace(/_/g, ".") : "" }; + } + if (/Windows/i.test(ua) || /^Win/i.test(platform)) { + match = ua.match(/Windows NT\s+(\d+(?:\.\d+)*)/i); + return { system_name: "Windows", system_version: match ? match[1] : "" }; + } + if (/Mac OS X|Macintosh/i.test(ua) || /^Mac/i.test(platform)) { + match = ua.match(/Mac OS X\s+(\d+(?:[._]\d+)*)/i); + return { system_name: "macOS", system_version: match ? match[1].replace(/_/g, ".") : "" }; + } + if (/Linux/i.test(ua) || /Linux/i.test(platform)) { + return { system_name: "Linux", system_version: "" }; + } + return { system_name: "Other", system_version: "" }; +} +function B(e){var t=e||{},s=hkSystemFields(),n={system_name:s.system_name,system_version:s.system_version,app_id:(t.appId||"")+"",uid:null!=t.uid?t.uid:0,channel:(t.channel||"")+"",sid:(t.sid||"")+"",client_ts:Math.floor(Date.now()/1e3),device:(t.deviceType||"PC")+"",device_id:(t.deviceId||"")+"",device_brand:(t.deviceBrand||"")+"",device_model:(t.deviceModel||"")+"",device_fingerprint:(t.deviceFingerprint||"")+"",fp_version:(t.fpVersion||"")+"",user_agent:"undefined"!=typeof navigator&&navigator.userAgent||"",sdk_version:(t.sdkVersion||"")+"",app_version:(t.appVersion||"1.0.0")+""};return null!=t.deviceEnv&&(n.device_env=t.deviceEnv),n}function K(){var e,t,n,r=new Uint8Array(16);if("undefined"!=typeof crypto&&crypto.getRandomValues)crypto.getRandomValues(r);else for(e=0;16>e;e++)r[e]=Math.floor(256*Math.random());for(t="",n=0;r.length>n;n++)t+=("0"+r[n].toString(16)).slice(-2);return t}function V(){try{return!("undefined"==typeof window||!window.sessionStorage||(window.sessionStorage.setItem("__sdk_ss_ping__","1"),window.sessionStorage.removeItem("__sdk_ss_ping__"),0))}catch(e){return!1}}function j(){if(!un){if(un=!0,function(){try{return!("undefined"==typeof window||!window.localStorage||(window.localStorage.setItem("__web_sdk_ping__","1"),window.localStorage.removeItem("__web_sdk_ping__"),0))}catch(e){return!1}}())try{window.localStorage.removeItem(rn)}catch(e){}if("undefined"!=typeof document&&"string"==typeof document.cookie)try{var t="path=/;max-age=0;SameSite=Lax";"undefined"!=typeof location&&"https:"===location.protocol&&(t+=";Secure"),document.cookie=encodeURIComponent(on)+"=;"+t}catch(e){}}}function N(t){j();var n=function(e){return JSON.stringify({sid:e.sid,sessionStartTs:e.sessionStartTs,lastValidBehaviorAt:e.lastValidBehaviorAt})}(t);if(V())try{window.sessionStorage.setItem(tn,t.sid),window.sessionStorage.setItem(nn,n)}catch(e){}cn=t}function F(){if(cn)return cn;j();var t=null;if(V())try{t=window.sessionStorage.getItem(nn)}catch(e){}return cn=t?function(t){try{var n=JSON.parse(t);return n&&n.sid?{sid:n.sid+"",sessionStartTs:"number"==typeof n.sessionStartTs?n.sessionStartTs:Date.now(),lastValidBehaviorAt:"number"==typeof n.lastValidBehaviorAt?n.lastValidBehaviorAt:Date.now()}:null}catch(e){return null}}(t):null,cn}function H(e){return{sid:K(),sessionStartTs:e,lastValidBehaviorAt:e}}function U(e){return"number"==typeof e&&isFinite(e)?1e12>e?1e3*e:e:null}function X(e,t,n){var r=U(e.sessionStartTs)||n,o=U(e.lastValidBehaviorAt)||r,i=n;return"idle"===t?i=Math.min(i=Math.max(r,o+an),n):"forced"===t&&(i=Math.min(r+sn,n)),Math.max(r,i)}function Y(e,t,n){var r,o;return n=n||Date.now(),r=U(e.sessionStartTs)||n,o=X(e,t,n),{sid:e.sid,sessionStartTs:r,lastValidBehaviorAt:U(e.lastValidBehaviorAt)||r,sessionEndTs:o,endReason:t}}function G(e){var t,n,r,o,i;return e=e||Date.now(),(t=F())?(r=e-t.sessionStartTs>sn,e-t.lastValidBehaviorAt>an||r?(o=Y(t,r?"forced":"idle",e),N(i=H(e)),{sid:i.sid,isNew:!0,endedSession:o}):{sid:t.sid,isNew:!1}):(N(n=H(e)),{sid:n.sid,isNew:!0})}function z(){var e=F();return e?e.sid:""}function W(){cn=null}function J(){try{return!("undefined"==typeof window||!window.localStorage||(window.localStorage.setItem(dn,"1"),window.localStorage.removeItem(dn),0))}catch(e){return!1}}function Q(){var t,n;if(!J())return Promise.resolve(_n||"NULL");try{return(t=window.localStorage.getItem(ln))&&/^[0-9a-f]{32}$/.test(t)?Promise.resolve(t):(n=function(){var e,t,n,r=new Uint8Array(16);if("undefined"!=typeof crypto&&crypto.getRandomValues)crypto.getRandomValues(r);else for(e=0;16>e;e++)r[e]=Math.floor(256*Math.random());for(t="",n=0;r.length>n;n++)t+=("0"+r[n].toString(16)).slice(-2);return t}(),window.localStorage.setItem(ln,n),_n=n,Promise.resolve(n))}catch(e){return Promise.resolve("NULL")}}function $(t,n){n&&(t.audio_fp=n),fn=t;var r=function(){if(!J())return"";try{var t=window.localStorage.getItem(pn);if(t&&/^[0-9a-f]{16,64}$/i.test(t))return t.toLowerCase()}catch(e){}return""}();return r?hn=r:(hn=function(e){var t,n,r,o,i,a;for(t=e||{},n={screen_width:1,screen_height:1,screen_dpi:1,device_pixel_ratio:1,color_depth:1,max_touch_points:1,cpu_cores:1,cookie_enabled:1,local_storage_enabled:1,session_storage_enabled:1,indexeddb_enabled:1,service_worker_enabled:1},r={canvas_fp:1,webgl_fp:1,audio_fp:1,fonts_hash:1,plugins_hash:1,mimes_hash:1},o=[],i=0;Qt.length>i;i++)o.push((a=Qt[i])+"="+w(t[a],n[a]?"int":r[a]?"hash":"string"));return d("fp_v3|fingerprint_platform=web|"+o.join("|"))}(t),function(t){if(t&&J())try{window.localStorage.setItem(pn,t)}catch(e){}}(hn)),{snapshot:t,fingerprint:hn}}function Z(){return fn&&hn?Promise.resolve({snapshot:fn,fingerprint:hn}):gn||(gn=new Promise(function(e){k(function(){var t,n,r,o,i="undefined"!=typeof OfflineAudioContext?OfflineAudioContext:"undefined"!=typeof webkitOfflineAudioContext?webkitOfflineAudioContext:null;i?((n=(t=new i(1,44100,44100)).createOscillator()).type="triangle",n.frequency.value=1e4,r=t.createDynamicsCompressor(),n.connect(r),r.connect(t.destination),n.start(0),(o=t.startRendering())&&"function"==typeof o.then?o.then(function(t){for(var n=t.getChannelData(0),r=0,o=4500;5e3>o&&n.length>o;o++)r+=Math.abs(n[o]);e(b(r+""))}).catch(function(){e("")}):e(S())):e("")},e(""))}).then(function(e){return $(R(),e)}).catch(function(){return $(R(),"")}),gn)}function ee(){return hn}function te(){return Wt}function ne(){return mn.digest()}function re(){try{if("undefined"!=typeof screen&&screen.orientation&&screen.orientation.type){var t=screen.orientation.type+"";if(-1!==t.indexOf("landscape"))return"landscape";if(-1!==t.indexOf("portrait"))return"portrait"}if("undefined"!=typeof screen&&null!=screen.width&&null!=screen.height){if(screen.width>screen.height)return"landscape";if(screen.width0?Math.round(n):-1}catch(e){return-1}}function ie(e,t){var n=t.accumulated_ms||0;return null!=t.resume_at_ms&&(n+=Math.max(0,e-t.resume_at_ms)),n}function ae(){return xn.pageSeqCounter++,xn.currentPageSeq=xn.pageSeqCounter,xn.currentPageSeq}function se(){try{return!("undefined"==typeof window||!window.sessionStorage||(window.sessionStorage.setItem("__sdk_metrics_ping__","1"),window.sessionStorage.removeItem("__sdk_metrics_ping__"),0))}catch(e){return!1}}function ce(e){var t,n=e||xn;return{eventCountTotal:n.eventCountTotal||0,businessEventCount:n.businessEventCount||0,pageViewCount:n.pageViewCount||0,pageClickCount:n.pageClickCount||0,networkChangeCount:n.networkChangeCount||0,backgroundCount:n.backgroundCount||0,pageKeys:le({},n.pageKeys||{}),pageStayList:(n.pageStayList||[]).slice(),pageSeqCounter:n.pageSeqCounter||0,currentPageSeq:n.currentPageSeq||0,lastEventName:n.lastEventName||"",scrollTotalCount:n.scrollTotalCount||0,scrollTotalDistance:n.scrollTotalDistance||0,scrollUpCount:n.scrollUpCount||0,scrollDownCount:n.scrollDownCount||0,scrollDirectionChangeCount:n.scrollDirectionChangeCount||0,lastScrollDirection:n.lastScrollDirection||0,firstScrollDelayMs:null!=n.firstScrollDelayMs?n.firstScrollDelayMs:-1,hasScrolled:!!n.hasScrolled,maxScrollDepthPercent:n.maxScrollDepthPercent||0,isReachBottom:n.isReachBottom||0,keydownTotalCount:n.keydownTotalCount||0,inputCount:n.inputCount||0,focusTotalCount:n.focusTotalCount||0,firstInteractionDelayMs:null!=n.firstInteractionDelayMs?n.firstInteractionDelayMs:-1,firstInteractionRecorded:!!n.firstInteractionRecorded,lastOrientation:n.lastOrientation||"unknown",touchTotalCount:n.touchTotalCount||0,swipeTotalCount:n.swipeTotalCount||0,longPressTotalCount:n.longPressTotalCount||0,pinchTotalCount:n.pinchTotalCount||0,currentPage:(t=n.currentPage,t&&"object"==typeof t?{page_key:(t.page_key||"")+"",page_name:(t.page_name||"")+"",enter_time:null!=t.enter_time?t.enter_time:Date.now(),enter_time_ms:null!=t.enter_time_ms?t.enter_time_ms:t.enter_time,page_seq_in_sid:t.page_seq_in_sid||0,has_interaction:!!t.has_interaction,accumulated_ms:t.accumulated_ms||0,resume_at_ms:null!=t.resume_at_ms?t.resume_at_ms:null,hidden_paused:!!t.hidden_paused,stay_duration_ms:t.stay_duration_ms}:null)}}function ue(e,t){return null==e||0>e||t>0&&e>t?-1:e}function le(e){var t,n,r,o,i=e||{};for(t=1;arguments.length>t;t++)if(n=arguments[t])for(r=Object.keys(n),o=0;r.length>o;o++)i[r[o]]=n[r[o]];return i}function de(e){return kn+":"+(e||"")}function _e(t){var n,r;if(!t)return null;if(n=null,se())try{n=window.sessionStorage.getItem(de(t))}catch(e){}if(!n&&ye())try{n=window.localStorage.getItem(de(t))}catch(e){}if(!n)return null;try{return(r=JSON.parse(n))&&r.sid===t?r:null}catch(e){return null}}function pe(t,n){if(t){var r=JSON.stringify(n||{});if(se())try{window.sessionStorage.setItem(de(t),r)}catch(e){}if(ye())try{window.localStorage.setItem(de(t),r)}catch(e){}}}function fe(e){if(!e)return null;var t=_e(e);return t&&t.metrics?t.metrics:null}function he(){var e,t=z();t&&(Cn?Sn||(Sn=t):(Cn=!0,(e=_e(t))&&e.metrics?(xn=ce(e.metrics),e.sessionStartedAt&&(Dn=e.sessionStartedAt),Sn=t):Sn=t))}function ge(e){var t=_e(e);return t&&t.behaviorHash?t.behaviorHash:""}function me(){var e=z();e&&(Sn&&Sn!==e||(Sn||(Sn=e),function(e){e&&pe(e,{sid:e,savedAt:Date.now(),sessionStartedAt:Dn,behaviorHash:ne(),metrics:ce(xn)})}(e)))}function ye(){try{return!("undefined"==typeof window||!window.localStorage||(window.localStorage.setItem("__sdk_metrics_ping__","1"),window.localStorage.removeItem("__sdk_metrics_ping__"),0))}catch(e){return!1}}function ve(){var t,n;if(!ye())return[];try{return(t=window.localStorage.getItem(wn))?(n=JSON.parse(t),Array.isArray(n)?n:[]):[]}catch(e){return[]}}function we(t){if(ye())try{window.localStorage.setItem(wn,JSON.stringify(t||[]))}catch(e){}}function ke(e){we(ve().filter(function(t){return t&&t.sid!==e}))}function be(e){for(var t,n=e.slice(0,yn);n.length>0;){if(t=JSON.stringify(n),vn>=t.length)return n;n.pop()}return n}function Ce(e){var t,n=xn.currentPage;n&&(n.stay_duration_ms=ie(e,n),t={page_key:n.page_key,page_name:n.page_name||"",stay_duration_ms:n.stay_duration_ms,enter_time:Math.floor(n.enter_time/1e3),has_interaction:n.has_interaction?1:0},n.page_seq_in_sid&&(t.page_seq_in_sid=n.page_seq_in_sid),xn.pageStayList.push(t),xn.pageStayList=be(xn.pageStayList),xn.currentPage=null)}function Se(){xn.firstInteractionRecorded||(xn.firstInteractionRecorded=!0,xn.firstInteractionDelayMs=Math.max(0,Date.now()-Dn))}function Pe(){xn.lastOrientation=re()}function Ee(e){e&&xn.currentPage&&xn.currentPage.page_key===e&&(xn.currentPage.has_interaction=!0)}function Te(e,t,n,r){var o=Date.now();Ce(o),xn.currentPage={page_key:(e||"")+"",page_name:(t||"")+"",enter_time:null!=n?1e3*n:o,enter_time_ms:o,page_seq_in_sid:r||xn.currentPageSeq||0,has_interaction:!1,accumulated_ms:0,resume_at_ms:o,hidden_paused:!1}}function xe(){Ce(Date.now())}function De(t){var n,r=t;if(null==r)return null;if("string"==typeof r)try{r=JSON.parse(r)}catch(e){return null}return r&&"object"==typeof r?(n={page_key:(r.page_key||"")+"",page_name:(r.page_name||"")+"",stay_duration_ms:null!=r.stay_duration_ms?r.stay_duration_ms:0,enter_time:null!=r.enter_time?r.enter_time:0,has_interaction:r.has_interaction?1:0},null!=r.page_seq_in_sid&&(n.page_seq_in_sid=r.page_seq_in_sid),n):null}function Me(e,t,n){var r,o,i,a,s,c,u,l,d,_,p,f=e||{},h=f.sid||z(),g=h&&h===z();for(n?r=ce(n):g?(xe(),me(),r=ce(xn)):r=function(e){var t,n;return he(),!(t=e||z())||t!==z()||Sn&&Sn!==t?(n=fe(t))?ce(n):Sn&&Sn!==t?{eventCountTotal:0,businessEventCount:0,pageViewCount:0,pageClickCount:0,networkChangeCount:0,backgroundCount:0,pageKeys:{},pageStayList:[],pageSeqCounter:0,currentPageSeq:0,lastEventName:"",scrollTotalCount:0,scrollTotalDistance:0,scrollUpCount:0,scrollDownCount:0,scrollDirectionChangeCount:0,lastScrollDirection:0,firstScrollDelayMs:-1,hasScrolled:!1,maxScrollDepthPercent:0,isReachBottom:0,keydownTotalCount:0,inputCount:0,focusTotalCount:0,firstInteractionDelayMs:-1,firstInteractionRecorded:!1,lastOrientation:"unknown",touchTotalCount:0,swipeTotalCount:0,longPressTotalCount:0,pinchTotalCount:0,currentPage:null}:ce(xn):ce(xn)}(h),o=function(e,t){var n=Date.now(),r=e||{},o=U(r.sessionStartTs)||n,i=null!=r.sessionEndTs?U(r.sessionEndTs):X(r,t||"idle",n);return{startTs:o,endTs:i=Math.max(o,i||n)}}(f,t),s=Math.max(0,(a=o.endTs)-(i=o.startTs)),c=le({},r.pageKeys||{}),u=function(e){var t,n,r=[];for(t=0;(e||[]).length>t;t++)(n=De(e[t]))&&r.push(n);return r}(function(e){var t,n,r,o={},i=[];for(t=0;e.length>t;t++)null!=(r=(n=e[t])&&n.page_seq_in_sid)&&o[r]||(null!=r&&(o[r]=!0),i.push(n));return i}((r.pageStayList||[]).slice()).sort(function(e,t){return(e.page_seq_in_sid||0)-(t.page_seq_in_sid||0)})),u=function(e,t,n){var r,o,i,a=Math.floor((t||0)/1e3),s=Math.floor((n||Date.now())/1e3),c=Math.max(0,a-5),u=[];for(r=0;(e||[]).length>r;r++)(o=e[r])&&((i=null!=o.enter_time?+o.enter_time:0)>0&&c>i||i>s+5||u.push(o));return u}(u,i,a),l=0;u.length>l;l++)(d=u[l]&&u[l].page_key?u[l].page_key+"":"")&&(c[d]=1);return _=Object.keys(c),p=n?ge(h)||ne():g?ne():ge(h)||ne(),{session_start_ts:Math.floor(i/1e3),session_end_ts:Math.floor(a/1e3),session_duration_ms:s,event_count_total:r.eventCountTotal||0,business_event_count:r.businessEventCount||0,page_count:_.length,page_view_count:r.pageViewCount||0,page_click_count:r.pageClickCount||0,session_end_reason:(t||"idle")+"",last_event_name:r.lastEventName||"",page_stay_list:u,behavior_sequence_hash:p,network_change_count:r.networkChangeCount||0,scroll_total_count:r.scrollTotalCount||0,scroll_total_distance_px:Math.round(r.scrollTotalDistance||0),scroll_up_count:r.scrollUpCount||0,scroll_down_count:r.scrollDownCount||0,scroll_direction_change_count:r.scrollDirectionChangeCount||0,first_scroll_delay_ms:ue(null!=r.firstScrollDelayMs?r.firstScrollDelayMs:-1,s),max_scroll_depth_percent:r.maxScrollDepthPercent||0,is_reach_bottom:r.isReachBottom||0,content_height:oe(),keydown_total_count:r.keydownTotalCount||0,input_count:r.inputCount||0,focus_total_count:r.focusTotalCount||0,first_interaction_delay_ms:ue(null!=r.firstInteractionDelayMs?r.firstInteractionDelayMs:-1,s),orientation:r.lastOrientation||re(),touch_total_count:r.touchTotalCount||0,swipe_total_count:r.swipeTotalCount||0,long_press_total_count:r.longPressTotalCount||0,pinch_total_count:r.pinchTotalCount||0}}function Ie(){On=!0,setTimeout(function(){On=!1},2e3)}function Ae(e){var t,n,r,o,i=e||{};for(t=1;arguments.length>t;t++)if(n=arguments[t])for(r=Object.keys(n),o=0;r.length>o;o++)i[r[o]]=n[r[o]];return i}function Oe(t){return e((null==t?"":t)+"").substr(0,32)}function Le(e){return null!=e&&""!==(e+"").trim()}function Re(){try{return"undefined"==typeof location?"":(location.protocol||"")+"//"+(location.host||"")+(location.pathname||"")}catch(e){return""}}function qe(e){var t=(null==e?"":e)+"";return(t=(t=(t=t.replace(/\.html?$/i,"")).replace(/[^a-zA-Z0-9_]+/g,"_")).replace(/_+/g,"_").replace(/^_|_$/g,""))||"index"}function Be(){try{return qe(("undefined"!=typeof location&&location.pathname?location.pathname:"/").replace(/^\/+|\/+$/g,"").replace(/\//g,"_")||"index")}catch(e){return"index"}}function Ke(){try{return document.scrollingElement||document.documentElement||document.body||null}catch(e){return null}}function Ve(){try{var t=Ke();return t?{scroll_top:Math.max(0,Math.round(t.scrollTop||0)),scroll_left:Math.max(0,Math.round(t.scrollLeft||0))}:{scroll_top:-1,scroll_left:-1}}catch(e){return{scroll_top:-1,scroll_left:-1}}}function je(e,t){if(null==e||!isFinite(e))return-1;var n=Math.round(+e);return 0>n&&(n=0),null!=t&&t>0&&n>t&&(n=t),n}function Ne(){var t,n,r;try{return(t=document.documentElement||document.body)?(r=t.scrollHeight||0,(n=t.scrollWidth||0)>0&&r>0?{page_width:Math.round(n),page_height:Math.round(r)}:{page_width:-1,page_height:-1}):{page_width:-1,page_height:-1}}catch(e){return{page_width:-1,page_height:-1}}}function Fe(){var t,n;try{return"undefined"==typeof window?{viewport_width:-1,viewport_height:-1}:(t=window.innerWidth||0,n=window.innerHeight||0,t>0&&n>0?{viewport_width:Math.round(t),viewport_height:Math.round(n)}:{viewport_width:-1,viewport_height:-1})}catch(e){return{viewport_width:-1,viewport_height:-1}}}function He(){var t,n;try{return"undefined"==typeof screen?{screen_width:-1,screen_height:-1}:(t=screen.width||0,n=screen.height||0,t>0&&n>0?{screen_width:Math.round(t),screen_height:Math.round(n)}:{screen_width:-1,screen_height:-1})}catch(e){return{screen_width:-1,screen_height:-1}}}function Ue(){var t,n,r;try{if("undefined"!=typeof performance&&performance.timing&&(t=performance.timing).loadEventEnd&&t.navigationStart&&t.loadEventEnd>0)return Math.max(0,t.loadEventEnd-t.navigationStart);if("undefined"!=typeof performance&&"function"==typeof performance.getEntriesByType&&(n=performance.getEntriesByType("navigation"))&&n.length>0&&(r=n[0]).loadEventEnd>0&&r.fetchStart>=0)return Math.max(0,Math.round(r.loadEventEnd-r.fetchStart))}catch(e){}return 0}function Xe(e,t){return t>0?Math.min(100,Math.max(0,Math.round(e/t*100))):0}function Ye(e){return e?{clientX:null!=e.clientX?e.clientX:null,clientY:null!=e.clientY?e.clientY:null,pageX:null!=e.pageX?e.pageX:null,pageY:null!=e.pageY?e.pageY:null}:{}}function Ge(e){var t,n,r,o,i,a,s,c,u=e||{},l=Fe(),d=Ne(),_=Ve(),p=function(e){return!!e&&(null!=e.clientX||null!=e.clientY||null!=e.pageX||null!=e.pageY)}(u),f=d.page_width>0&&d.page_height>0,h=He(),g=f&&_.scroll_top>=0?_.scroll_top:0,m=f&&_.scroll_left>=0?_.scroll_left:0,y=l.viewport_width>0?l.viewport_width:-1,v=l.viewport_height>0?l.viewport_height:-1;return p?(n=null!=u.clientY?+u.clientY:null,r=null!=u.pageX?+u.pageX:null,o=null!=u.pageY?+u.pageY:null,null!=(t=null!=u.clientX?+u.clientX:null)||null!=n?(i=null!=t&&isFinite(t)?t:0,a=null!=n&&isFinite(n)?n:0,s=f?null!=r&&isFinite(r)?r:i+m:-1,c=f?null!=o&&isFinite(o)?o:a+g:-1):(s=f&&null!=r&&isFinite(r)?r:-1,c=f&&null!=o&&isFinite(o)?o:-1,i=null!=r&&isFinite(r)?r-m:0,a=null!=o&&isFinite(o)?o-g:0),i=je(i,y),a=je(a,v),f&&(s=je(s,d.page_width),c=je(c,d.page_height)),Ae({click_viewport_x:i,click_viewport_y:a,click_document_x:f?s:-1,click_document_y:f?c:-1,click_page_x:i,click_page_y:a,click_x_percent:y>0?Xe(i,y):-1,click_y_percent:v>0?Xe(a,v):-1,screen_width:h.screen_width,screen_height:h.screen_height},l,f?d:{page_width:-1,page_height:-1},f?_:{scroll_top:-1,scroll_left:-1})):{click_viewport_x:-1,click_viewport_y:-1,click_document_x:-1,click_document_y:-1,click_page_x:-1,click_page_y:-1,click_x_percent:-1,click_y_percent:-1,screen_width:h.screen_width,screen_height:h.screen_height,viewport_width:l.viewport_width,viewport_height:l.viewport_height,page_width:f?d.page_width:-1,page_height:f?d.page_height:-1,scroll_top:f?_.scroll_top:-1,scroll_left:f?_.scroll_left:-1}}function ze(t){var n=t||{},r=qe(n.pageKey||Be()),o=n.pageName||"undefined"!=typeof document&&document.title||r,i=function(){try{return"undefined"==typeof location?{}:{page_url:Re(),page_domain:(location.hostname||"")+"",page_path:(location.pathname||"")+"",page_query_hash:location.search?Oe(location.search):""}}catch(e){return{}}}(),a=Fe();return{user_type:"",page_key:r,page_name:o,referrer_page_key:(n.previousPageKey||"")+"",referrer_page_name:(n.previousPageName||"")+"",current_page_key:r,current_page_name:o,recommend_trace_id:"",page_url:i.page_url,viewport_width:a.viewport_width,viewport_height:a.viewport_height,is_sdk_auto_collected:1}}function We(e){var t,n,r,o,i=e||{};for(t=1;arguments.length>t;t++)if(n=arguments[t])for(r=Object.keys(n),o=0;r.length>o;o++)i[r[o]]=n[r[o]];return i}function Je(e,t){var n,r;return"app_session_summary"===t?We({},e.summaryFields||{},e.payload||{}):(n=function(e){var t=We({},e||{});return delete t._metricsRecorded,delete t._pageSeqPrealloc,delete t._clickSnap,t}(e.payload||{}),r={event:1,payload:1,event_id:1,summaryFields:1,_pageSeqInSid:1,_metricsRecorded:1,_sdkAutoClick:1,_pageSeqPrealloc:1},Object.keys(e).forEach(function(t){r[t]||(n[t]=e[t])}),n)}function Qe(e,t,n,r,o){var i,a,s,c,u,d,h,g=e||{},y=((n.event||"")+"").trim(),v=n.lifecycle_event_key||n.payload&&n.payload.lifecycle_event_key||"";return function(e,t){var n,r=(e||"")+"";return"app_page_view"===r||"page_click"===r||("app_lifecycle"===r?"cold_start"==(n=(t||"")+"")||"web_load"===n||"warm_start"===n||"foreground"===n:"sdk_init"!==r&&"network_state_change"!==r&&"app_session_summary"!==r&&"background"!==r)}(y,v)&&(c=Date.now(),(u=F())?(u.lastValidBehaviorAt=c,N(u)):N(H(c))),i="",n.payload&&null!=n.payload.page_key?i=n.payload.page_key+"":null!=n.page_key&&(i=n.page_key+""),function(e,t,n){"app_lifecycle"===e&&t&&en[t]?mn.append(t,n):("app_page_view"===e||"page_click"===e||e&&!Tn[e])&&mn.append(e,n)}(y,v,i),a=function(e,t){return B({appId:e.appId,channel:e.channel,uid:e.uid,sid:t,deviceId:(n=e.deviceId,((null==n?"":n)+"").trim()||"NULL"),deviceType:e.deviceType||"PC",deviceBrand:e.deviceBrand||"",deviceModel:e.deviceModel||"",deviceFingerprint:ee(),fpVersion:te(),sdkVersion:e.sdkVersion,appVersion:e.appVersion});var n}(g,t),function(e,t){var n,r;if(he(),(n=(e||"")+"")&&"app_session_summary"!==n){if("app_page_view"===n&&t&&t._metricsRecorded)return xn.lastEventName=n,void me();xn.eventCountTotal++,xn.lastEventName=n,Tn[n]||xn.businessEventCount++,r=t&&null!=t.page_key?t.page_key+"":"","app_page_view"===n?(xn.pageViewCount++,r&&(xn.pageKeys[r]=1)):"page_click"===n?(xn.pageClickCount++,Ee(r)):"network_state_change"===n?xn.networkChangeCount++:r&&Ee(r),me()}}(y,n.payload||{}),s=function(e,t){return function(e,t){for(var n=t||{},r={},o=Object.keys(e||{}),i=0;o.length>i;i++)r[o[i]]=e[o[i]];return r.event_sig_version=(n.eventSigVersion||Jt)+"",r.event_sig=n.hmacKey?function(e,t){return t?function(e,t){var n,r,o,i,a=_((null==t?"":t)+"");for(a.length>64&&(a=p(l(a)));64>a.length;)a.push(0);for(n=[],r=[],o=0;64>o;o++)n.push(54^a[o]),r.push(92^a[o]);return i=p(l(f(n,_((null==e?"":e)+"")))),l(f(r,i))}(function(e){var t=e||{};return[q(t.event),q(t.event_id),q(t.app_id),q(t.client_ts),q(t.device_fingerprint)].join("|")}(e),t+""):""}(e,n.hmacKey):"",r}(e,{hmacKey:t&&t.hmacKey?t.hmacKey:"",eventSigVersion:Jt})}(function(e,t,n){return function(e,t){var n=function(){var e,t,n,r,o;for(e={},t=0;arguments.length>t;t++)if(n=arguments[t])for(r=Object.keys(n),o=0;r.length>o;o++)e[r[o]]=n[r[o]];return e}(e,t);return n.event_id||(n.event_id=m(n)),n}(e,{event:n,payload:Je(t,n)})}(a,n,y),r),"app_page_view"===y&&n.payload&&n.payload.page_key&&(n.payload._metricsRecorded||n._metricsRecorded||Te(n.payload.page_key,n.payload.page_name,n.payload.page_enter_ts,null!=n._pageSeqInSid?n._pageSeqInSid:n.payload.page_seq_in_sid)),"page_click"===y&&n.payload&&!n._sdkAutoClick&&An.push({x:null!=(d=null!=n.payload.click_viewport_x?n.payload.click_viewport_x:n.payload.click_page_x)?d:0,y:null!=(h=null!=n.payload.click_viewport_y?n.payload.click_viewport_y:n.payload.click_page_y)?h:0,t:Date.now()}),s._sessionInfo=o||null,s}function $e(t,n,r){var o=We({},n||{}),i=((o.event||"")+"").trim();return Bn[i]&&o.payload&&1!==o.payload.is_sdk_auto_collected&&(o.payload=function(e,t,n){var r,o=e||{},i=1===o.is_sdk_auto_collected,a=((n||"")+"").trim(),s="app_page_view"===a||null!=o.page_key&&(null!=o.current_page_key||null!=o.user_type),c="page_click"===a,u={}||{},l=qe(o.page_key||Be());return r=s?ze(Ae(u,{pageKey:l,pageName:o.page_name})):c?Ae({page_key:l,page_name:o.page_name||"undefined"!=typeof document&&document.title||l,recommend_trace_id:"",referrer_page_key:u.previousPageKey||""},function(e){var t=e||{},n=Fe(),r=He(),o=Ne(),i=Ve(),a=o.page_width>0&&o.page_height>0,s=a&&i.scroll_top>=0?i.scroll_top:0,c=a&&i.scroll_left>=0?i.scroll_left:0,u=n.viewport_width>0?n.viewport_width:-1,l=n.viewport_height>0?n.viewport_height:-1,d=null!=t.click_page_x?+t.click_page_x:null,_=null!=t.click_page_y?+t.click_page_y:null,p=-1,f=-1,h=-1,g=-1;return null!=d&&isFinite(d)&&null!=_&&isFinite(_)?(p=d,f=_,a&&(h=d+c,g=_+s)):null!=t.click_x_percent&&null!=t.click_y_percent&&u>0&&l>0&&(p=+t.click_x_percent/100*u,f=+t.click_y_percent/100*l,a&&(h=p+c,g=f+s)),-1!==p&&(p=je(p,u)),-1!==f&&(f=je(f,l)),a&&-1!==h&&(h=je(h,o.page_width)),a&&-1!==g&&(g=je(g,o.page_height)),{click_viewport_x:p,click_viewport_y:f,click_document_x:a?h:-1,click_document_y:a?g:-1,screen_width:r.screen_width,screen_height:r.screen_height,viewport_width:n.viewport_width,viewport_height:n.viewport_height,page_width:a?o.page_width:-1,page_height:a?o.page_height:-1,scroll_top:a?i.scroll_top:-1,scroll_left:a?i.scroll_left:-1}}(o)):{page_key:l,page_name:o.page_name||"undefined"!=typeof document&&document.title||l,recommend_trace_id:"",referrer_page_key:u.previousPageKey||""},i||s?i&&(r.is_sdk_auto_collected=1):delete r.is_sdk_auto_collected,function(e,t){var n,r,o=Ae({},e||{}),i=Object.keys(t||{});for(n=0;i.length>n;n++)Le(o[r=i[n]])||(o[r]=t[r]);return o}(o,r)}(o.payload,0,i)),Z().then(function(){var n,a=G();if(a.endedSession&&qn)try{qn(a.endedSession)}catch(e){}return n=a.sid||z(),Bn[i]&&o.payload&&function(t,n){var r,o,i,a=((t.event||"")+"").trim();Bn[a]&&t.payload&&("app_page_view"===a?1===t.payload.is_sdk_auto_collected?(r=t.payload._pageSeqPrealloc||ae(),o=t.payload._metricsRecorded,null!=t.payload._pageSeqPrealloc&&delete t.payload._pageSeqPrealloc,t.payload=function(e,t,n){var r=e||{},o=Fe(),i=Math.floor(Date.now()/1e3),a=Oe((t||"")+"|"+(n||0)+"|"+i),s=Ue(),c=qe(r.page_key||""),u=(r.page_name||"")+"";return{user_type:(null!=r.user_type?r.user_type:"")+"",page_key:c,page_name:u,referrer_page_key:(null!=r.referrer_page_key?r.referrer_page_key:"")+"",referrer_page_name:(null!=r.referrer_page_name?r.referrer_page_name:"")+"",current_page_key:(null!=r.current_page_key?r.current_page_key:c)+"",current_page_name:(null!=r.current_page_name?r.current_page_name:u)+"",recommend_trace_id:(null!=r.recommend_trace_id?r.recommend_trace_id:"")+"",page_id:a,page_enter_ts:i,page_url:r.page_url||Re(),page_load_time:null!=s?s:0,viewport_width:o.viewport_width,viewport_height:o.viewport_height}}(t.payload,n,r),t._pageSeqInSid=r,o&&(t._metricsRecorded=1)):(i=function(t,n){var r,o=t||{},i=null!=o.page_enter_ts?o.page_enter_ts:Math.floor(Date.now()/1e3),a=o.page_seq_in_sid||ae();return{page_seq_in_sid:a,page_id:o.page_id||e((null==(r=(n||"")+"|"+a+"|"+i)?"":r)+"").substr(0,32),page_enter_ts:i}}(t.payload,n),t.payload=We(t.payload,i)):"page_click"===a&&1===t.payload.is_sdk_auto_collected&&(t.payload=function(e){var t=e||{},n=Ge(t._clickSnap||t);return{page_key:qe(t.page_key||""),page_name:(t.page_name||"")+"",recommend_trace_id:(null!=t.recommend_trace_id?t.recommend_trace_id:"")+"",referrer_page_key:(null!=t.referrer_page_key?t.referrer_page_key:"")+"",click_viewport_x:n.click_viewport_x,click_viewport_y:n.click_viewport_y,click_document_x:n.click_document_x,click_document_y:n.click_document_y,click_page_x:n.click_page_x,click_page_y:n.click_page_y,click_x_percent:n.click_x_percent,click_y_percent:n.click_y_percent,screen_width:n.screen_width,screen_height:n.screen_height,viewport_width:n.viewport_width,viewport_height:n.viewport_height,page_width:n.page_width,page_height:n.page_height,scroll_top:n.scroll_top,scroll_left:n.scroll_left}}(t.payload),t._sdkAutoClick=1,delete t.payload.is_sdk_auto_collected,delete t.payload._clickSnap))}(o,n),Qe(t,n,o,r,a)})}function Ze(){try{return"undefined"==typeof location?"":(location.protocol||"")+"//"+(location.host||"")+(location.pathname||"")}catch(e){return""}}function et(){var t,n;try{return(t=function(){try{return document.scrollingElement||document.documentElement||document.body||null}catch(e){return null}}())&&(n=Math.max(0,(t.scrollHeight||0)-(t.clientHeight||0)))>0?Math.min(100,Math.max(0,Math.round((t.scrollTop||0)/n*100))):0}catch(e){return 0}}function tt(){if(On)return"navigation";try{if("hidden"===document.visibilityState)return"visibility_hidden"}catch(e){}return"unknown"}function nt(){return function(){if(xn.currentPage&&xn.currentPage.page_key)return xn.currentPage.page_key;var e=xn.pageStayList||[];return e.length>0&&e[e.length-1].page_key||""}()||Be()}function rt(){var t,n="unknown";try{n="visible"===document.visibilityState||"hidden"===document.visibilityState?document.visibilityState:"unknown"}catch(e){}return{page_url:Ze(),unload_reason:tt(),scroll_depth_percent_at_unload:et(),web_visibility_state:n,page_stay_duration_ms:(t=xn.currentPage,t?ie(Date.now(),t):0),last_active_page_key:nt()}}function ot(e,t,n,r){var o,i,a=n||{};return a.lifecycle_event_key=t,t===Kn&&(a=at((o=Fe(),{page_url:Ze(),page_load_time_ms:Ue(),viewport_width:o.viewport_width,viewport_height:o.viewport_height}),a)),t===Nn&&(a=at(a,rt())),t===jn&&((i=xn.currentPage)&&!i.hidden_paused&&(i.accumulated_ms=ie(Date.now(),i),i.resume_at_ms=null,i.hidden_paused=!0),a.last_active_page_key||(a.last_active_page_key=nt())),t===Vn&&function(){var e=xn.currentPage;e&&e.hidden_paused&&(e.resume_at_ms=Date.now(),e.hidden_paused=!1)}(),$e(e,{event:"app_lifecycle",payload:a},r)}function it(e,t,n,r){return function(e,t,n,r){var o=We({},n||{});return Z().then(function(){return Qe(e,t,o,r,null)})}(e,r||z(),{event:"app_session_summary",summaryFields:t||{}},n)}function at(e){var t,n,r,o,i=e||{};for(t=1;arguments.length>t;t++)if(n=arguments[t])for(r=Object.keys(n),o=0;r.length>o;o++)i[r[o]]=n[r[o]];return i}function st(e){var t,n;return!e||2>e.length?0:Math.sqrt((t=e[0].clientX-e[1].clientX)*t+(n=e[0].clientY-e[1].clientY)*n)}function ct(){Gn&&(clearTimeout(Gn),Gn=null)}function ut(t,n,r){var o,i,a,s,c,u;if(r=r||{},!t)return Promise.resolve({sent:!1,ok:!1,status:0,reason:"no_endpoint"});if(!Array.isArray(n)||0===n.length)return Promise.resolve({sent:!0,ok:!0,status:200,reason:"empty_batch"});if(o=JSON.stringify(n),i=!0===r.useBeacon,a=!1!==r.requireHttps,c=isFinite(s=+r.timeoutMs)&&s>0?Math.floor(s):Qn,!function(e,t){if(!t)return!0;var n=((e||"")+"").match(/^(https?):\/\/([^/:?#]+)/i);return!(!n||"https"!==n[1].toLowerCase()&&("http"!==n[1].toLowerCase()||!function(e){var t=((e||"")+"").toLowerCase();return"localhost"===t||"127.0.0.1"===t||"::1"===t}(n[2])))}(t,a))return void 0!==console&&console.warn&&console.warn("[sdk][web] 已拒绝非 HTTPS 上报地址(localhost HTTP 除外)"),Promise.resolve({sent:!1,ok:!1,status:0,reason:"insecure_endpoint"});if(i&&"undefined"!=typeof navigator&&"function"==typeof navigator.sendBeacon)try{if(u=new Blob([o],{type:"application/json"}),navigator.sendBeacon(t,u))return Promise.resolve({sent:!0,ok:!0,status:200,transport:"beacon",uncertain:!0})}catch(e){}return function(e,t,n){var r,o;return"function"!=typeof fetch?Promise.resolve(null):(r="undefined"!=typeof AbortController?new AbortController:null,o=r&&n>0?setTimeout(function(){r.abort()},n):null,fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},body:t,keepalive:!0,signal:r?r.signal:void 0}).then(function(e){return o&&clearTimeout(o),{sent:!0,ok:e.ok,status:+(e.status||0),transport:"fetch"}}).catch(function(e){return o&&clearTimeout(o),e&&"object"==typeof e&&"AbortError"===e.name?{sent:!1,ok:!1,status:0,reason:"timeout",transport:"fetch"}:null}))}(t,o,c).then(function(n){return null!==n?n:function(t,n,r){return new Promise(function(o){try{if("undefined"==typeof XMLHttpRequest)return o({sent:!1,ok:!1,status:0,reason:"no_xhr"});var i=new XMLHttpRequest;i.open("POST",t,!0),i.timeout=r,i.setRequestHeader("Content-Type","application/json"),i.onreadystatechange=function(){4===i.readyState&&o({sent:!0,ok:i.status>=200&&300>i.status,status:i.status,transport:"xhr"})},i.onerror=function(){o({sent:!1,ok:!1,status:0,reason:"xhr_error"})},i.ontimeout=function(){o({sent:!1,ok:!1,status:0,reason:"timeout",transport:"xhr"})},i.send(n)}catch(e){o({sent:!1,ok:!1,status:0,reason:"xhr_exception"})}})}(t,o,c)})}function lt(e){e=e||{},this.selectedBatchEndpoint="",this.remoteEndpoints=[],this.remoteProbePromise=null,this.remoteProbeKey="",this.shouldPersistOnFail=!1,this.hasBatchProbeStarted=!1,this.initialized=!1,this.initPromise=null,this.endpointReadyListeners=[],this.timeoutMs=+e.pingTimeoutMs||1500}function dt(e){return 255&(e<<1^(128&e?27:0))}function _t(e){return(Mt[e>>>24&255]<<24|Mt[e>>>16&255]<<16|Mt[e>>>8&255]<<8|Mt[255&e])>>>0}function pt(e,t){var n,r,o,i,a,s,c,u,l,d,_,p,f=e.slice(0);for(o=[],i=0;16>i;i++)f[i]=255&(f[i]^t[i>>2]>>>24-8*(3&i));for(a=1;14>=a;a++){for(n=[],i=0;16>i;i++)n[i]=Mt[f[i]];for(r=[],c=0;4>c;c++)for(u=0;4>u;u++)r[c+4*u]=n[c+4*(u+c&3)];if(14>a){for(o=[],s=0;4>s;s++)d=r[4*s+1],_=r[4*s+2],p=r[4*s+3],o[4*s]=255&(dt(l=r[4*s])^dt(d)^d^_^p),o[4*s+1]=255&(l^dt(d)^dt(_)^_^p),o[4*s+2]=255&(l^d^dt(_)^dt(p)^p),o[4*s+3]=255&(dt(l)^l^d^_^dt(p));for(f=o,o=[],i=0;16>i;i++)f[i]=255&(f[i]^t[4*a+(i>>2)]>>>24-8*(3&i))}else for(f=r,i=0;16>i;i++)f[i]=255&(f[i]^t[4*a+(i>>2)]>>>24-8*(3&i))}return f}function ft(e,t){var n,r,o,i=[],a=t.slice(0);for(n=0;16>n;n++)i[n]=0;for(n=0;128>n;n++){if(e[n>>3]&128>>(7&n))for(r=0;16>r;r++)i[r]^=a[r];for(o=1&a[15],r=15;r>0;r--)a[r]=255&(a[r]>>>1|a[r-1]<<7);a[0]=a[0]>>>1&255,o&&(a[0]^=225)}return i}function ht(e){var t,n=e.slice(0);for(t=15;t>=12&&(n[t]=n[t]+1&255,!n[t]);t--);return n}function gt(e){this.encryptedConfigText=(e=e||{}).encryptedConfig||e.configCipher||"",this.cachedConfigPromise=null,this._secondGcmKeyBase64="en1BNo0VBrN/zi+mI2LO7E9W40ehCBYwC+frBn8s3rQ="}function mt(e,t){var n=((e&&e.event_id||"")+"").trim();return n?n+"|||"+(t||""):null}function yt(e){var t,n={},r=Object.keys(e);for(t=0;r.length>t;t++)n[r[t]]=e[r[t]];return n}function vt(){this.maxSize=tr}function wt(e){var t,n,r=e||{},o=r.im_behavior_key,i=qt[o];if(!i)throw(t=Error("[sdk][web] 未知 im_behavior_key:"+o)).name="EventValidationError",t.eventMethod="imEvent",t.typeErrorFields=["im_behavior_key(unknown)"],t;return n={},Object.keys(r).forEach(function(e){"im_behavior_name"!==e&&(n[e]=r[e])}),n.im_behavior_name=i,n}function kt(e,t){var n,r,o,i,a,s=Lt[e];if(!s)throw Error("[sdk][web] 未知事件方法:"+e);if(n=t||{},r={},o=[],i=[],Object.keys(s).forEach(function(e){var t,a=s[e],c=1&a,u=a>>1&3,l=!!(8&a),d=n[e];l&&null===d&&(d=""),!c||l||null!=d&&""!==d?c&&l&&void 0===d?o.push(e):(c||null!=d)&&(t=1===u?function(e){return"number"==typeof e&&isFinite(e)&&e===Math.floor(e)}(d):2===u?function(e){return"number"==typeof e&&isFinite(e)}(d):function(e){return"string"==typeof e}(d),t?r[e]=d:i.push(e+"(expected "+(1===u?"int":2===u?"number":"string")+")")):o.push(e)}),o.length||i.length)throw(a=Error("[sdk][web] 事件字段校验失败:"+e)).name="EventValidationError",a.eventMethod=e,a.missingFields=o.slice(),a.typeErrorFields=i.slice(),a;return r}function bt(){var e,t,n,r,o={};for(e=0;arguments.length>e;e++)if(t=arguments[e])for(n=Object.keys(t),r=0;n.length>r;r++)o[n[r]]=t[n[r]];return o}function Ct(e){var t,n,r={};if(!e||!Array.isArray(e))return r;for(t=0;e.length>t;t++)(n=((null==e[t]?"":e[t])+"").trim())&&(Vt[n]?r[n]=1:Kt('[sdk][web] disableAutoEvents 含未知事件 "'+n+'",已忽略'));return r}function St(e){var t,n=[],r=Object.keys(e||{});for(t=0;r.length>t;t++)n.push(r[t]);return n}function Pt(e,t){this.opts=e,this.resolver=t,this.queue=[],this.timer=null,this.flushTail=Promise.resolve(),this.replaying=!1,this.replayTimer=null,this.hasPersistentItems=!0,this.persistentQueue=new vt,this._resolveEndpoint=null,this._getEventCodes=null,this.startReplayTimer()}function Et(e){var t,n;if("undefined"==typeof window)return Kt("[web-sdk] 非浏览器环境(SSR/Node.js),SDK 仅支持浏览器,初始化已跳过。"),this._ssrSkipped=!0,this.options=e,this.configMgr=null,this.resolver=null,void(this.batcher=null);if(!Object.prototype.hasOwnProperty.call(e,"channel"))throw Error('channel 为必传字段(可传空字符串 "")');this._ssrSkipped=!1,(t=bt(e)).channel=null==e.channel?"":e.channel+"",this._disableAutoEvents=Ct(t.disableAutoEvents),t.disableAutoEvents=St(this._disableAutoEvents),this.options=t,this.configMgr=new gt(e),this.resolver=new lt({pingTimeoutMs:nr}),this.batcher=new Pt(this.options,this.resolver),n=this,this.batcher._resolveEndpoint=function(e){return n.resolver.resolveEndpoint(function(){return n.configMgr.getBatchEndpoints()},e)},this.batcher._getEventCodes=function(){return n.configMgr.getEventCodes()},this.batcher._getSigningConfig=function(){return n.configMgr.getSigningConfig()},this._unsubReady=null,this._refreshTimer=null,this._autoLifecycleTeardown=null,this._webLoadTeardown=null,this._autoNetworkTeardown=null,this._pageTrackerTeardown=null,this._webInteractionTeardown=null,this._sessionPollTimer=null,this._autoEventsStarted=!1,this._init({}),this._startAutoEvents()}function Tt(e){if(!Ht)throw Error("请先调用 init() 初始化(参数须包含 channel)");return Ht}function xt(e,t,n){var r,o,i;if(n>=e.length)return Promise.resolve();if(!Ht||Ht!==t){for(o=(r=e.slice(n)).length-1;o>=0;o--)Ut.unshift(r[o]);return Promise.resolve()}return t.track((i=e[n]).event,i.endpoint).catch(function(){}).then(function(){return xt(e,t,n+1)})}function Dt(e,t){var n;return Ht?(n=Ht,(Xt||Promise.resolve()).then(function(){return n.track(e,t)})):(Ut.push({event:e,endpoint:t}),Ut.length>500&&Ut.shift(),Promise.resolve({sent:!1,ok:!0,status:0,reason:"pre_init_cached"}))}var Mt,It,At,Ot,Lt,Rt,qt,Bt,Kt,Vt,jt,Nt,Ft,Ht,Ut,Xt,Yt,Gt,zt=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298],Wt="3.0.1",Jt="sig_v2",Qt=["os_family","browser_kernel","browser_platform","screen_width","screen_height","screen_dpi","device_pixel_ratio","color_depth","max_touch_points","cpu_cores","cookie_enabled","local_storage_enabled","session_storage_enabled","indexeddb_enabled","service_worker_enabled","canvas_fp","webgl_fp","audio_fp","fonts_hash","plugins_hash","mimes_hash"],$t=["Arial","Arial Black","Comic Sans MS","Courier New","Georgia","Impact","Times New Roman","Trebuchet MS","Verdana","Microsoft YaHei","SimHei","SimSun","PingFang SC","Helvetica","Roboto","Monaco","Menlo","Consolas","Palatino Linotype","Tahoma","Lucida Console","Candara","Futura","Geneva"],Zt=[{kind:"video",type:'video/mp4; codecs="avc1.42E01E"'},{kind:"video",type:'video/webm; codecs="vp8, vorbis"'},{kind:"video",type:'video/ogg; codecs="theora"'},{kind:"video",type:"video/mp4"},{kind:"video",type:"video/webm"},{kind:"audio",type:"audio/mpeg"},{kind:"audio",type:'audio/ogg; codecs="vorbis"'},{kind:"audio",type:'audio/wav; codecs="1"'},{kind:"audio",type:"audio/mp4"},{kind:"audio",type:"audio/aac"}],en={cold_start:1,web_load:1,warm_start:1,foreground:1,background:1,web_unload:1,terminate:1,unknown:1},tn="__web_sdk_sid__",nn="__web_sdk_sid_meta__",rn="__web_sdk_sid__",on="__web_sdk_sid_v1",an=18e5,sn=216e5,cn=null,un=!1,ln="__web_sdk_device_id__",dn="__web_sdk_ping__",_n=null,pn="__web_sdk_device_fp__",fn=null,hn="",gn=null,mn=function(){var e="";return{append:function(t,n){var r=((t||"")+"").trim();r&&(e+=r+"|"+(null!=n?n+"":"")+";")},digest:function(){return d(e)},reset:function(){e=""}}}(),yn=50,vn=16384,wn="__web_sdk_pending_session_summaries__",kn="__web_sdk_session_metrics_v1__",bn="__web_sdk_summary_claimed__:",Cn=!1,Sn="",Pn={},En={},Tn={sdk_init:1,app_lifecycle:1,app_session_summary:1,network_state_change:1},xn={eventCountTotal:0,businessEventCount:0,pageViewCount:0,pageClickCount:0,networkChangeCount:0,backgroundCount:0,pageKeys:{},pageStayList:[],pageSeqCounter:0,currentPageSeq:0,lastEventName:"",scrollTotalCount:0,scrollTotalDistance:0,scrollUpCount:0,scrollDownCount:0,scrollDirectionChangeCount:0,lastScrollDirection:0,firstScrollDelayMs:-1,hasScrolled:!1,maxScrollDepthPercent:0,isReachBottom:0,keydownTotalCount:0,inputCount:0,focusTotalCount:0,firstInteractionDelayMs:-1,firstInteractionRecorded:!1,lastOrientation:"unknown",touchTotalCount:0,swipeTotalCount:0,longPressTotalCount:0,pinchTotalCount:0,currentPage:null},Dn=Date.now(),Mn=0,In=0,An=[],On=!1,Ln="",Rn="",qn=null,Bn={app_page_view:1,page_click:1},Kn="web_load",Vn="foreground",jn="background",Nn="web_unload",Fn=null,Hn=0,Un=0,Xn=0,Yn=0,Gn=null,zn=!1,Wn=null,Jn=!1,Qn=1e4,$n="1.2.0",Zn=5e3,er=3e4,tr=2e3,nr=1500,rr=!1;if(lt.prototype.normalizeBatchEndpoints=function(e){return(Array.isArray(e)?e:[]).map(function(e){return((e||"")+"").trim()}).filter(Boolean).filter(function(e){return/^https:\/\//i.test(e)}).map(function(e){var t=e.match(/^(https?:\/\/[^/?#]+)/i);return(t?t[1]:e.replace(/\/+$/,""))+"/api/eventTracking/batchReport.json"})},lt.prototype._withTimeout=function(e,t){return new Promise(function(n,r){var o=setTimeout(function(){r(Error("timeout"))},t);e.then(function(e){clearTimeout(o),n(e)}).catch(function(e){clearTimeout(o),r(e)})})},lt.prototype._xhrRace=function(t,n){return t.length?new Promise(function(r){function o(){for(var t=0;c.length>t;t++)try{c[t].abort()}catch(e){}}var i,a=t.length,s=!1,c=[];for(i=0;t.length>i;i++)(function(t){var i,u,l;try{function d(e){l||(l=!0,u&&(clearTimeout(u),u=null),e&&!s?(s=!0,o(),r(t)):e||(a--,s||0!==a||r("")))}i=new XMLHttpRequest,u=null,l=!1,c.push(i),u=setTimeout(function(){d(!1)},n),i.open("POST",t,!0),i.setRequestHeader("Content-Type","application/json"),i.onreadystatechange=function(){4===i.readyState&&d(i.status>0)},i.onerror=function(){d(!1)},i.send("[]")}catch(e){a--,s||0!==a||r("")}})(t[i])}):Promise.resolve("")},lt.prototype._pickFastest=function(t,n){function r(){if(o)for(var t=0;i.length>t;t++)try{i[t].abort()}catch(e){}}var o,i,a=this;return"undefined"==typeof fetch?a._xhrRace(t,n):t.length?(o="undefined"!=typeof AbortController,i=[],new Promise(function(e){var s,c=t.length,u=!1;for(s=0;t.length>s;s++)(function(t){var s,l=o?new AbortController:null;l&&i.push(l),s={method:"POST",mode:"cors",headers:{"Content-Type":"application/json"},body:"[]",cache:"no-store"},l&&(s.signal=l.signal),a._withTimeout(fetch(t,s),n).then(function(n){n&&n.status>0?u||(u=!0,r(),e(t)):(c--,u||0!==c||e(""))}).catch(function(){c--,u||0!==c||e("")})})(t[s])})):Promise.resolve("")},lt.prototype._notifyReady=function(){var t=this.selectedBatchEndpoint;this.endpointReadyListeners.forEach(function(n){try{n(t)}catch(e){}})},lt.prototype._setSelectedEndpoint=function(e){var t=(e||"")+"";t&&t!==this.selectedBatchEndpoint&&(this.selectedBatchEndpoint=t,this._notifyReady())},lt.prototype.startRemoteProbe=function(e){var t,n,r=e.join("|");return e.length?this.remoteProbePromise&&this.remoteProbeKey===r?this.remoteProbePromise:(this.remoteProbeKey=r,this.shouldPersistOnFail=!1,t=this,n=this._pickFastest(e,this.timeoutMs).then(function(r){if(t.remoteProbePromise!==n)return t.selectedBatchEndpoint;if(t.remoteProbePromise=null,r)t._setSelectedEndpoint(r);else if(!t.selectedBatchEndpoint&&e.length){t.shouldPersistOnFail=!0;var o=e[Math.floor(Math.random()*e.length)];t._setSelectedEndpoint(o)}return t.selectedBatchEndpoint}).catch(function(){return t.remoteProbePromise===n&&(t.remoteProbePromise=null),t.selectedBatchEndpoint}),this.remoteProbePromise=n,n):Promise.resolve("")},lt.prototype.refreshDomainConfig=function(e){var t=this;return e().then(function(e){var n=t.normalizeBatchEndpoints(e);return t.remoteEndpoints=n,n.length?(t.hasBatchProbeStarted=!0,t.startRemoteProbe(n)):t.selectedBatchEndpoint})},lt.prototype.init=function(e){return this.initialized?Promise.resolve():(this.initPromise||(this.initialized=!0,this.initPromise=this.refreshDomainConfig(e).catch(function(){})),this.initPromise)},lt.prototype.resolveEndpoint=function(e,t){if(t)return Promise.resolve(t);var n=this;return this.init(e).then(function(){return n.selectedBatchEndpoint||""})},lt.prototype.onEndpointReady=function(e){if("function"!=typeof e)return function(){};this.endpointReadyListeners.push(e);var t=this;return function(){var n=t.endpointReadyListeners.indexOf(e);0>n||t.endpointReadyListeners.splice(n,1)}},lt.prototype.consumeProbeFailurePersistSignal=function(){return!!this.shouldPersistOnFail&&(this.shouldPersistOnFail=!1,!0)},lt.prototype.getEndpointState=function(){return this.selectedBatchEndpoint?"ready":this.remoteEndpoints.length>0?"probing":"unavailable"},lt.prototype.getSelectedBatchEndpoint=function(){return this.selectedBatchEndpoint},Mt=function(){var e,t=[];for(e=0;1272>e;e+=2)t.push(parseInt("637c777bf26b6fc53001672bed2a28a248888da76f3248a3043813899432c6b326fb8d0d37e84760dad3574759e0b764532a380602b1fc71a9e7f97d3b6415c21d02b5612440a4500bf9c043d33e085f45c20e0b65124603dfa8cd7a76b65c11b829e69a4e7f7872c6c7150b6a0f0801d163ae27d4d6cd5d19e77a9b1b48aaf2428594d3752f1d67250bab67676203b9b40df3d7cdf7eebf4b1bd5b6e16416be39a4c4a56e644726087d013b8ea78a6750d8b0339b871ee1b6a9ab66c8032cc1ac2462c695459dc034deae0678ae1779d9e7abce45d167e719b7776c73222f3c7e3cc11ae6da5cd659b79ce60ca82ec97f6059f674c029a68db0853dc22bb6518048ed4f66256d430b74de2f1a95f0589c341befce783e7c6d9dc7dd8e21cb643ee710b3c11329260195e0c0a956f14f970a9e61e594c663f68266a11045baf84a11e051ea5b6a42067253b2d37392c3516e1fc226a5cd4ab8990c475651a4698910219b94e2eb7d2b7129f38c94e3b1246a3298fef32dcae306137c39e1f0e32fe97d11b986c41a55bd5351319f7f078010fa81eb86b8a0556f880000e3630c6a0b136307027bf70e1b5722632937a93b53bf2d240e3949fa5a6e4e6a2a6f4d5b43a39d051df60bc98f25ff3130c31fd85b6e4167b276aed3276b70c3e527f2f2963b60554543e32e1f0c1754766b610c20b0ffe75d3fb88f6fa49605d6a3d6ca3022ffc62f5c8d297b65c778da96c8941ac9c55845e094f1c4a020bff5bb01a0cb04f5a8ac42b1cb41c38d27c1e1358256f3e99e3295a6e3b8c9c0481ed49af7d43f580a8571a0b13162171ebe8666ee5365e9ce6bef0a682080268b3bb67a1076a5776a996c26c2c8c12c7490c356348d10351b5279fa09851ebf".substr(e,2),16));return t}(),It=[1,2,4,8,16,32,64,128,27,54],At="__web_sdk_remote_config_cache__",gt.prototype.setEncryptedConfig=function(e){this.encryptedConfigText!==e&&(this.encryptedConfigText=e,this.cachedConfigPromise=null)},gt.prototype._tryParse=function(t){try{return JSON.parse(t)}catch(e){return t}},gt.prototype._unwrap=function(e,t){var n,r,o;for(t=t||3,n=e,r=0;t>r&&"string"==typeof n&&(o=this._tryParse(n))!==n;r++)n=o;return n},gt.prototype._toStringArray=function(e){var t,n=this._unwrap(e);return Array.isArray(n)?n.map(function(e){return((e||"")+"").trim()}).filter(Boolean):"string"==typeof n&&n.trim()?(t=this._tryParse(n),Array.isArray(t)?t.map(function(e){return((e||"")+"").trim()}).filter(Boolean):[n.trim()]):[]},gt.prototype._getStorage=function(){try{return"undefined"!=typeof window&&window.localStorage?window.localStorage:null}catch(e){return null}},gt.prototype._readPersisted=function(){var t,n,r;try{return(t=this._getStorage())&&(n=t.getItem(At))?(r=JSON.parse(n),Date.now()-(r.updatedAt||0)>24e5?{}:this._mapPayload(r)):{}}catch(e){return{}}},gt.prototype._mapPayload=function(e){if(!e||"object"!=typeof e)return{};var t=e.data&&"object"==typeof e.data?e.data:e;return{batchUrls:this._toStringArray(null!=t.batchUrls?t.batchUrls:t.domainList),eventCodes:this._toStringArray(null!=t.eventCodes?t.eventCodes:t.eventList),signKey:(t.signKey||t.hmacKey||t.eventSignKey||"")+"",securityKeyVersion:(t.securityKeyVersion||t.signKeyVersion||"")+""}},gt.prototype._persist=function(t){try{var n=this._getStorage();if(!n)return;n.setItem(At,JSON.stringify({batchUrls:this._toStringArray(t.batchUrls),eventCodes:this._toStringArray(t.eventCodes),signKey:(t.signKey||"")+"",securityKeyVersion:(t.securityKeyVersion||"")+"",updatedAt:Date.now()}))}catch(e){}},gt.prototype._base64ToBytes=function(e){var t,n,r,o,i=((e||"")+"").trim().replace(/-/g,"+").replace(/_/g,"/"),a=i.length%4?4-i.length%4:0;for(t=0;a>t;t++)i+="=";if("undefined"!=typeof atob&&"undefined"!=typeof Uint8Array){for(n=atob(i),r=new Uint8Array(n.length),o=0;n.length>o;o++)r[o]=n.charCodeAt(o);return r}return new Uint8Array(0)},gt.prototype._decryptAes256Gcm=function(t,n){var r,o,i,a,s,c,u;try{if(28>(r=this._base64ToBytes(t)).length)return Promise.reject(Error("密文过短"));if(o=this._base64ToBytes(n),i=r.slice(0,12),a=r.slice(12),"undefined"!=typeof crypto&&crypto.subtle)return crypto.subtle.importKey("raw",o,{name:"AES-GCM"},!1,["decrypt"]).then(function(e){return crypto.subtle.decrypt({name:"AES-GCM",iv:i},e,a)}).then(function(e){return new Uint8Array(e)});if(!(s=function(e,t,n){var r,o,i,a,s,c,u,l,d,_,p,f,h,g,m=[],y=[],v=[],w=[];for(r=0;32>r;r++)m[r]=e[r]||0;for(r=0;12>r;r++)y[r]=t[r]||0;if(0>(i=n.length-16))return null;for(r=0;i>r;r++)v[r]=n[r];for(r=0;16>r;r++)w[r]=n[i+r];for(a=function(e){var t,n,r=[];for(t=0;8>t;t++)r[t]=(e[4*t]<<24|e[4*t+1]<<16|e[4*t+2]<<8|e[4*t+3])>>>0;for(t=8;60>t;t++)n=r[t-1],t%8?t%8==4&&(n=_t(n)):n=(_t((n<<8|n>>>24)>>>0)^It[t/8-1]<<24)>>>0,r[t]=(r[t-8]^n)>>>0;return r}(m),s=[],r=0;16>r;r++)s[r]=0;for(c=pt(s,a),u=y.slice(0),r=12;15>r;r++)u[r]=0;for(u[15]=1,l=pt(u,a),d=ht(u),_=[],r=0;i>r;r+=16)for(p=pt(d,a),d=ht(d),o=0;16>o&&i>r+o;o++)_[r+o]=v[r+o]^p[o];for(f=v.slice(0);15&f.length;)f.push(0);for(r=0;8>r;r++)f.push(0);for(h=8*i,f.push(0,0,0,0),f.push(h>>>24&255,h>>>16&255,h>>>8&255,255&h),g=function(e,t){var n,r,o=[];for(n=0;16>n;n++)o[n]=0;for(n=0;t.length>n;n+=16){for(r=0;16>r;r++)o[r]^=t[n+r]||0;o=ft(o,e)}return o}(c,f),r=0;16>r;r++)if((l[r]^g[r])!==w[r])return null;return _}(o,i,a)))return Promise.reject(Error("AES-GCM 认证标签校验失败"));if("undefined"!=typeof Uint8Array)for(c=new Uint8Array(s.length),u=0;s.length>u;u++)c[u]=s[u];else c=s;return Promise.resolve(c)}catch(e){return Promise.reject(e)}},gt.prototype.fetchAndDecryptConfig=function(){var e,t,n;return this.cachedConfigPromise?this.cachedConfigPromise:(e=this,t=this._readPersisted(),this.encryptedConfigText?(n=this._decryptAes256Gcm(this.encryptedConfigText,this._secondGcmKeyBase64).then(function(t){var n,r,o,i;if("undefined"!=typeof TextDecoder)n=(new TextDecoder).decode(t);else{for(r=[],o=0;t.length>o;o++)r[o]=t[o];n=String.fromCharCode.apply(null,r)}return i=e._mapPayload(JSON.parse(n)),e._persist(i),i}).catch(function(e){return void 0!==console&&console.warn&&console.warn("[sdk][config] 解密失败:",e),t}),this.cachedConfigPromise=n,n):(this.cachedConfigPromise=Promise.resolve(t),this.cachedConfigPromise))},gt.prototype.getBatchEndpoints=function(){return this.fetchAndDecryptConfig().then(function(e){return e.batchUrls||[]})},gt.prototype.getEventCodes=function(){return this.fetchAndDecryptConfig().then(function(e){return Array.isArray(e.eventCodes)&&e.eventCodes.length>0?e.eventCodes:[]})},gt.prototype.getSigningConfig=function(){return this.fetchAndDecryptConfig().then(function(e){return{hmacKey:(e.signKey||"")+"",securityKeyVersion:(e.securityKeyVersion||"")+""}})},gt.prototype.prewarm=function(){this.fetchAndDecryptConfig().catch(function(){})},gt.prototype.forceRefresh=function(){this.cachedConfigPromise=null},Ot="events",vt.prototype.isAvailable=function(){return"undefined"!=typeof indexedDB&&"undefined"!=typeof window&&null!=window.indexedDB},vt.prototype.openDb=function(){var t=this;return new Promise(function(n,r){if(!t.isAvailable())return n(null);var o=indexedDB.open("web_sdk_event_queue",3);o.onerror=function(){r(o.error||Error("open indexedDB failed"))},o.onupgradeneeded=function(t){var n,r,i,a,s=o.result,c=t.oldVersion,u=t.target.transaction;if(!s.objectStoreNames.contains(Ot))return(n=s.createObjectStore(Ot,{keyPath:"id",autoIncrement:!0})).createIndex("createdAt","createdAt",{unique:!1}),void n.createIndex("pendingDedupeKey","pendingDedupeKey",{unique:!0});2>c&&u&&((r=u.objectStore(Ot)).indexNames.contains("pendingDedupeKey")||r.createIndex("pendingDedupeKey","pendingDedupeKey",{unique:!1})),3>c&&u&&((i=u.objectStore(Ot)).indexNames.contains("pendingDedupeKey")&&i.deleteIndex("pendingDedupeKey"),(a=i.getAll()).onsuccess=function(){var t,n,r,o,s,c,u,l=a.result||[],d={};for(t=0;l.length>t;t++)(r=(n=l[t]).pendingDedupeKey||mt(n.record,n.endpointOverride))&&(d[r]||(d[r]=[]),d[r].push(n.id));for(o=Object.keys(d),s=0;o.length>s;s++)if((c=d[o[s]]).length>=2)for(c.sort(function(e,t){return e-t}),u=1;c.length>u;u++)i.delete(c[u]);try{i.createIndex("pendingDedupeKey","pendingDedupeKey",{unique:!0})}catch(e){}},a.onerror=function(){try{i.createIndex("pendingDedupeKey","pendingDedupeKey",{unique:!0})}catch(e){}})},o.onsuccess=function(){n(o.result)}})},vt.prototype._txDone=function(e){return new Promise(function(t,n){e.oncomplete=function(){t()},e.onerror=function(){n(e.error||Error("tx failed"))},e.onabort=function(){n(e.error||Error("tx aborted"))}})},vt.prototype._trimOverflow=function(e){var t=this,n=Ot,r=e.transaction(n,"readonly");return new Promise(function(e,t){var o=r.objectStore(n).count();o.onsuccess=function(){e(+(o.result||0))},o.onerror=function(){t(o.error)}}).then(function(e){return t._txDone(r).then(function(){return e})}).then(function(r){var o,i,a,s;if(r>t.maxSize)return o=r-t.maxSize,i=e.transaction(n,"readwrite"),a=i.objectStore(n).index("createdAt"),s=0,new Promise(function(e,t){var r=a.openCursor();r.onerror=function(){t(r.error)},r.onsuccess=function(){var t=r.result;if(!t||s>=o)return e();i.objectStore(n).delete(t.primaryKey),s++,t.continue()}}).then(function(){return t._txDone(i)})})},vt.prototype.enqueueMany=function(e){var t=this;return e&&e.length?this.openDb().then(function(n){var r,o;if(n)return r=Date.now(),o=e.map(function(e,t){var n=e&&"object"==typeof e&&"record"in e?e:{record:e},o=mt(n.record,n.endpointOverride||""),i={createdAt:r+t,record:n.record,endpointOverride:n.endpointOverride||"",retryCount:0,nextRetryAt:0};return o&&(i.pendingDedupeKey=o),i}),new Promise(function(e,t){var r,i=n.transaction(Ot,"readwrite"),a=i.objectStore(Ot);i.oncomplete=function(){e()},i.onerror=function(){t(i.error)},i.onabort=function(){t(i.error)},r=0,function e(){var n,i;o.length>r&&(n=o[r++],(i=a.add(n)).onsuccess=function(){e()},i.onerror=function(n){var r=i.error;if(r&&"ConstraintError"===r.name)return n.preventDefault(),void e();t(r||Error("add failed"))})}()}).then(function(){return t._trimOverflow(n)}).then(function(){n.close()}).catch(function(e){throw n.close(),e})}):Promise.resolve()},vt.prototype.readOldest=function(e){return e=e||20,this.openDb().then(function(t){var n,r;return t?(n=Date.now(),r=[],new Promise(function(o,i){var a=t.transaction(Ot,"readonly").objectStore(Ot).index("createdAt").openCursor();a.onerror=function(){i(a.error)},a.onsuccess=function(){var t,i,s,c,u,l=a.result;if(!l||r.length>=e)return o();(s=isFinite(i=+(t=l.value).nextRetryAt)?Math.floor(i):0)>n||((c=yt(t)).endpointOverride=t.endpointOverride||"",c.retryCount=isFinite(u=+t.retryCount)?Math.floor(u):0,c.nextRetryAt=s,r.push(c)),l.continue()}}).then(function(){return t.close(),r}).catch(function(e){throw t.close(),e})):[]})},vt.prototype.removeByIds=function(e){var t=this;return e&&e.length?this.openDb().then(function(n){var r,o,i;if(n){for(o=(r=n.transaction(Ot,"readwrite")).objectStore(Ot),i=0;e.length>i;i++)o.delete(e[i]);return t._txDone(r).then(function(){n.close()}).catch(function(e){throw n.close(),e})}}):Promise.resolve()},vt.prototype.updateRetryMeta=function(e,t,n){var r=this;return e&&e.length?this.openDb().then(function(o){var i,a,s,c,u,l,d,_,p,f,h,g,m,y,v;if(!o)return{dropped:0,kept:0};for(i=Date.now(),s=isFinite(a=+t)?Math.max(0,Math.floor(a)):0,u=isFinite(c=+n)?Math.max(0,Math.floor(c)):0,l=0,d=0,p=(_=o.transaction(Ot,"readwrite")).objectStore(Ot),f=0;e.length>f;f++)u<(m=(isFinite(g=+(h=e[f]).retryCount)?Math.floor(g):0)+1)?(l++,p.delete(h.id)):(d++,y=h.pendingDedupeKey||mt(h.record,h.endpointOverride||""),v=yt(h),y&&(v.pendingDedupeKey=y),v.retryCount=m,v.nextRetryAt=i+s,p.put(v));return r._txDone(_).then(function(){return o.close(),{dropped:l,kept:d}}).catch(function(e){throw o.close(),e})}):Promise.resolve({dropped:0,kept:0})},vt.prototype.count=function(){var e=this;return this.openDb().then(function(t){if(!t)return 0;var n=t.transaction(Ot,"readonly");return new Promise(function(e,t){var r=n.objectStore(Ot).count();r.onsuccess=function(){e(+(r.result||0))},r.onerror=function(){t(r.error)}}).then(function(r){return e._txDone(n).then(function(){return t.close(),r})}).catch(function(e){throw t.close(),e})})},Lt={navigation:{navigation_key:1,navigation_name:1},appPageView:{user_type:1,page_key:1,page_name:1,referrer_page_key:9,referrer_page_name:9,current_page_key:1,current_page_name:1,page_load_time:3,recommend_trace_id:0},appInstall:{trace_id:1,attribution_flag:2},pageClick:{page_key:1,page_name:1,click_page_x:3,click_page_y:3,click_x_percent:3,click_y_percent:3,screen_width:3,screen_height:3,recommend_trace_id:0},recommendListView:{page_key:1,page_name:1,recommend_content_type:1,recommend_trace_id:0,recommend_id:1,recommend_trace_info:0,client_version:0},recommendListClick:{page_key:1,page_name:1,recommend_content_type:1,recommend_trace_id:0,recommend_id:1,recommend_trace_info:0,client_version:0},advertising:{event_type:1,advertising_key:1,advertising_name:1,advertising_id:1},videoEvent:{media_id:0,video_id:1,video_title:1,video_type_id:1,video_type_name:1,video_content_type:9,recommend_trace_id:0,video_tag_key:9,video_tag_name:9,video_duration:3,play_duration:3,play_progress:3,play_speed:3,video_behavior_key:1,video_behavior_name:1,post_id:0},novelEvent:{media_id:0,novel_id:1,novel_title:1,chapter_id:0,chapter_name:0,novel_type_id:1,novel_type_name:1,recommend_trace_id:0,novel_tag_key:9,novel_tag_name:9,read_progress:3,page_no:3,novel_behavior_key:1,novel_behavior_name:1},comicEvent:{media_id:0,comic_id:1,comic_title:1,chapter_id:0,chapter_name:0,comic_type_id:1,comic_type_name:1,recommend_trace_id:0,comic_tag_key:9,comic_tag_name:9,read_progress:3,page_no:3,comic_behavior_key:1,comic_behavior_name:1},keywordSearch:{keyword:1,search_result_count:3,search_content_type:0,search_trace_id:0,search_id:0},keywordClick:{keyword:1,click_item_id:1,click_item_type_key:1,click_item_type_name:1,click_position:3,search_trace_id:0},adImpression:{page_key:1,page_name:1,ad_slot_key:1,ad_slot_name:1,ad_id:1,creative_id:0,ad_type:0},adClick:{page_key:1,page_name:1,ad_slot_key:1,ad_slot_name:1,ad_id:1,creative_id:0,ad_type:0},appPageitemExposure:{item_id:1,item_type:1,item_name:1,parent_item_id:1,page_key:1,page_name:1,referrer_page_key:9,referrer_page_name:9,recommend_trace_id:9,recommend_id:9,recommend_trace_info:9},appPageitemClick:{item_id:1,item_type:1,item_name:1,parent_item_id:1,position:3,page_key:1,page_name:1,recommend_trace_id:9,recommend_id:9,recommend_trace_info:9},adRequest:{request_id:1,ad_slot_key:1},adFill:{trace_id:1,request_id:1,ad_slot_key:1,ad_id:1,ad_type:1,fill_count:3,is_skippable:3,is_closable:3},adNoFill:{request_id:1,ad_slot_key:1,ad_type:1,reason:1},adLoadSuccess:{trace_id:1,request_id:1,ad_slot_key:1,ad_id:1,ad_type:1,load_duration:3},adImpressionSdk:{trace_id:1,request_id:1,ad_slot_key:1,ad_id:1,ad_type:1,material_size:1,is_skippable:3,is_closable:3},adClickSdk:{trace_id:1,request_id:1,ad_slot_key:1,ad_id:1,ad_type:1},adClose:{trace_id:1,request_id:1,ad_slot_key:1,ad_id:1,ad_type:1,close_type:1},adError:{trace_id:1,request_id:0,ad_slot_key:1,ad_type:1,ad_id:9,error_code:1,error_msg:1,error_stage:1},imEvent:{conversation_id:1,conversation_type:1,current_uid:1,target_uid:1,target_role_key:1,target_role_name:1,message_id:9,message_type:9,im_behavior_key:1,im_behavior_name:1},imConversationView:{current_uid:1,conversation_count:3,total_unread_count:3,normal_conversation_count:3,archived_count:3,pinned_count:3},imConnectionSuccess:{current_uid:9,im_sdk_version:1},imMessageContent:{conversation_id:1,conversation_type:1,current_uid:1,target_uid:1,sender_uid:1,message_id:1,message_type:1,message:9,message_content_length:3,content_hash:9},postEvent:{media_id:0,post_id:1,post_title:1,post_create_time:3,post_user_id:1,post_user_nick:1,post_type_id:1,post_type_name:1,post_content_type:1,recommend_trace_id:9,post_tag_key:9,post_tag_name:9,post_behavior_key:1,post_behavior_name:1},dramaEvent:{media_id:0,drama_id:1,drama_title:1,episode:3,total_episode:3,video_id:1,drama_type_id:1,drama_type_name:1,drama_content_type:1,recommend_trace_id:9,drama_tag_key:9,drama_tag_name:9,drama_duration:3,play_duration:3,play_progress:3,play_speed:3,play_behavior_key:1,play_behavior_name:1},dramaStatusChange:{media_id:0,drama_id:1,drama_title:1,episode:3,video_id:1,pre_episode:3,drama_behavior_key:1,drama_behavior_name:1}},qt={message_send:"发送消息",message_receive:"收到消息",message_read:"消息已读",message_recall:"消息撤回",conversation_pin:"置顶会话",conversation_unpin:"取消置顶",conversation_archive:"归档会话",conversation_unarchive:"取消归档"},Bt=Object.keys(Rt={navigation:"navigation",appPageView:"app_page_view",appInstall:"app_install",pageClick:"page_click",recommendListView:"recommend_list_view",recommendListClick:"recommend_list_click",advertising:"advertising",videoEvent:"video_event",novelEvent:"novel_event",comicEvent:"comic_event",keywordSearch:"keyword_search",keywordClick:"keyword_click",adImpression:"ad_impression",adClick:"ad_click",appPageitemExposure:"app_pageitem_exposure",appPageitemClick:"app_pageitem_click",adRequest:"ad_request",adFill:"ad_fill",adNoFill:"ad_no_fill",adLoadSuccess:"ad_load_success",adImpressionSdk:"ad_impression_sdk",adClickSdk:"ad_click_sdk",adClose:"ad_close",adError:"ad_error",imEvent:"im_event",imConversationView:"im_conversation_view",imConnectionSuccess:"im_connection_success",imMessageContent:"im_message_content",postEvent:"post_event",dramaEvent:"drama_event",dramaStatusChange:"drama_status_change"}).reduce(function(e,t){var n=Rt[t];return e[t]="imEvent"===t?function(e){return{event:n,payload:kt(t,wt(e))}}:function(e){return{event:n,payload:kt(t,e||{})}},e},{}),Kt=void 0!==console&&console.warn?function(e){console.warn(e)}:function(){},Vt={sdk_init:1,app_lifecycle:1,network_state_change:1,app_session_summary:1,app_page_view:1,page_click:1},jt=6e4,Nt="__web_sdk_recent_ok_event_ids__",Ft=function(){function t(e){var t,n=Object.keys(r);for(t=0;n.length>t;t++)e-r[n[t]]>jt&&delete r[n[t]]}function n(){try{"undefined"!=typeof window&&window.localStorage&&window.localStorage.setItem(Nt,JSON.stringify(r))}catch(e){}}var r={};if(function(){var t,n,o,i,a;try{if("undefined"!=typeof window&&window.localStorage&&(t=window.localStorage.getItem(Nt)))for(n=JSON.parse(t),o=Date.now(),i=Object.keys(n),a=0;i.length>a;a++)/^[0-9a-f]{32}$/.test(i[a])&&(o-n[i[a]]>jt||(r[i[a]]=n[i[a]]))}catch(e){}}(),"undefined"!=typeof window)try{window.addEventListener("storage",function(e){var t,n,o,i,a;try{if(e.key!==Nt||!e.newValue)return;for(t=JSON.parse(e.newValue),n=Date.now(),o=Object.keys(t),i=0;o.length>i;i++)a=t[o[i]],/^[0-9a-f]{32}$/.test(o[i])&&(n-a>jt||(r[o[i]]=a))}catch(s){}})}catch(e){}return{has:function(e){var n,o;return!!e&&(t(n=Date.now()),void 0!==(o=r[e])&&jt>=n-o)},set:function(e){var o,i,a,s,c,u=Date.now();for(t(u),o=0;e.length>o;o++)e[o]&&(r[e[o]]=u);if((i=Object.keys(r)).length>500){for(a="",s=1/0,c=0;i.length>c;c++)s>r[i[c]]&&(s=r[i[c]],a=i[c]);a&&delete r[a]}n()},reset:function(){r={},n()}}}(),Pt.prototype.startReplayTimer=function(){var e=this;this.replayTimer=setInterval(function(){e.replayPersistent()},Zn)},Pt.prototype._stopTimer=function(){this.timer&&(clearTimeout(this.timer),this.timer=null)},Pt.prototype._settleItem=function(e,t){e.settled||(e.settled=!0,e.timeoutTimer&&(clearTimeout(e.timeoutTimer),e.timeoutTimer=null),e.resolve(t))},Pt.prototype._purgeSettled=function(){this.queue.length&&(this.queue=this.queue.filter(function(e){return!e.settled}))},Pt.prototype._toPersistentEntries=function(e){return e.map(function(e){return{record:e.record,endpointOverride:e.endpointOverride||"",retryCount:0,nextRetryAt:0}})},Pt.prototype._getRetryDelay=function(e){return Math.min(6e4,Math.max(Zn,Math.floor(Zn*Math.pow(2,Math.min(10,Math.max(0,e))))))},Pt.prototype.schedule=function(){var e=this;this._purgeSettled(),!this.timer&&this.queue.length&&(this.timer=setTimeout(function(){e.timer=null,e.flush().catch(function(){})},5e3))},Pt.prototype.flush=function(){var e=this,t=this.flushTail.then(function(){return e.flushOnce()});return this.flushTail=t.catch(function(){}),t},Pt.prototype._processFlushGroup=function(e,t,n){var r,o,i,a,s,c,u,l,d,_,p,f=this,h="__default__"===e?"":e,g=[],m=[];for(r=0;t.length>r;r++)((i=(((o=t[r]).record&&o.record.event_id||"")+"").trim())&&Ft.has(i)?m:g).push(o);for(a=0;m.length>a;a++)f._settleItem(m[a],{sent:!1,ok:!1,status:0,reason:"cross_batch_recent_success_deduped"});if(!g.length)return Promise.resolve();if(!f._resolveEndpoint){for(Kt("[sdk][web] _resolveEndpoint 未注入,事件无法发送"),s=0;g.length>s;s++)f._settleItem(g[s],{sent:!1,ok:!1,status:0,reason:"no_endpoint_resolver"});return Promise.resolve()}for(c={},u=[],l={},d=0;g.length>d;d++)c[p=(((_=g[d]).record&&_.record.event_id||"")+"").trim()||"__empty__:"+d]||(c[p]=!0,u.push(_.record),l[d]=!0);return f.resolver.consumeProbeFailurePersistSignal()?f.persistentQueue.enqueueMany(f._toPersistentEntries(g)).then(function(){f.hasPersistentItems=!0;for(var e=0;g.length>e;e++)f._settleItem(g[e],{sent:!1,ok:!1,status:0,reason:"probe_all_failed"})}).catch(function(){}):f._resolveEndpoint(h).then(function(e){var t,r,o,i,a,s;if(e){for(r=[],o=0;u.length>o;o++)if((i=((u[o]&&u[o].event_id||"")+"").trim())&&Ft.has(i)){for(a=0;g.length>a;a++)if(l[a]&&((g[a].record&&g[a].record.event_id||"")+"").trim()===i){f._settleItem(g[a],{sent:!1,ok:!1,status:0,reason:"cross_batch_recent_success_deduped"}),l[a]=!1;break}}else r.push(u[o]);if(!r.length){for(s=0;g.length>s;s++)l[s]||f._settleItem(g[s],{sent:!1,ok:!1,status:0,reason:"batch_deduped_duplicate"});return Promise.resolve()}return ut(e,r,{useBeacon:rr}).then(function(e){var t,n,o,i,a,s=Promise.resolve();if(e&&e.ok){for(t=[],n=0;r.length>n;n++)(o=((r[n]&&r[n].event_id||"")+"").trim())&&t.push(o);Ft.set(t)}else{for(i=[],a=0;g.length>a;a++)l[a]&&i.push(g[a]);s=f.persistentQueue.enqueueMany(f._toPersistentEntries(i)).then(function(){f.hasPersistentItems=!0}).catch(function(){})}return s.then(function(){for(var t=0;g.length>t;t++)f._settleItem(g[t],l[t]?e:{sent:!1,ok:!1,status:0,reason:"batch_deduped_duplicate"})})})}if("unavailable"===f.resolver.getEndpointState())return f.persistentQueue.enqueueMany(f._toPersistentEntries(g)).then(function(){f.hasPersistentItems=!0;for(var e=0;g.length>e;e++)f._settleItem(g[e],{sent:!1,ok:!1,status:0,reason:"endpoint_unavailable"})}).catch(function(){});for(t=0;g.length>t;t++)n.push(g[t])})},Pt.prototype.flushOnce=function(){var e,t,n,r,o,i,a,s,c,u=this;if(this._purgeSettled(),!this.queue.length)return Promise.resolve();for(this._stopTimer(),e=this.queue.splice(0,10),t={},n=0;e.length>n;n++)(r=e[n]).settled||(t[o=r.endpointOverride||"__default__"]||(t[o]=[]),t[o].push(r));for(i=[],a=Object.keys(t),s=Promise.resolve(),c=0;a.length>c;c++)(function(e){s=s.then(function(){return u._processFlushGroup(e,t[e],i)})})(a[c]);return s.then(function(){var e,t,n,r,o,a,s;if(i.length){for(e=Date.now(),t=[],n=[],r=0;i.length>r;r++)(i[r].timeoutAt>e?n:t).push(i[r]);for(o=0;n.length>o;o++)u.queue.push(n[o]);for(a=Promise.resolve(),s=0;t.length>s;s++)(function(e){a=a.then(function(){return u.persistentQueue.enqueueMany(u._toPersistentEntries([e])).catch(function(){}).then(function(){u.hasPersistentItems=!0,u._settleItem(e,{sent:!1,ok:!1,status:0,reason:"track_timeout",transport:"queue"})})})})(t[s]);return a}}).then(function(){u.queue.length&&u.schedule()},function(e){throw u.queue.length&&u.schedule(),e})},Pt.prototype._processReplayGroup=function(e,t){var n=this;return n._resolveEndpoint?n._resolveEndpoint("__default__"===e?"":e).then(function(e){var r,o,i,a,s,c,u,l,d;if(e){for(r={},o=[],i=[],a=[],s=0;t.length>s;s++)u=(((c=t[s]).record&&c.record.event_id||"")+"").trim()||"__empty__:"+c.id,(l=((c.record&&c.record.event_id||"")+"").trim())&&Ft.has(l)||r[u]?a.push(c.id):(r[u]=!0,o.push(c.record),i.push(c.id));return d=Promise.resolve(),a.length&&(d=d.then(function(){return n.persistentQueue.removeByIds(a)})),o.length?d.then(function(){return ut(e,o,{useBeacon:rr})}).then(function(e){var r,s,c,u,l,d,_,p,f,h;if(e&&e.ok){for(r=[],s=0;o.length>s;s++)(c=((o[s]&&o[s].event_id||"")+"").trim())&&r.push(c);for(Ft.set(r),u=[],l=0;i.length>l;l++)u.push(i[l]);for(d=0;a.length>d;d++)u.push(a[d]);return n.persistentQueue.removeByIds(u)}for(_=0,p=0;t.length>p;p++)(f=t[p].retryCount||0)>_&&(_=f);return h=n._getRetryDelay(_),n.persistentQueue.updateRetryMeta(t,h,5)}):d}}):(Kt("[sdk][web] _resolveEndpoint 未注入,持久队列重试跳过"),Promise.resolve())},Pt.prototype.replayPersistent=function(){var e=this;return!this.hasPersistentItems||this.replaying?Promise.resolve():(this.replaying=!0,this.persistentQueue.readOldest(10).then(function(t){var n,r,o,i,a,s,c;if(!t.length)return e.persistentQueue.count().then(function(t){0===t&&(e.hasPersistentItems=!1)});for(n={},r=0;t.length>r;r++)n[i=(o=t[r]).endpointOverride||"__default__"]||(n[i]=[]),n[i].push(o);for(a=Object.keys(n),s=Promise.resolve(),c=0;a.length>c;c++)(function(t){s=s.then(function(){return e._processReplayGroup(t,n[t])})})(a[c]);return s}).catch(function(){}).then(function(){e.replaying=!1},function(){e.replaying=!1}))},Pt.prototype.enqueueAssembled=function(e,t){var n=this;return(n._getEventCodes?n._getEventCodes():Promise.resolve([])).then(function(r){return r&&0===r.length||r&&-1===r.indexOf(e.event)?{sent:!1,ok:!1,status:0,reason:"event_code_not_allowed",event:e.event}:new Promise(function(r){var o={record:e,endpointOverride:t||"",resolve:r,reject:r,settled:!1,timeoutAt:Date.now()+er,timeoutTimer:null};o.timeoutTimer=setTimeout(function(){o.settled||n.persistentQueue.enqueueMany(n._toPersistentEntries([o])).catch(function(){}).then(function(){n.hasPersistentItems=!0,n._settleItem(o,{sent:!1,ok:!1,status:0,reason:"track_timeout",transport:"queue"})})},er),n.queue.push(o),10>n.queue.length?n.schedule():n.flush().catch(function(){})})})},Pt.prototype.track=function(e,t){var n=this;return Q().then(function(r){var o={appId:n.opts.appId,channel:n.opts.channel,uid:n.opts.uid||"",deviceId:n.opts.deviceId||r,deviceType:y(),sdkVersion:$n,appVersion:v(n.opts.appVersion)};return(n._getSigningConfig?n._getSigningConfig():Promise.resolve({hmacKey:"",securityKeyVersion:""})).then(function(t){return $e(o,e,t)}).then(function(e){return delete e._sessionInfo,(n._getEventCodes?n._getEventCodes():Promise.resolve([])).then(function(r){return r&&0===r.length?(Kt("[sdk][web] 当前没有事件允许上报,请检查服务端配置(eventCodes 为空)"),{sent:!1,ok:!1,status:0,reason:"event_code_not_allowed",event:e.event}):r&&-1===r.indexOf(e.event)?(Kt('[sdk][web] 事件 "'+e.event+'" 不在服务端允许上报的列表中,已忽略'),{sent:!1,ok:!1,status:0,reason:"event_code_not_allowed",event:e.event}):new Promise(function(r,o){var i={record:e,endpointOverride:t||"",resolve:r,reject:o,settled:!1,timeoutAt:Date.now()+er,timeoutTimer:null};i.timeoutTimer=setTimeout(function(){i.settled||n.persistentQueue.enqueueMany(n._toPersistentEntries([i])).catch(function(){}).then(function(){n.hasPersistentItems=!0,n._settleItem(i,{sent:!1,ok:!1,status:0,reason:"track_timeout",transport:"queue"})})},er),n.queue.push(i),10>n.queue.length?n.schedule():n.flush().catch(function(){})})})})})},Pt.prototype.stop=function(){var e,t;this._stopTimer(),this.replayTimer&&(clearInterval(this.replayTimer),this.replayTimer=null),e={sent:!1,ok:!1,status:0,reason:"stopped"},t=this,this.queue.splice(0).forEach(function(n){t._settleItem(n,e)})},Pt.prototype.getConfig=function(){return{batchMaxSize:10,batchIntervalMs:5e3,replayBatchSize:10,replayIntervalMs:Zn}},Et.prototype._sdkOpts=function(){return{appId:this.options.appId,channel:this.options.channel,uid:this.options.uid||"",deviceId:this.options.deviceId||"",deviceType:y(),sdkVersion:$n,appVersion:v(this.options.appVersion)}},Et.prototype._isAutoDisabled=function(e){return!(!this._disableAutoEvents||!this._disableAutoEvents[e])},Et.prototype._handleEndedSession=function(t){var n,r,o,i,a,s,c;return t&&t.sid&&!this._ssrSkipped?this._isAutoDisabled("app_session_summary")?Promise.resolve():function(t){if(!t)return!1;if(Pn[t]||En[t])return!1;if(se())try{if(window.sessionStorage.getItem(bn+(t+"")))return Pn[t]=1,!1}catch(e){}return En[t]=1,!0}(t.sid)?(n=this,o=t.sid,a=(null!=t.sessionEndTs?1e12>t.sessionEndTs?1e3*t.sessionEndTs:t.sessionEndTs:Date.now())||Date.now(),s=fe(o),c=!Sn||Sn===o||!s&&(xn.eventCountTotal>0||xn.currentPage||(xn.pageStayList||[]).length>0),s&&Sn&&Sn!==o&&(c=!1),s&&c&&(s.eventCountTotal||0)>(xn.eventCountTotal||0)&&(c=!1),c?(xe(),i=ce(xn)):i=s?function(e,t){var n,r,o,i=e&&e.currentPage;return i?(n=t||Date.now(),r=i.accumulated_ms||0,null!=i.resume_at_ms&&(r+=Math.max(0,n-i.resume_at_ms)),o={page_key:i.page_key,page_name:i.page_name||"",stay_duration_ms:r,enter_time:Math.floor((i.enter_time||n)/1e3),has_interaction:i.has_interaction?1:0},i.page_seq_in_sid&&(o.page_seq_in_sid=i.page_seq_in_sid),e.pageStayList=be((e.pageStayList||[]).concat([o])),e.currentPage=null,e):e}(ce(s),a):{eventCountTotal:0,businessEventCount:0,pageViewCount:0,pageClickCount:0,networkChangeCount:0,backgroundCount:0,pageKeys:{},pageStayList:[],pageSeqCounter:0,currentPageSeq:0,lastEventName:"",scrollTotalCount:0,scrollTotalDistance:0,scrollUpCount:0,scrollDownCount:0,scrollDirectionChangeCount:0,lastScrollDirection:0,firstScrollDelayMs:-1,hasScrolled:!1,maxScrollDepthPercent:0,isReachBottom:0,keydownTotalCount:0,inputCount:0,focusTotalCount:0,firstInteractionDelayMs:-1,firstInteractionRecorded:!1,lastOrientation:"unknown",touchTotalCount:0,swipeTotalCount:0,longPressTotalCount:0,pinchTotalCount:0,currentPage:null},pe(o,{sid:o,savedAt:Date.now(),sessionStartedAt:Dn,behaviorHash:ne(),metrics:i}),r=i,Q().then(function(e){var o=bt(n._sdkOpts(),{deviceId:e});return n.configMgr.getSigningConfig().then(function(e){var n=Me(t,t.endReason,r);return it(o,n,e,t.sid)})}).then(function(e){return e}).then(function(e){return delete e._sessionInfo,n.batcher.enqueueAssembled(e,"")}).then(function(){var n,r;!function(t){if(t&&(delete En[t],Pn[t]=1,se()))try{window.sessionStorage.setItem(bn+(t+""),"1")}catch(e){}}(t.sid),ke(t.sid),n={pageKey:Ln||"",pageName:Rn||""},r=function(e){if(!e||null==e.sessionStartTs)return Date.now();var t=e.sessionStartTs;return 1e12>t?1e3*t:t}(F()),function(t,n){var r,o;t&&function(t){if(t){if(se())try{window.sessionStorage.removeItem(de(t))}catch(e){}if(ye())try{window.localStorage.removeItem(de(t))}catch(e){}}}(t),xn={eventCountTotal:0,businessEventCount:0,pageViewCount:0,pageClickCount:0,networkChangeCount:0,backgroundCount:0,pageKeys:{},pageStayList:[],pageSeqCounter:0,currentPageSeq:0,lastEventName:"",scrollTotalCount:0,scrollTotalDistance:0,scrollUpCount:0,scrollDownCount:0,scrollDirectionChangeCount:0,lastScrollDirection:0,firstScrollDelayMs:-1,hasScrolled:!1,maxScrollDepthPercent:0,isReachBottom:0,keydownTotalCount:0,inputCount:0,focusTotalCount:0,firstInteractionDelayMs:-1,firstInteractionRecorded:!1,lastOrientation:"unknown",touchTotalCount:0,swipeTotalCount:0,longPressTotalCount:0,pinchTotalCount:0,currentPage:null},Dn=r=n&&"number"==typeof n.sessionStartMs&&isFinite(n.sessionStartMs)?n.sessionStartMs:Date.now(),xn.lastOrientation=re(),mn.reset(),Cn=!0,Sn=z()||"",(o=n&&n.carryOver)&&o.pageKey?function(t,n,r){var o,i,a,s,c=((t||"")+"").trim();if(!c)return!1;o="number"==typeof r&&isFinite(r)?r:Date.now(),i=Date.now(),a=!1;try{a="undefined"!=typeof document&&"hidden"===document.visibilityState}catch(e){}xn.pageKeys[c]=1,s=ae(),xn.currentPage={page_key:c,page_name:(n||"")+"",enter_time:o,enter_time_ms:o,page_seq_in_sid:s,has_interaction:!1,accumulated_ms:0,resume_at_ms:a?null:i,hidden_paused:a},me()}(o.pageKey,o.pageName,r):me()}(t.sid,{sessionStartMs:r,carryOver:n.pageKey?{pageKey:n.pageKey,pageName:n.pageName}:null})}).catch(function(){var e;(e=t.sid)&&delete En[e],function(e){if(e&&e.sid){var t=ve().filter(function(t){return t&&t.sid!==e.sid});t.push(e),we(t)}}({sid:t.sid,sessionStartTs:t.sessionStartTs,sessionEndTs:t.sessionEndTs,endReason:t.endReason,fields:Me(t,t.endReason,r)})})):Promise.resolve():Promise.resolve()},Et.prototype._replayPendingSummaries=function(e,t,n){var r,o,i=ve();if(!i.length)return Promise.resolve();for(r=Promise.resolve(),o=0;i.length>o;o++)(function(o){r=r.then(function(){return n(it(e,o.fields,t,o.sid)).then(function(){return ke(o.sid),null}).catch(function(){})})})(i[o]);return r},Et.prototype._startAutoEvents=function(){if(!this._ssrSkipped&&!this._autoEventsStarted){var t=this;this._autoEventsStarted=!0,qn=function(e){t._handleEndedSession(e).catch(function(){})},Z().then(function(){return Q()}).then(function(n){var r=bt(t._sdkOpts(),{deviceId:n});return t.configMgr.getSigningConfig().then(function(n){return t.configMgr.fetchAndDecryptConfig().then(function(o){function i(e){return!s||-1!==s.indexOf(e)}function a(e){return i(e)&&!t._isAutoDisabled(e)}var s=o.eventCodes&&o.eventCodes.length>0?o.eventCodes:null,c=function(e){return e.then(function(e){return delete e._sessionInfo,t.batcher.enqueueAssembled(e,"")})};return t._replayPendingSummaries(r,n,c).then(function(){function o(e){if(!s&&(s=!0,a("app_session_summary"))){var n=function(e){var t,n,r=Date.now()||Date.now(),o=F();return o?(t=Y(o,e||"web_visibility_hidden",r),"web_visibility_hidden"!==e&&"app_exit"!==e||(t.sessionEndTs=r),N(n=H(r)),{endedSession:t,sid:n.sid}):null}(e);n&&n.endedSession&&t._handleEndedSession(n.endedSession).catch(function(){})}}var s,u,l,d,_,p,f,h,g,m,y,v,w,k,b;a("sdk_init")&&c(function(e,t){var n,r=fn||{},o={},i=Object.keys(r);for(n=0;i.length>n;n++)o[i[n]]=r[i[n]];return $e(e,{event:"sdk_init",payload:o},t)}(r,n)).catch(function(){}),a("app_lifecycle")&&(t._webLoadTeardown=function(t,n,r){function o(){a||(a=!0,s&&(window.removeEventListener("load",s,!1),s=null),t(ot(n,Kn,{},r)).catch(function(){}))}function i(){(function(){var t,n,r;try{if("undefined"!=typeof performance&&performance.timing&&(t=performance.timing).loadEventEnd&&t.navigationStart&&t.loadEventEnd>0)return!0;if("undefined"!=typeof performance&&"function"==typeof performance.getEntriesByType&&(n=performance.getEntriesByType("navigation"))&&n.length>0&&(r=n[0]).loadEventEnd>0&&r.fetchStart>=0)return!0}catch(e){}return!1})()&&o()}var a,s;return"undefined"==typeof window?(t(ot(n,Kn,{},r)).catch(function(){}),function(){}):(a=!1,s=null,"complete"===document.readyState?(i(),a||o(),function(){}):(s=function(){i(),a||o()},window.addEventListener("load",s,!1),setTimeout(function(){a||o()},1e4),function(){a=!0,s&&(window.removeEventListener("load",s,!1),s=null)}))}(c,r,n)),s=!1,t._autoLifecycleTeardown=function(e,t){var n,r,o,i,a;return"undefined"==typeof window||"undefined"==typeof document?function(){}:(r=!0===(n=t||{}).webMode,o=function(){"visible"===document.visibilityState?r?n.onVisible&&n.onVisible():e(Vn,{}):"hidden"===document.visibilityState&&(r?n.onVisibleHidden&&n.onVisibleHidden():e(jn,{}),n.onHidden&&n.onHidden())},i=function(){e(Nn,rt()),n.onPageUnload&&n.onPageUnload()},a=function(e){e&&e.persisted||n.onPageHide&&n.onPageHide()},window.addEventListener("visibilitychange",o,!1),window.addEventListener("beforeunload",i,!1),window.addEventListener("pagehide",a,!1),function(){window.removeEventListener("visibilitychange",o,!1),window.removeEventListener("beforeunload",i,!1),window.removeEventListener("pagehide",a,!1)})}(function(e,t){return a("app_lifecycle")?c(ot(r,e,t||{},n)):Promise.resolve()},{onHidden:function(){t.batcher.flush().catch(function(){})},onPageUnload:function(){t.batcher.flush().catch(function(){}),o("app_exit")},onPageHide:function(){t.batcher.flush().catch(function(){}),o("web_visibility_hidden")}}),t._autoNetworkTeardown=(l=function(e){return a("network_state_change")?c(function(e,t,n){return $e(e,{event:"network_state_change",payload:t||{}},n)}(r,e,n)):Promise.resolve()},"undefined"==typeof navigator?function(){}:(d=navigator.connection||navigator.mozConnection||navigator.webkitConnection)?(_="unknown",p=d.type?d.type+"":"",f="boolean"==typeof navigator.onLine?navigator.onLine:null,d.onchange=function(){var e,t,n,r,o,i,a=d.type?d.type+"":"",s="boolean"==typeof navigator.onLine?navigator.onLine:null;(a!==p||s!==f)&&(p=a,f=s,r="boolean"==typeof(t=s)?t?1:0:-1,"none"==(n=(a||"")+"")&&1===r?n="unknown":n&&"none"!==n&&"unknown"!==n&&0===r&&(r=1),l(at({network_type:_,from_network_type:_,to_network_type:_,is_online:(e={connection_type:n,is_online:r}).is_online,change_count_in_sid:(xn.networkChangeCount||0)+1},(i={},(o=e.connection_type)?i.connection_type=o+"":d.type&&(i.connection_type=d.type+""),d.effectiveType&&(i.effective_type=d.effectiveType+""),null!=d.downlink&&(i.downlink=Math.round(1e3*d.downlink)),null!=d.rtt&&(i.rtt=d.rtt),i))))},function(){d.onchange=null}):function(){}),t._webInteractionTeardown="undefined"==typeof window||"undefined"==typeof document?function(){}:(h=function(e){!e||e.repeat||e.isComposing||(Se(),he(),xn.keydownTotalCount++,me())},g=function(e){var t,n=e&&e.target;(function(t){if(!t||1!==t.nodeType)return!1;var n=((t.tagName||"")+"").toLowerCase();if("input"===n||"textarea"===n)return!0;try{if(t.isContentEditable)return!0}catch(e){}return!1})(n)&&(t=Date.now(),Fn===n&&50>t-Hn||(Fn=n,Hn=t,Se(),he(),xn.inputCount++,me()))},m=function(e){(function(t){if(!t||1!==t.nodeType)return!1;var n=((t.tagName||"")+"").toLowerCase();if("input"===n||"textarea"===n||"select"===n)return!0;try{if(t.isContentEditable)return!0}catch(e){}return!1})(e&&e.target)&&(Se(),he(),xn.focusTotalCount++,me())},y=function(){Pe()},v=function(e){e&&e.changedTouches&&e.changedTouches.length&&(Se(),he(),xn.touchTotalCount++,me(),e.touches&&1===e.touches.length?(Un=e.touches[0].clientX,Xn=e.touches[0].clientY,Yn=Date.now(),ct(),zn=!1,Gn=setTimeout(function(){zn=!0,he(),xn.longPressTotalCount++,me()},500)):(Yn=0,ct()),e.touches&&2===e.touches.length&&(Wn=st(e.touches),Jn=!1))},w=function(e){var t,n,r;e&&e.touches&&(1===e.touches.length&&Yn>0&&Math.sqrt((t=e.touches[0].clientX-Un)*t+(n=e.touches[0].clientY-Xn)*n)>8&&ct(),2===e.touches.length&&(r=st(e.touches),null==Wn||Jn||12>Math.abs(r-Wn)||(he(),xn.pinchTotalCount++,me(),Jn=!0),Wn=r))},k=function(e){var t,n;ct(),Yn>0&&e.changedTouches&&e.changedTouches.length&&!zn&&(30>Math.sqrt((t=e.changedTouches[0].clientX-Un)*t+(n=e.changedTouches[0].clientY-Xn)*n)||(he(),xn.swipeTotalCount++,me())),Yn=0,zn=!1,e.touches&&e.touches.length>=2||(Wn=null,Jn=!1)},b=function(){ct(),Yn=0,zn=!1,Wn=null,Jn=!1},document.addEventListener("keydown",h,!1),document.addEventListener("input",g,!1),document.addEventListener("focusin",m,!1),window.addEventListener("orientationchange",y,!1),document.addEventListener("touchstart",v,!1),document.addEventListener("touchmove",w,!1),document.addEventListener("touchend",k,!1),document.addEventListener("touchcancel",b,!1),Pe(),function(){document.removeEventListener("keydown",h,!1),document.removeEventListener("input",g,!1),document.removeEventListener("focusin",m,!1),window.removeEventListener("orientationchange",y,!1),document.removeEventListener("touchstart",v,!1),document.removeEventListener("touchmove",w,!1),document.removeEventListener("touchend",k,!1),document.removeEventListener("touchcancel",b,!1),ct()}),(i("app_page_view")||i("page_click"))&&(u=a("app_page_view"),t._pageTrackerTeardown=function(t){function n(){if(u.resolvePageKey)try{var t=u.resolvePageKey();if(t)return qe(t+"")}catch(e){}return Be()}function r(){var e,t=n(),r=ze({previousPageKey:d,previousPageName:_,pageKey:t});d=t,_=r.page_name,Ln=t,Rn=r.page_name||"",l&&(e=ae(),function(e,t,n,r){he(),xn.eventCountTotal++,xn.lastEventName="app_page_view",xn.pageViewCount++;var o=(e||"")+"";o&&(xn.pageKeys[o]=1),Te(o,t,n,r),me()}(t,r.page_name,Math.floor(Date.now()/1e3),e),r._pageSeqPrealloc=e,r._metricsRecorded=1,u.onPageView&&u.onPageView(r))}function o(){var t,n,r,o,i=Date.now();200>i-Mn||(Mn=i,(t=Ke())&&(r=(n=t.scrollTop||0)-In,In=n,o=function(){var t,n,r;try{return(t=Ke())?(n=Math.max(0,(t.scrollHeight||0)-(t.clientHeight||0)))>0?{depthPercent:Math.min(100,Math.max(0,Math.round((r=t.scrollTop||0)/n*100))),atBottom:r>=n-8}:{depthPercent:0,atBottom:!0}:{depthPercent:0,atBottom:!1}}catch(e){return{depthPercent:0,atBottom:!1}}}(),u.onScroll&&u.onScroll(r,o.depthPercent,o.atBottom)))}function i(e){var t,r,o,i;e&&0>=e.button&&((t=e.target)&&"function"==typeof t.closest&&t.closest("[data-sdk-no-auto-click]")||(r=null!=e.clientX?e.clientX:0,o=null!=e.clientY?e.clientY:0,i=Ye(e),setTimeout(function(){if(!function(e,t){var n,r;for(function(e){for(var t=An.length-1;t>=0;t--)e-An[t].t>300&&An.splice(t,1)}(Date.now()),n=0;An.length>n;n++)if(10>=Math.abs((r=An[n]).x-e)&&10>=Math.abs(r.y-t))return!0;return!1}(r,o)){Se();var e=function(e,t){var n=e||{},r=qe(n.pageKey||Be()),o=Ye(t);return Ae({page_key:r,page_name:n.pageName||"undefined"!=typeof document&&document.title||r,recommend_trace_id:"",referrer_page_key:n.previousPageKey||"",is_sdk_auto_collected:1,_clickSnap:o},Ge(o))}({pageKey:d||n(),pageName:_,previousPageKey:d},i);u.onPageClick&&u.onPageClick(e)}},50)))}function a(){Ie(),r()}function s(e){e&&e.persisted&&r()}function c(){var e,t,n=Ke();In=n&&n.scrollTop||0,r(),!p&&window.history&&(p=!0,e=history.pushState,t=history.replaceState,history.pushState=function(){var t=e.apply(history,arguments);return Ie(),setTimeout(r,0),t},history.replaceState=function(){var e=t.apply(history,arguments);return Ie(),setTimeout(r,0),e},window.addEventListener("popstate",a,!1))}var u,l,d,_,p;return"undefined"==typeof window||"undefined"==typeof document?function(){}:(l=!1!==(u=t||{}).enableAutoPageView,d="",_="",p=!1,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",c,!1):c(),window.addEventListener("pageshow",s,!1),window.addEventListener("scroll",o,!1),document.addEventListener("click",i,!0),function(){document.removeEventListener("click",i,!0),window.removeEventListener("scroll",o,!1),window.removeEventListener("pageshow",s,!1),window.removeEventListener("popstate",a,!1),document.removeEventListener("DOMContentLoaded",c,!1)})}({resolvePageKey:t.options.pageKeyResolver,enableAutoPageView:u,onPageView:function(e){u&&t.batcher.track({event:"app_page_view",payload:e},"").catch(function(){})},onPageClick:function(e){a("page_click")&&t.batcher.track({event:"page_click",payload:e},"").catch(function(){})},onScroll:function(e,t,n){!function(e,t,n){var r,o;he(),8>(r=Math.abs(e))||(xn.hasScrolled||(xn.hasScrolled=!0,xn.firstScrollDelayMs=Math.max(0,Date.now()-Dn)),xn.scrollTotalCount++,xn.scrollTotalDistance+=r,o=e>0?1:-1,0!==xn.lastScrollDirection&&o!==xn.lastScrollDirection&&xn.scrollDirectionChangeCount++,xn.lastScrollDirection=o,0>e?xn.scrollUpCount++:e>0&&xn.scrollDownCount++,null!=t&&(xn.maxScrollDepthPercent=Math.max(xn.maxScrollDepthPercent,Math.min(100,Math.max(0,Math.round(t))))),n&&(xn.isReachBottom=1),me())}(e,t,n)}})),t._sessionPollTimer&&clearInterval(t._sessionPollTimer),t._sessionPollTimer=setInterval(function(){var e=G();e.endedSession&&t._handleEndedSession(e.endedSession).catch(function(){})},6e4)})})})}).catch(function(){})}},Et.prototype._init=function(e){var t,n,r;if(this._ssrSkipped)return this._buildInitReturn();for(t=Object.keys(e||{}),n=0;t.length>n;n++)this.options[t[n]]=e[t[n]];return null==this.options.channel&&(this.options.channel=""),this._disableAutoEvents=Ct(this.options.disableAutoEvents),this.options.disableAutoEvents=St(this._disableAutoEvents),this.configMgr.setEncryptedConfig(this.options.encryptedConfig||this.options.configCipher||""),this.configMgr.prewarm(),r=this,this.resolver.init(function(){return r.configMgr.getBatchEndpoints()}),this._refreshTimer&&clearInterval(this._refreshTimer),this._refreshTimer=setInterval(function(){r.refreshConfig()},12e5),this._unsubReady&&this._unsubReady(),this._unsubReady=this.resolver.onEndpointReady(function(){r.batcher.flush().catch(function(){})}),this._buildInitReturn()},Et.prototype.refreshConfig=function(){var e=this;return this.configMgr.forceRefresh(),this.resolver.refreshDomainConfig(function(){return e.configMgr.getBatchEndpoints()}).catch(function(){})},Et.prototype.track=function(e,t){return this._ssrSkipped?Promise.resolve({sent:!1,ok:!0,status:0,reason:"ssr_skipped"}):this.batcher.track(e,t)},Et.prototype.setContext=function(e){if(this._ssrSkipped)return this.getConfig();var t=e&&"object"==typeof e?e:{};return Object.prototype.hasOwnProperty.call(t,"uid")&&(this.options.uid=null==t.uid?"":t.uid+""),Object.prototype.hasOwnProperty.call(t,"channel")&&(this.options.channel=null==t.channel?"":t.channel+""),this.batcher.opts=bt(this.options),this.getConfig()},Et.prototype.setUid=function(e){return this.setContext({uid:e})},Et.prototype.getSelectedHost=function(){var e,t;return this._ssrSkipped||!this.resolver?"":(e=this.resolver.getSelectedBatchEndpoint())&&(t=/^(https?:\/\/[^/]+)/.exec(e))?t[1]:""},Et.prototype._buildInitReturn=function(){return this._ssrSkipped||!this.batcher?{selectedHost:"",version:$n}:bt(this.batcher.getConfig(),{selectedHost:this.getSelectedHost(),version:$n})},Et.prototype.getConfig=function(){if(this._ssrSkipped||!this.batcher)return bt(this.options,{selectedHost:"",selectedBatchEndpoint:"",version:$n});var e=bt(this.options,this.batcher.getConfig(),{selectedHost:this.getSelectedHost(),selectedBatchEndpoint:this.resolver.getSelectedBatchEndpoint(),version:$n});return delete e.encryptedConfig,delete e.configCipher,e},Et.prototype.flush=function(){return this._ssrSkipped?Promise.resolve():this.batcher.flush()},Et.prototype.refreshDomainConfig=function(){if(this._ssrSkipped)return Promise.resolve({selectedBatchEndpoint:"",selectedHost:"",version:$n});var e=this;return this.resolver.refreshDomainConfig(function(){return e.configMgr.getBatchEndpoints()}).then(function(t){return{selectedBatchEndpoint:t,selectedHost:e.getSelectedHost(),version:$n}})},Et.prototype.stop=function(){this._ssrSkipped||(this._sessionPollTimer&&(clearInterval(this._sessionPollTimer),this._sessionPollTimer=null),this._pageTrackerTeardown&&(this._pageTrackerTeardown(),this._pageTrackerTeardown=null),this._webInteractionTeardown&&(this._webInteractionTeardown(),this._webInteractionTeardown=null),this._webLoadTeardown&&(this._webLoadTeardown(),this._webLoadTeardown=null),this._autoLifecycleTeardown&&(this._autoLifecycleTeardown(),this._autoLifecycleTeardown=null),this._autoNetworkTeardown&&(this._autoNetworkTeardown(),this._autoNetworkTeardown=null),this._unsubReady&&(this._unsubReady(),this._unsubReady=null),this._refreshTimer&&(clearInterval(this._refreshTimer),this._refreshTimer=null),this.batcher.stop())},Ht=null,Ut=[],Xt=null,(Yt=Dt).setContext=function(e){return Tt().setContext(e)},Yt.setUid=function(e){return Tt().setUid(e)},Yt.setChannel=function(e){return Tt().setContext({channel:e})},Yt.notifyPageLoad=function(){!function(){if(cn=null,V())try{window.sessionStorage.removeItem(tn),window.sessionStorage.removeItem(nn)}catch(e){}}(),G(Date.now())},Yt.debugInvalidateSessionCache=function(){W()},Yt.debugForceEnsureSession=function(){var e,t;return W(),e=G(),t=Tt(),e.endedSession&&t._handleEndedSession(e.endedSession).catch(function(){}),e},Yt.getConfig=function(){return Tt().getConfig()},Yt.flush=function(){return Tt().flush()},Yt.refreshDomainConfig=function(){return Tt().refreshDomainConfig()},Yt.stop=function(){Ht?(Ht.stop(),Ht=null):Ut.length=0},Yt.version=$n,Yt.getCommonFields=function(e){var t=Ht?Ht.getConfig():{};return Z().then(function(){return Q().then(function(n){var r,o,i,a,s=B({appId:t.appId,channel:t.channel,uid:t.uid,deviceId:t.deviceId||n,sid:(a=z(),a||G().sid),deviceType:y(),deviceBrand:"",deviceModel:"",deviceFingerprint:ee(),fpVersion:te(),sdkVersion:$n,appVersion:v(t.appVersion)});if(!Array.isArray(e)||!e.length)return s;for(r={},o=0;e.length>o;o++)(i=((e[o]||"")+"").trim())&&(r[i]=Object.prototype.hasOwnProperty.call(s,i)?s[i]:"");return r})})},Yt.getCommonField=function(e){var t=((e||"")+"").trim();return t?Yt.getCommonFields([t]).then(function(e){return e[t]}):Promise.resolve("")},Yt.init=function(e){var t,n,r,o;return Ht&&(t=Ht,Ht=null,t.flush().catch(function(){}).then(function(){t.stop()})),n=new Et(e),Ht=n,o=xt(Ut.splice(0),n,0).then(function(){Xt===r&&(Xt=null)},function(){Xt===r&&(Xt=null)}),Xt=r=o,bt(n._buildInitReturn(),{track:Yt.track,setContext:Yt.setContext,setUid:Yt.setUid,setChannel:Yt.setChannel,notifyPageLoad:Yt.notifyPageLoad,getCommonFields:Yt.getCommonFields,getCommonField:Yt.getCommonField})},Gt=function(){return Kt("[sdk][web] 非法上报:禁止直接调用 WebSDK.track({...}),请使用 WebSDK.track.videoEvent({...}) 等具名方法"),Promise.resolve({sent:!1,ok:!1,status:0,reason:"direct_track_forbidden"})},Object.keys(Bt).forEach(function(t){var n=Bt[t];Gt[t]=function(r,o){try{return Dt(n(r),o)}catch(e){if(e&&"EventValidationError"===e.name)return Promise.resolve({sent:!1,ok:!1,status:0,reason:"event_validation_failed",event:e.eventMethod||t,missingFields:e.missingFields||[],typeErrorFields:e.typeErrorFields||[]});throw e}}}),Yt.track="undefined"!=typeof Proxy?new Proxy(Gt,{get:function(e,t){return"string"!=typeof t||t in e||!/^[a-z]/.test(t)?e[t]:function(){return Kt('[sdk][web] 未知上报事件 "'+t+'",该方法不存在,事件未上报'),Promise.resolve({sent:!1,ok:!1,status:0,reason:"unknown_event_method",method:t})}}}):Gt,"undefined"!=typeof globalThis)try{globalThis.WebSDK=Yt}catch(e){}else"undefined"!=typeof window&&(window.WebSDK=Yt);return Yt}); diff --git a/public/static/favicon.ico b/public/static/favicon.ico new file mode 100644 index 0000000..ef8fbc7 Binary files /dev/null and b/public/static/favicon.ico differ diff --git a/public/static/favicon.png b/public/static/favicon.png new file mode 100644 index 0000000..94fd7e1 Binary files /dev/null and b/public/static/favicon.png differ diff --git a/public/static/favicon.svg b/public/static/favicon.svg new file mode 100644 index 0000000..0cbff4a --- /dev/null +++ b/public/static/favicon.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/static/images/18jia.svg b/public/static/images/18jia.svg new file mode 100644 index 0000000..3bcb7ea --- /dev/null +++ b/public/static/images/18jia.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/about/about-hero.png b/public/static/images/about/about-hero.png new file mode 100644 index 0000000..0582b7c Binary files /dev/null and b/public/static/images/about/about-hero.png differ diff --git a/public/static/images/app-qr.png b/public/static/images/app-qr.png new file mode 100644 index 0000000..02f0e09 Binary files /dev/null and b/public/static/images/app-qr.png differ diff --git a/public/static/images/arrow-right-line.svg b/public/static/images/arrow-right-line.svg new file mode 100644 index 0000000..787ba54 --- /dev/null +++ b/public/static/images/arrow-right-line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/arrow-up-right.svg b/public/static/images/arrow-up-right.svg new file mode 100644 index 0000000..b1e35dc --- /dev/null +++ b/public/static/images/arrow-up-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/auth/award.svg b/public/static/images/auth/award.svg new file mode 100644 index 0000000..df65005 --- /dev/null +++ b/public/static/images/auth/award.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/badge_bj.png b/public/static/images/badge_bj.png new file mode 100644 index 0000000..338536d Binary files /dev/null and b/public/static/images/badge_bj.png differ diff --git a/public/static/images/badge_yc.png b/public/static/images/badge_yc.png new file mode 100644 index 0000000..acd0ca5 Binary files /dev/null and b/public/static/images/badge_yc.png differ diff --git a/public/static/images/badge_yj.png b/public/static/images/badge_yj.png new file mode 100644 index 0000000..61bb6ab Binary files /dev/null and b/public/static/images/badge_yj.png differ diff --git a/public/static/images/bell.svg b/public/static/images/bell.svg new file mode 100644 index 0000000..4730e4d --- /dev/null +++ b/public/static/images/bell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/bookmark.svg b/public/static/images/bookmark.svg new file mode 100644 index 0000000..895d455 --- /dev/null +++ b/public/static/images/bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/brand-android.svg b/public/static/images/brand-android.svg new file mode 100644 index 0000000..e8832cd --- /dev/null +++ b/public/static/images/brand-android.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/static/images/brand-apple.svg b/public/static/images/brand-apple.svg new file mode 100644 index 0000000..bf6d815 --- /dev/null +++ b/public/static/images/brand-apple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/brand-instagram.svg b/public/static/images/brand-instagram.svg new file mode 100644 index 0000000..3196740 --- /dev/null +++ b/public/static/images/brand-instagram.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/brand-telegram.svg b/public/static/images/brand-telegram.svg new file mode 100644 index 0000000..e832477 --- /dev/null +++ b/public/static/images/brand-telegram.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/brand-tiktok.svg b/public/static/images/brand-tiktok.svg new file mode 100644 index 0000000..37d953b --- /dev/null +++ b/public/static/images/brand-tiktok.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/brand-x.svg b/public/static/images/brand-x.svg new file mode 100644 index 0000000..d5862a2 --- /dev/null +++ b/public/static/images/brand-x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/brand-youtube.svg b/public/static/images/brand-youtube.svg new file mode 100644 index 0000000..b8df721 --- /dev/null +++ b/public/static/images/brand-youtube.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/check-circle.svg b/public/static/images/check-circle.svg new file mode 100644 index 0000000..afd41fe --- /dev/null +++ b/public/static/images/check-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/check.svg b/public/static/images/check.svg new file mode 100644 index 0000000..9d95a45 --- /dev/null +++ b/public/static/images/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/checked.svg b/public/static/images/checked.svg new file mode 100644 index 0000000..8054dcb --- /dev/null +++ b/public/static/images/checked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/chevron-left.svg b/public/static/images/chevron-left.svg new file mode 100644 index 0000000..9ebce6e --- /dev/null +++ b/public/static/images/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/chevron-right.svg b/public/static/images/chevron-right.svg new file mode 100644 index 0000000..ab24d97 --- /dev/null +++ b/public/static/images/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/clock-dim.svg b/public/static/images/clock-dim.svg new file mode 100644 index 0000000..9204617 --- /dev/null +++ b/public/static/images/clock-dim.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/close.svg b/public/static/images/close.svg new file mode 100644 index 0000000..418cf7e --- /dev/null +++ b/public/static/images/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/close2.svg b/public/static/images/close2.svg new file mode 100644 index 0000000..1525ae9 --- /dev/null +++ b/public/static/images/close2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/coin.png b/public/static/images/coin.png new file mode 100644 index 0000000..0fd39f9 Binary files /dev/null and b/public/static/images/coin.png differ diff --git a/public/static/images/copy.svg b/public/static/images/copy.svg new file mode 100644 index 0000000..3c51ca7 --- /dev/null +++ b/public/static/images/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/download.svg b/public/static/images/download.svg new file mode 100644 index 0000000..98077da --- /dev/null +++ b/public/static/images/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/error-tip.svg b/public/static/images/error-tip.svg new file mode 100644 index 0000000..d554d4c --- /dev/null +++ b/public/static/images/error-tip.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/external-link.svg b/public/static/images/external-link.svg new file mode 100644 index 0000000..c348768 --- /dev/null +++ b/public/static/images/external-link.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/eye-off.svg b/public/static/images/eye-off.svg new file mode 100644 index 0000000..3ca0b3e --- /dev/null +++ b/public/static/images/eye-off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/eye.svg b/public/static/images/eye.svg new file mode 100644 index 0000000..93ea20f --- /dev/null +++ b/public/static/images/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/favorites-empty/arrow-right.svg b/public/static/images/favorites-empty/arrow-right.svg new file mode 100644 index 0000000..787ba54 --- /dev/null +++ b/public/static/images/favorites-empty/arrow-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/favorites-empty/bookmark.svg b/public/static/images/favorites-empty/bookmark.svg new file mode 100644 index 0000000..9217785 --- /dev/null +++ b/public/static/images/favorites-empty/bookmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/favorites/sliders.svg b/public/static/images/favorites/sliders.svg new file mode 100644 index 0000000..69aed31 --- /dev/null +++ b/public/static/images/favorites/sliders.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/static/images/feedback/help-circle.svg b/public/static/images/feedback/help-circle.svg new file mode 100644 index 0000000..1ee8093 --- /dev/null +++ b/public/static/images/feedback/help-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/film.svg b/public/static/images/film.svg new file mode 100644 index 0000000..84426a1 --- /dev/null +++ b/public/static/images/film.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/static/images/film2.svg b/public/static/images/film2.svg new file mode 100644 index 0000000..6db647b --- /dev/null +++ b/public/static/images/film2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/static/images/gift-dim.svg b/public/static/images/gift-dim.svg new file mode 100644 index 0000000..cb291ac --- /dev/null +++ b/public/static/images/gift-dim.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/static/images/gift.svg b/public/static/images/gift.svg new file mode 100644 index 0000000..603f7a1 --- /dev/null +++ b/public/static/images/gift.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/static/images/heart.svg b/public/static/images/heart.svg new file mode 100644 index 0000000..b1b0eac --- /dev/null +++ b/public/static/images/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/help-circle.svg b/public/static/images/help-circle.svg new file mode 100644 index 0000000..1ee8093 --- /dev/null +++ b/public/static/images/help-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/home-road/android.svg b/public/static/images/home-road/android.svg new file mode 100644 index 0000000..ff6a93a --- /dev/null +++ b/public/static/images/home-road/android.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/home-road/desktop.svg b/public/static/images/home-road/desktop.svg new file mode 100644 index 0000000..c9ccc20 --- /dev/null +++ b/public/static/images/home-road/desktop.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/home-road/devices.svg b/public/static/images/home-road/devices.svg new file mode 100644 index 0000000..7296b21 --- /dev/null +++ b/public/static/images/home-road/devices.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/home-road/hero-background-mobile.png b/public/static/images/home-road/hero-background-mobile.png new file mode 100644 index 0000000..ab7e488 Binary files /dev/null and b/public/static/images/home-road/hero-background-mobile.png differ diff --git a/public/static/images/home-road/hero-background.png b/public/static/images/home-road/hero-background.png new file mode 100644 index 0000000..b60f4ca Binary files /dev/null and b/public/static/images/home-road/hero-background.png differ diff --git a/public/static/images/home-road/iphone.svg b/public/static/images/home-road/iphone.svg new file mode 100644 index 0000000..1674bb3 --- /dev/null +++ b/public/static/images/home-road/iphone.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/home-road/link.svg b/public/static/images/home-road/link.svg new file mode 100644 index 0000000..50cef4a --- /dev/null +++ b/public/static/images/home-road/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/home-road/official.svg b/public/static/images/home-road/official.svg new file mode 100644 index 0000000..134f493 --- /dev/null +++ b/public/static/images/home-road/official.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/home-road/rocket.svg b/public/static/images/home-road/rocket.svg new file mode 100644 index 0000000..27303ff --- /dev/null +++ b/public/static/images/home-road/rocket.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/home-road/shield.svg b/public/static/images/home-road/shield.svg new file mode 100644 index 0000000..a49a7c0 --- /dev/null +++ b/public/static/images/home-road/shield.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/home-road/troubleshoot.svg b/public/static/images/home-road/troubleshoot.svg new file mode 100644 index 0000000..a48334e --- /dev/null +++ b/public/static/images/home-road/troubleshoot.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/home.svg b/public/static/images/home.svg new file mode 100644 index 0000000..f22a4ae --- /dev/null +++ b/public/static/images/home.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/image-dim.svg b/public/static/images/image-dim.svg new file mode 100644 index 0000000..140707f --- /dev/null +++ b/public/static/images/image-dim.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/static/images/install/external-link.svg b/public/static/images/install/external-link.svg new file mode 100644 index 0000000..8a0c6f5 --- /dev/null +++ b/public/static/images/install/external-link.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/install/notice.svg b/public/static/images/install/notice.svg new file mode 100644 index 0000000..da3c26f --- /dev/null +++ b/public/static/images/install/notice.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/link_award.svg b/public/static/images/link_award.svg new file mode 100644 index 0000000..a47856b --- /dev/null +++ b/public/static/images/link_award.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/link_globe.svg b/public/static/images/link_globe.svg new file mode 100644 index 0000000..cc808c3 --- /dev/null +++ b/public/static/images/link_globe.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/static/images/link_refresh-cw.svg b/public/static/images/link_refresh-cw.svg new file mode 100644 index 0000000..4313d32 --- /dev/null +++ b/public/static/images/link_refresh-cw.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/link_send.svg b/public/static/images/link_send.svg new file mode 100644 index 0000000..00359d6 --- /dev/null +++ b/public/static/images/link_send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/link_shield.svg b/public/static/images/link_shield.svg new file mode 100644 index 0000000..5875138 --- /dev/null +++ b/public/static/images/link_shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/link_smartphone.svg b/public/static/images/link_smartphone.svg new file mode 100644 index 0000000..44cbc6b --- /dev/null +++ b/public/static/images/link_smartphone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/lock-dim.svg b/public/static/images/lock-dim.svg new file mode 100644 index 0000000..512fe0e --- /dev/null +++ b/public/static/images/lock-dim.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/lock.svg b/public/static/images/lock.svg new file mode 100644 index 0000000..9c5de78 --- /dev/null +++ b/public/static/images/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/logout.svg b/public/static/images/logout.svg new file mode 100644 index 0000000..b491926 --- /dev/null +++ b/public/static/images/logout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/mail-dim.svg b/public/static/images/mail-dim.svg new file mode 100644 index 0000000..e15c530 --- /dev/null +++ b/public/static/images/mail-dim.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/mail.svg b/public/static/images/mail.svg new file mode 100644 index 0000000..897be45 --- /dev/null +++ b/public/static/images/mail.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/menu.svg b/public/static/images/menu.svg new file mode 100644 index 0000000..7ff08db --- /dev/null +++ b/public/static/images/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/menu_dj.svg b/public/static/images/menu_dj.svg new file mode 100644 index 0000000..e7d9e30 --- /dev/null +++ b/public/static/images/menu_dj.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/static/images/menu_gkls.svg b/public/static/images/menu_gkls.svg new file mode 100644 index 0000000..352a9c2 --- /dev/null +++ b/public/static/images/menu_gkls.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/menu_grzx.svg b/public/static/images/menu_grzx.svg new file mode 100644 index 0000000..e71d51a --- /dev/null +++ b/public/static/images/menu_grzx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/menu_jbcz.svg b/public/static/images/menu_jbcz.svg new file mode 100644 index 0000000..fc9fdf6 --- /dev/null +++ b/public/static/images/menu_jbcz.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/menu_jj.svg b/public/static/images/menu_jj.svg new file mode 100644 index 0000000..7431832 --- /dev/null +++ b/public/static/images/menu_jj.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/menu_phb.svg b/public/static/images/menu_phb.svg new file mode 100644 index 0000000..860d5a6 --- /dev/null +++ b/public/static/images/menu_phb.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/menu_sc.svg b/public/static/images/menu_sc.svg new file mode 100644 index 0000000..26953ee --- /dev/null +++ b/public/static/images/menu_sc.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/menu_sy.svg b/public/static/images/menu_sy.svg new file mode 100644 index 0000000..94d7152 --- /dev/null +++ b/public/static/images/menu_sy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/menu_zhsz.svg b/public/static/images/menu_zhsz.svg new file mode 100644 index 0000000..709ef85 --- /dev/null +++ b/public/static/images/menu_zhsz.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/static/images/menu_zt.svg b/public/static/images/menu_zt.svg new file mode 100644 index 0000000..6c561f5 --- /dev/null +++ b/public/static/images/menu_zt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/message-circle.svg b/public/static/images/message-circle.svg new file mode 100644 index 0000000..eb099d6 --- /dev/null +++ b/public/static/images/message-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/mobile-tabbar/film-active.svg b/public/static/images/mobile-tabbar/film-active.svg new file mode 100644 index 0000000..7eb917d --- /dev/null +++ b/public/static/images/mobile-tabbar/film-active.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/static/images/mobile-tabbar/film-muted.svg b/public/static/images/mobile-tabbar/film-muted.svg new file mode 100644 index 0000000..6db647b --- /dev/null +++ b/public/static/images/mobile-tabbar/film-muted.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/static/images/mobile-tabbar/home-active.svg b/public/static/images/mobile-tabbar/home-active.svg new file mode 100644 index 0000000..58e84ce --- /dev/null +++ b/public/static/images/mobile-tabbar/home-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/mobile-tabbar/home-muted.svg b/public/static/images/mobile-tabbar/home-muted.svg new file mode 100644 index 0000000..cd49878 --- /dev/null +++ b/public/static/images/mobile-tabbar/home-muted.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/mobile-tabbar/tv-active.svg b/public/static/images/mobile-tabbar/tv-active.svg new file mode 100644 index 0000000..9d8f9a0 --- /dev/null +++ b/public/static/images/mobile-tabbar/tv-active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/mobile-tabbar/tv-muted.svg b/public/static/images/mobile-tabbar/tv-muted.svg new file mode 100644 index 0000000..1e1d298 --- /dev/null +++ b/public/static/images/mobile-tabbar/tv-muted.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/mobile-tabbar/user-active.svg b/public/static/images/mobile-tabbar/user-active.svg new file mode 100644 index 0000000..33606b6 --- /dev/null +++ b/public/static/images/mobile-tabbar/user-active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/mobile-tabbar/user-muted.svg b/public/static/images/mobile-tabbar/user-muted.svg new file mode 100644 index 0000000..05175e7 --- /dev/null +++ b/public/static/images/mobile-tabbar/user-muted.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/nav_bg.webp b/public/static/images/nav_bg.webp new file mode 100644 index 0000000..0992538 Binary files /dev/null and b/public/static/images/nav_bg.webp differ diff --git a/public/static/images/nav_grid.svg b/public/static/images/nav_grid.svg new file mode 100644 index 0000000..8ef2f77 --- /dev/null +++ b/public/static/images/nav_grid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/nav_star.svg b/public/static/images/nav_star.svg new file mode 100644 index 0000000..e63eb36 --- /dev/null +++ b/public/static/images/nav_star.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/nav_trend.svg b/public/static/images/nav_trend.svg new file mode 100644 index 0000000..11d87ff --- /dev/null +++ b/public/static/images/nav_trend.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/nav_zap.svg b/public/static/images/nav_zap.svg new file mode 100644 index 0000000..fa8c306 --- /dev/null +++ b/public/static/images/nav_zap.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/ok.svg b/public/static/images/ok.svg new file mode 100644 index 0000000..5f3b26c --- /dev/null +++ b/public/static/images/ok.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/play.svg b/public/static/images/play.svg new file mode 100644 index 0000000..54d1c8f --- /dev/null +++ b/public/static/images/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/play_hollow.svg b/public/static/images/play_hollow.svg new file mode 100644 index 0000000..9117278 --- /dev/null +++ b/public/static/images/play_hollow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/plus-dim.svg b/public/static/images/plus-dim.svg new file mode 100644 index 0000000..ec9fb33 --- /dev/null +++ b/public/static/images/plus-dim.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/plus.svg b/public/static/images/plus.svg new file mode 100644 index 0000000..d089ec5 --- /dev/null +++ b/public/static/images/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/profile/avatar.png b/public/static/images/profile/avatar.png new file mode 100644 index 0000000..5aaaf1f Binary files /dev/null and b/public/static/images/profile/avatar.png differ diff --git a/public/static/images/profile/water-margin.jpg b/public/static/images/profile/water-margin.jpg new file mode 100644 index 0000000..398eba8 Binary files /dev/null and b/public/static/images/profile/water-margin.jpg differ diff --git a/public/static/images/purchased-empty/bag-play.svg b/public/static/images/purchased-empty/bag-play.svg new file mode 100644 index 0000000..55f6c72 --- /dev/null +++ b/public/static/images/purchased-empty/bag-play.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/recharge/award.svg b/public/static/images/recharge/award.svg new file mode 100644 index 0000000..cbc100f --- /dev/null +++ b/public/static/images/recharge/award.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/recharge/back.svg b/public/static/images/recharge/back.svg new file mode 100644 index 0000000..9ebce6e --- /dev/null +++ b/public/static/images/recharge/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/recharge/check-circle.svg b/public/static/images/recharge/check-circle.svg new file mode 100644 index 0000000..9ea9d54 --- /dev/null +++ b/public/static/images/recharge/check-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/recharge/check.svg b/public/static/images/recharge/check.svg new file mode 100644 index 0000000..7ae8e93 --- /dev/null +++ b/public/static/images/recharge/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/recharge/close.svg b/public/static/images/recharge/close.svg new file mode 100644 index 0000000..418cf7e --- /dev/null +++ b/public/static/images/recharge/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/recharge/coin-star.svg b/public/static/images/recharge/coin-star.svg new file mode 100644 index 0000000..ac08866 --- /dev/null +++ b/public/static/images/recharge/coin-star.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/recharge/coin3d.png b/public/static/images/recharge/coin3d.png new file mode 100644 index 0000000..87ff947 Binary files /dev/null and b/public/static/images/recharge/coin3d.png differ diff --git a/public/static/images/recharge/download.svg b/public/static/images/recharge/download.svg new file mode 100644 index 0000000..5e59326 --- /dev/null +++ b/public/static/images/recharge/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/recharge/help.svg b/public/static/images/recharge/help.svg new file mode 100644 index 0000000..7f3fd26 --- /dev/null +++ b/public/static/images/recharge/help.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/recharge/lock.svg b/public/static/images/recharge/lock.svg new file mode 100644 index 0000000..b510fd8 --- /dev/null +++ b/public/static/images/recharge/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/recharge/zap.svg b/public/static/images/recharge/zap.svg new file mode 100644 index 0000000..576f265 --- /dev/null +++ b/public/static/images/recharge/zap.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/search.svg b/public/static/images/search.svg new file mode 100644 index 0000000..2a18788 --- /dev/null +++ b/public/static/images/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/search/search-cover-1.jpg b/public/static/images/search/search-cover-1.jpg new file mode 100644 index 0000000..6778147 Binary files /dev/null and b/public/static/images/search/search-cover-1.jpg differ diff --git a/public/static/images/search/search-cover-10.jpg b/public/static/images/search/search-cover-10.jpg new file mode 100644 index 0000000..227c542 Binary files /dev/null and b/public/static/images/search/search-cover-10.jpg differ diff --git a/public/static/images/search/search-cover-11.jpg b/public/static/images/search/search-cover-11.jpg new file mode 100644 index 0000000..29ae275 Binary files /dev/null and b/public/static/images/search/search-cover-11.jpg differ diff --git a/public/static/images/search/search-cover-12.jpg b/public/static/images/search/search-cover-12.jpg new file mode 100644 index 0000000..484e15f Binary files /dev/null and b/public/static/images/search/search-cover-12.jpg differ diff --git a/public/static/images/search/search-cover-13.jpg b/public/static/images/search/search-cover-13.jpg new file mode 100644 index 0000000..4e10510 Binary files /dev/null and b/public/static/images/search/search-cover-13.jpg differ diff --git a/public/static/images/search/search-cover-2.jpg b/public/static/images/search/search-cover-2.jpg new file mode 100644 index 0000000..94b0305 Binary files /dev/null and b/public/static/images/search/search-cover-2.jpg differ diff --git a/public/static/images/search/search-cover-3.jpg b/public/static/images/search/search-cover-3.jpg new file mode 100644 index 0000000..fc15055 Binary files /dev/null and b/public/static/images/search/search-cover-3.jpg differ diff --git a/public/static/images/search/search-cover-4.jpg b/public/static/images/search/search-cover-4.jpg new file mode 100644 index 0000000..40005a0 Binary files /dev/null and b/public/static/images/search/search-cover-4.jpg differ diff --git a/public/static/images/search/search-cover-5.jpg b/public/static/images/search/search-cover-5.jpg new file mode 100644 index 0000000..cc7a2f6 Binary files /dev/null and b/public/static/images/search/search-cover-5.jpg differ diff --git a/public/static/images/search/search-cover-6.jpg b/public/static/images/search/search-cover-6.jpg new file mode 100644 index 0000000..0e991a0 Binary files /dev/null and b/public/static/images/search/search-cover-6.jpg differ diff --git a/public/static/images/search/search-cover-7.jpg b/public/static/images/search/search-cover-7.jpg new file mode 100644 index 0000000..6b54bab Binary files /dev/null and b/public/static/images/search/search-cover-7.jpg differ diff --git a/public/static/images/search/search-cover-8.jpg b/public/static/images/search/search-cover-8.jpg new file mode 100644 index 0000000..217c113 Binary files /dev/null and b/public/static/images/search/search-cover-8.jpg differ diff --git a/public/static/images/search/search-cover-9.jpg b/public/static/images/search/search-cover-9.jpg new file mode 100644 index 0000000..f3cb4b3 Binary files /dev/null and b/public/static/images/search/search-cover-9.jpg differ diff --git a/public/static/images/search_h5.svg b/public/static/images/search_h5.svg new file mode 100644 index 0000000..682906d --- /dev/null +++ b/public/static/images/search_h5.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/send.svg b/public/static/images/send.svg new file mode 100644 index 0000000..087752e --- /dev/null +++ b/public/static/images/send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/settings/eye.svg b/public/static/images/settings/eye.svg new file mode 100644 index 0000000..1d35a4f --- /dev/null +++ b/public/static/images/settings/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/settings/field-shield.svg b/public/static/images/settings/field-shield.svg new file mode 100644 index 0000000..5e2a365 --- /dev/null +++ b/public/static/images/settings/field-shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/settings/field-user.svg b/public/static/images/settings/field-user.svg new file mode 100644 index 0000000..8540093 --- /dev/null +++ b/public/static/images/settings/field-user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/settings/image.svg b/public/static/images/settings/image.svg new file mode 100644 index 0000000..e55fb0c --- /dev/null +++ b/public/static/images/settings/image.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/static/images/settings/lock.svg b/public/static/images/settings/lock.svg new file mode 100644 index 0000000..512fe0e --- /dev/null +++ b/public/static/images/settings/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/static/images/settings/mail.svg b/public/static/images/settings/mail.svg new file mode 100644 index 0000000..7862261 --- /dev/null +++ b/public/static/images/settings/mail.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/settings/message.svg b/public/static/images/settings/message.svg new file mode 100644 index 0000000..8e1fd56 --- /dev/null +++ b/public/static/images/settings/message.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/settings/shield.svg b/public/static/images/settings/shield.svg new file mode 100644 index 0000000..2ab5bc3 --- /dev/null +++ b/public/static/images/settings/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/static/images/settings/user.svg b/public/static/images/settings/user.svg new file mode 100644 index 0000000..a5a7067 --- /dev/null +++ b/public/static/images/settings/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/share.svg b/public/static/images/share.svg new file mode 100644 index 0000000..b07da15 --- /dev/null +++ b/public/static/images/share.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/static/images/trending-up.svg b/public/static/images/trending-up.svg new file mode 100644 index 0000000..cefb5bc --- /dev/null +++ b/public/static/images/trending-up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/images/user.svg b/public/static/images/user.svg new file mode 100644 index 0000000..8540093 --- /dev/null +++ b/public/static/images/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/static/logo.png b/public/static/logo.png new file mode 100644 index 0000000..4912763 Binary files /dev/null and b/public/static/logo.png differ diff --git a/public/static/logo2.png b/public/static/logo2.png new file mode 100644 index 0000000..4c82acd Binary files /dev/null and b/public/static/logo2.png differ diff --git a/public/static/logo_icon.png b/public/static/logo_icon.png new file mode 100644 index 0000000..acf1f78 Binary files /dev/null and b/public/static/logo_icon.png differ diff --git a/public/static/og-default.png b/public/static/og-default.png new file mode 100644 index 0000000..73b9298 Binary files /dev/null and b/public/static/og-default.png differ diff --git a/public/static/pwa/apple-touch-icon.png b/public/static/pwa/apple-touch-icon.png new file mode 100644 index 0000000..aeb398f Binary files /dev/null and b/public/static/pwa/apple-touch-icon.png differ diff --git a/public/static/pwa/icon-192.png b/public/static/pwa/icon-192.png new file mode 100644 index 0000000..a30891d Binary files /dev/null and b/public/static/pwa/icon-192.png differ diff --git a/public/static/pwa/icon-512.png b/public/static/pwa/icon-512.png new file mode 100644 index 0000000..54b9df1 Binary files /dev/null and b/public/static/pwa/icon-512.png differ diff --git a/public/static/pwa/icon-maskable-512.png b/public/static/pwa/icon-maskable-512.png new file mode 100644 index 0000000..a7fd893 Binary files /dev/null and b/public/static/pwa/icon-maskable-512.png differ diff --git a/public/static/sitemap.xsl b/public/static/sitemap.xsl new file mode 100644 index 0000000..d31c603 --- /dev/null +++ b/public/static/sitemap.xsl @@ -0,0 +1,45 @@ + + + + + + + + + Sitemap + + + +

Sitemap

+ + + + + + + + + + + +
loclastmod
+ + + + +
+ + +
+
diff --git a/public/static/sitemapindex.xsl b/public/static/sitemapindex.xsl new file mode 100644 index 0000000..41afa64 --- /dev/null +++ b/public/static/sitemapindex.xsl @@ -0,0 +1,45 @@ + + + + + + + + + Sitemap Index + + + +

Sitemap Index

+ + + + + + + + + + + +
loclastmod
+ + + + +
+ + +
+
diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..51121e6 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,12 @@ +// Generated from src/lib/pwa/service-worker.ts; run npm run assets:pwa. +"use strict"; +(() => { + // src/lib/pwa/service-worker.ts + var worker = self; + worker.addEventListener("install", (event) => { + event.waitUntil(worker.skipWaiting()); + }); + worker.addEventListener("activate", (event) => { + event.waitUntil(worker.clients.claim()); + }); +})(); diff --git a/scripts/browser-smoke.ts b/scripts/browser-smoke.ts new file mode 100644 index 0000000..3045b2b --- /dev/null +++ b/scripts/browser-smoke.ts @@ -0,0 +1,195 @@ +import { chromium } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const origin = process.env.SMOKE_URL || "http://localhost:25131"; +const output = process.env.SMOKE_OUTPUT_DIR || "/tmp/hkdj-browser-smoke"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ + executablePath: + process.env.CHROME_PATH || + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: true, +}); +const results: Record[] = []; +try { + for (const [name, viewport] of [ + ["desktop", { width: 1440, height: 1000 }], + ["mobile", { width: 390, height: 844 }], + ] as const) { + const context = await browser.newContext({ + viewport, + isMobile: name === "mobile", + deviceScaleFactor: 1, + }); + await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1")); + 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. + if ( + !local && + (request.method() !== "GET" || request.resourceType() === "script") + ) + return route.abort(); + return route.continue(); + }); + const page = await context.newPage(); + const errors: string[] = []; + const missingAssets: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("response", (response) => { + if ( + response.status() >= 400 && + response.url().startsWith(origin + "/static/") + ) + missingAssets.push(new URL(response.url()).pathname); + }); + for (const path of [ + "/", + "/dramas/", + "/discover/search/", + ]) { + const response = await page.goto(origin + path, { + waitUntil: "domcontentloaded", + timeout: 40000, + }); + await page.waitForTimeout(500); + if (path !== "/discover/search/") + await page.waitForFunction( + () => typeof (window as unknown as Record).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]", + ); + if (!(await close.count()) || !(await close.first().isVisible())) break; + await close.first().click({ force: true }); + } + const layout = await page.evaluate(() => ({ + viewport: innerWidth, + document: document.documentElement.scrollWidth, + heading: document.querySelector("h1")?.textContent?.trim() || "", + legacyScripts: Array.from(document.scripts).some((script) => + /\/static\/js\//.test(script.src), + ), + tracker: + typeof (window as unknown as Record).tracker === + "object", + })); + results.push({ + device: name, + path, + status: response?.status(), + ...layout, + }); + if (response?.status() !== 200) + throw new Error(`${name} ${path} returned ${response?.status()}`); + if (layout.document > layout.viewport + 1) + 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 (path === "/") { + const accountEntries = await page.locator( + "[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']", + ).count(); + if (accountEntries) + throw new Error(`${name} home still renders ${accountEntries} account entries`); + if (name === "desktop" && await page.locator("[data-hero]").count()) { + await page.hover("[data-hero]"); + await page.waitForFunction(() => { + const slide = document.querySelector(".hk-home-hero__slide.hk-is-active"); + const image = slide?.querySelector(".hk-home-hero__poster"); + return !!image && image.complete && image.naturalWidth > 0 && + !image.hasAttribute("data-decrypt-failed") && + !image.currentSrc.includes("/static/brand/placeholder.svg") && + getComputedStyle(image).opacity === "1" && + getComputedStyle(slide!).opacity === "1"; + }, null, { timeout: 45000 }); + } + await page.screenshot({ + path: resolve(output, name + "-home.png"), + fullPage: false, + animations: "disabled", + }); + if (name === "mobile") { + await page.locator("[data-drawer-open]").click(); + if ( + !(await page + .locator("[data-drawer]") + .evaluate((node) => node.classList.contains("hk-is-open"))) + ) + throw new Error("Mobile drawer did not open"); + await page.locator(".hk-drawer__close").click(); + } else { + const input = page.locator("[data-header-search-input]"); + const suggestionResponse = page.waitForResponse( + (response) => response.url().includes("/api/web/search/suggest"), + { timeout: 15000 }, + ); + await input.fill("测试"); + await suggestionResponse; + if ( + !(await page.locator("[data-header-search-dropdown]").isVisible()) + ) + throw new Error("Search suggestions did not open"); + const download = page.locator("[data-download-open]").first(); + if (await download.isVisible()) { + await download.click(); + if ( + !(await page + .locator("[data-download-pop]") + .evaluate((node) => node.classList.contains("hk-is-open"))) + ) + throw new Error("Download dialog did not open"); + await page.keyboard.press("Escape"); + } + } + } + } + for (const removed of ["/account/", "/account/settings/", "/account/recharge/", "/login/"]) { + const response = await context.request.get(origin + removed, { maxRedirects: 0 }); + if (response.status() !== 301 || response.headers().location !== "/") + throw new Error(`${name} ${removed} should 301 to /, got ${response.status()} ${response.headers().location}`); + } + const playURL = await page.evaluate(() => + document + .querySelector('a[href^="/drama/"]') + ?.getAttribute("href"), + ); + if (playURL) { + await page.goto(origin + playURL, { waitUntil: "domcontentloaded" }); + const player = page.locator("[data-play-player]"); + results.push({ + device: name, + path: "play", + player: await player.count(), + xgplayer: await page.locator(".xgplayer").count(), + video: await page.locator("video").count(), + }); + } + if (errors.length || missingAssets.length) + throw new Error(JSON.stringify({ device: name, errors, missingAssets })); + await context.close(); + } + await writeFile( + resolve(output, "results.json"), + JSON.stringify(results, null, 2), + ); + console.log( + JSON.stringify( + { pages: results.length, devices: 2, errors: 0, output }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/scripts/build-pwa.ts b/scripts/build-pwa.ts new file mode 100644 index 0000000..6d2f6bc --- /dev/null +++ b/scripts/build-pwa.ts @@ -0,0 +1,22 @@ +import { build } from "esbuild"; +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const output = new URL("../public/sw.js", import.meta.url); +const result = await build({ + entryPoints: [fileURLToPath(new URL("../src/lib/pwa/service-worker.ts", import.meta.url))], + bundle: true, + format: "iife", + platform: "browser", + target: "es2020", + write: false, + banner: { js: "// Generated from src/lib/pwa/service-worker.ts; run npm run assets:pwa." }, +}); +const bytes = result.outputFiles[0].contents; +if (process.argv.includes("--check")) { + const existing = await readFile(output); + if (!existing.equals(Buffer.from(bytes))) throw new Error("PWA worker is stale. Run npm run assets:pwa."); +} else { + const existing = await readFile(output).catch(() => null); + if (!existing?.equals(Buffer.from(bytes))) await writeFile(output, bytes); +} diff --git a/scripts/catalog-react-smoke.ts b/scripts/catalog-react-smoke.ts new file mode 100644 index 0000000..c5c840e --- /dev/null +++ b/scripts/catalog-react-smoke.ts @@ -0,0 +1,364 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFile, mkdtemp, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { chromium } from "@playwright/test"; +import { decryptImageBytes, IMAGE_PLACEHOLDER } from "../src/lib/media/images"; +import { createStyleFixture } from "./style-fixtures"; + +// Exercise the actual React components and API client against local mocks only. +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."), + temporary = await mkdtemp(join(tmpdir(), "huakui-catalog-react-")); +const bundle = join(temporary, "catalog.js"); +const heroStyles = await createStyleFixture(["base", "index"]); +const plainImage = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jEtQAAAAASUVORK5CYII=", + "base64", +); +const encryptedImage = decryptImageBytes( + plainImage.buffer.slice(plainImage.byteOffset, plainImage.byteOffset + plainImage.byteLength) as ArrayBuffer, +); +const imageReads = new Map(); +let releaseReplacement: (() => void) | undefined; +let replacementArrived: (() => void) | undefined; +const replacementStarted = new Promise((done) => { replacementArrived = done; }); +await build({ + stdin: { + resolveDir: root, + loader: "tsx", + contents: ` +import {createRoot} from 'react-dom/client'; +import {useState} from 'react'; +import {AppRouterContext} from 'next/dist/shared/lib/app-router-context.shared-runtime'; +import {SiteProvider} from '@/components/providers/SiteProvider'; +import {HeroReact} from '@/components/catalog/HeroReact'; +import {SearchMobilePage} from '@/features/catalog/DiscoveryPages'; +const router={back(){window.__navigation='back'},forward(){},refresh(){},push(path){window.__navigation=path},replace(path){window.__navigation=path},prefetch(){}}; +const banners=[{id:1,slug:'first',playName:'第一部',cover:'/static/brand/header.svg',banner:'/static/brand/footer.svg',totalEpisode:8,countCollect:5,isCollect:2},{id:2,slug:'second',playName:'第二部',cover:'/static/brand/header.svg',totalEpisode:12,countCollect:7,isCollect:1},{id:3,slug:'third',playName:'第三部',cover:'/static/brand/header.svg',totalEpisode:2,countCollect:0,isCollect:2}]; +const assetBanners=[{...banners[0],horizontalCover:'/fixture/encrypted-first.png',banner:'/fixture/encrypted-first.png'},{...banners[1],horizontalCover:'/fixture/encrypted-second.png',banner:'/fixture/encrypted-second.png'},{...banners[2],horizontalCover:'/fixture/broken-image.png',banner:'/fixture/encrypted-second.png'}]; +function HeroAssets(){const [items,setItems]=useState(assetBanners);window.__replaceHeroPoster=source=>setItems(previous=>previous.map((item,index)=>index===0?{...item,horizontalCover:source}:item));return ;} +createRoot(document.getElementById('root')).render({location.pathname==='/hero-css/'?:<>}); +`, + }, + bundle: true, + platform: "browser", + format: "iife", + outfile: bundle, + tsconfig: join(root, "tsconfig.json"), + banner: { js: 'var process={env:{NODE_ENV:"development"}};' }, + define: { + "process.env.NODE_ENV": '"development"', + "process.env.__NEXT_ROUTER_BASEPATH": '""', + }, +}); +const server = createServer(async (request, response) => { + try { + const path = new URL(request.url || "/", "http://local.test").pathname; + if (path === heroStyles.href) { + response.setHeader("Content-Type", "text/css;charset=utf-8"); + response.end(heroStyles.css); + return; + } + if (path === "/") { + response.setHeader("Content-Type", "text/html"); + response.end( + '
', + ); + return; + } + if (path === "/hero-css/") { + response.setHeader("Content-Type", "text/html;charset=utf-8"); + response.end(`
`); + return; + } + if (path.startsWith("/fixture/")) { + imageReads.set(path, (imageReads.get(path) || 0) + 1); + response.setHeader("Cache-Control", "no-store"); + if (path === "/fixture/broken-image.png") { + response.writeHead(404).end(); + return; + } + if (path === "/fixture/replacement.png") { + await new Promise((done) => { releaseReplacement = done; replacementArrived?.(); }); + } + response.setHeader("Content-Type", "application/octet-stream"); + response.end(encryptedImage); + return; + } + if (path.startsWith("/search/")) { + response.setHeader("Content-Type", "text/html"); + response.end( + "隔离搜索结果
", + ); + return; + } + if (path === "/catalog.js") { + response.setHeader("Content-Type", "application/javascript"); + response.end(await readFile(bundle)); + return; + } + if (path.startsWith("/static/")) { + const file = resolve(root, "public", "." + path); + if (!file.startsWith(join(root, "public") + "/")) { + response.writeHead(403).end(); + return; + } + response.setHeader( + "Content-Type", + extname(file) === ".svg" ? "image/svg+xml" : extname(file) === ".css" ? "text/css" : "application/octet-stream", + ); + response.end(await readFile(file)); + return; + } + response.writeHead(404).end(); + } catch { + response.writeHead(404).end(); + } +}); +await new Promise((resolveListen) => + server.listen(0, "127.0.0.1", resolveListen), +); +const address = server.address(); +assert.ok(address && typeof address !== "string"); +const origin = `http://127.0.0.1:${address.port}`; +const configuredChrome = process.env.CHROME_PATH, + macChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +let browser: Awaited> | undefined; +try { + browser = await chromium.launch({ + headless: true, + ...(configuredChrome || existsSync(macChrome) + ? { executablePath: configuredChrome || macChrome } + : {}), + }); + const context = await browser.newContext(), + page = await context.newPage(), + errors: string[] = [], + requests: { + path: string; + body: Record; + headers: Record; + }[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await context.addInitScript({ + content: ` + window.__copies=[];window.__events=[];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(['历史词'])); + `, + }); + let suggestionMode: "normal" | "fail" = "normal"; + await page.route("**/api/**", async (route) => { + const request = route.request(), + url = new URL(request.url()), + body = (request.postDataJSON() as Record) || {}; + requests.push({ path: url.pathname, body, headers: request.headers() }); + if (url.pathname === "/api/auth/guest") { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + await route.fulfill({ + json: { + code: 200, + data: { token: "mock-guest", userId: 12345, type: 0 }, + }, + }); + return; + } + if (url.pathname === "/api/web/search/suggest") { + if (suggestionMode === "fail") { + await route.abort("failed"); + return; + } + const keyword = url.searchParams.get("keyword") || ""; + if (keyword === "旧") + await new Promise((resolveDelay) => setTimeout(resolveDelay, 200)); + await route.fulfill({ + json: { + code: 200, + keyword, + feature: { + title: keyword + "作品", + cover: "/static/brand/header.svg", + url: "/drama/suggest/", + meta: "全 8 集", + }, + items: [ + { + title: keyword, + query: keyword, + kind: "搜索", + url: "/search/" + encodeURIComponent(keyword) + "/", + }, + ], + }, + }); + return; + } + errors.push("Unexpected API: " + url.pathname); + await route.fulfill({ status: 500, json: { code: 500, data: null } }); + }); + await page.goto(origin); + await page + .waitForSelector(".hk-home-hero__share", { timeout: 10000 }) + .catch((error) => { + throw new Error( + `React mount failed: ${errors.join("; ")}; ${String(error)}`, + ); + }); + await page.hover(".hk-home-hero"); + // The account system was removed: the hero has no collection toggle or account entry. + assert.equal(await page.locator(".hk-home-hero__collect").count(), 0); + assert.equal(await page.locator("[data-login-open], [data-register-open]").count(), 0); + await page.locator(".hk-home-hero__share").first().click(); + assert.deepEqual( + await page.evaluate( + () => (window as unknown as { __copies: string[] }).__copies, + ), + [origin + "/drama/first/"], + ); + await page.locator(".hk-home-hero__thumb").nth(1).click(); + assert.equal(await page.locator("[data-hero-current]").textContent(), "02"); + assert.equal( + await page.locator(".hk-home-hero__slide.hk-is-active .hk-home-hero__title").textContent(), + "第二部", + ); + assert.equal( + await page.locator(".hk-mobile-search__chip").first().textContent(), + "历史词", + ); + await page.getByRole("button", { name: "清除搜索历史", exact: true }).click(); + assert.equal( + await page.locator(".hk-mobile-search__history").isVisible(), + false, + ); + const input = page.locator(".hk-mobile-search__input"); + await input.fill("都市"); + await page.waitForSelector(".hk-mobile-search__suggestion-feature"); + assert.equal( + await page.locator(".hk-mobile-search__feature-title").textContent(), + "都市作品", + ); + await input.fill("旧"); + await page.waitForTimeout(140); + await input.fill("新"); + await page.waitForFunction( + () => + document.querySelector(".hk-mobile-search__feature-title")?.textContent === + "新作品", + ); + await page.waitForTimeout(220); + assert.equal( + await page.locator(".hk-mobile-search__feature-title").textContent(), + "新作品", + ); + suggestionMode = "fail"; + await input.fill("无网"); + await page.waitForSelector(".hk-mobile-search__suggestion"); + assert.match( + (await page.locator(".hk-mobile-search__suggestion").getAttribute("href")) || + "", + /\/search\/%E6%97%A0%E7%BD%91\//, + ); + await input.fill("a/b"); + const navigation = page.waitForURL(origin + "/search/a%2Fb/"); + await input.press("Enter"); + await navigation; + assert.equal(new URL(page.url()).pathname, "/search/a%2Fb/"); + assert.equal(await page.locator("[data-search-results]").count(), 1); + assert.equal(await page.locator("body").getAttribute("class"), "hk-search-page"); + assert.ok( + await page.evaluate(() => + JSON.parse(localStorage.getItem("hgdj_search_history") || "[]").includes( + "a/b", + ), + ), + ); + // The interaction fixture intentionally has no global CSS. Verify the actual + // desktop poster opacity contract separately so a loaded-but-transparent image fails. + const heroPage = await context.newPage(); + await heroPage.setViewportSize({ width: 1440, height: 1000 }); + heroPage.on("pageerror", (error) => errors.push(error.message)); + const activePoster = ".hk-home-hero__slide.hk-is-active .hk-home-hero__poster"; + const waitForPoster = async () => { + await heroPage.waitForFunction(() => { + const image = document.querySelector(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster"); + return !!image && image.complete && image.naturalWidth > 0 && + image.getAttribute("data-hero-loaded") === "1" && + Number(getComputedStyle(image).opacity) > 0.99; + }); + return heroPage.locator(activePoster).evaluate((image) => ({ + src: image.getAttribute("src"), + naturalWidth: (image as HTMLImageElement).naturalWidth, + opacity: Number(getComputedStyle(image).opacity), + })); + }; + await heroPage.goto(origin + "/hero-css/"); + await heroPage.hover(".hk-home-hero"); + const firstPoster = await waitForPoster(); + assert.match(firstPoster.src || "", /^blob:/); + assert.equal(firstPoster.naturalWidth, 1, "Encrypted PNG is really decoded by the browser"); + await heroPage.locator(".hk-home-hero__thumb").nth(1).click(); + const secondPoster = await waitForPoster(); + assert.match(secondPoster.src || "", /^blob:/); + assert.notEqual(secondPoster.src, firstPoster.src); + const firstReads = imageReads.get("/fixture/encrypted-first.png"); + const secondReads = imageReads.get("/fixture/encrypted-second.png"); + for (const index of [0, 1, 0]) { + await heroPage.locator(".hk-home-hero__thumb").nth(index).click(); + const revisited = await waitForPoster(); + assert.equal(revisited.src, index === 0 ? firstPoster.src : secondPoster.src); + } + assert.equal(imageReads.get("/fixture/encrypted-first.png"), firstReads, "Revisiting a poster reuses its decoded cache"); + assert.equal(imageReads.get("/fixture/encrypted-second.png"), secondReads); + await heroPage.locator(".hk-home-hero__thumb").nth(2).click(); + const fallback = await waitForPoster(); + assert.equal(fallback.src, IMAGE_PLACEHOLDER, "Failed poster shows the real shared brand placeholder"); + assert.equal(fallback.naturalWidth, 480); + await heroPage.waitForFunction(() => document.querySelector(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster")?.getAttribute("data-decrypt-failed") === "1"); + + await heroPage.locator(".hk-home-hero__thumb").first().click(); + await waitForPoster(); + await heroPage.evaluate((source) => { + (window as unknown as { __replaceHeroPoster(source: string): void }).__replaceHeroPoster(source); + }, "/fixture/replacement.png"); + await replacementStarted; + await heroPage.waitForFunction(() => { + const image = document.querySelector(".hk-home-hero__slide.hk-is-active .hk-home-hero__poster"); + return image?.getAttribute("src")?.includes("/static/brand/placeholder.svg") && + image.getAttribute("data-hero-loaded") !== "1" && + Number(getComputedStyle(image).opacity) < 0.01; + }); + assert.equal(await heroPage.locator(".hk-home-hero__slide.hk-is-active .hk-home-hero__title").textContent(), "第一部", "The same item receives a new image source"); + releaseReplacement?.(); + const replacement = await waitForPoster(); + assert.match(replacement.src || "", /^blob:/); + assert.notEqual(replacement.src, firstPoster.src, "A new source cannot keep the previous poster's readiness/blob"); + await heroPage.reload(); + await heroPage.hover(".hk-home-hero"); + const refreshed = await waitForPoster(); + assert.match(refreshed.src || "", /^blob:/); + assert.equal(await heroPage.locator("[data-hero-current]").textContent(), "01"); + assert((imageReads.get("/fixture/encrypted-first.png") || 0) > (firstReads || 0), "A refreshed document loads and reveals its image again"); + await heroPage.close(); + assert.deepEqual(errors, []); + assert.deepEqual( + requests.filter((request) => request.path.startsWith("/api/playlet/collect")), + [], + "Removed collection API must not be called", + ); + console.log( + "React catalog smoke passed: no collection/account entries, share, carousel, history, suggestions, stale requests, offline fallback, encoded search, encrypted hero CSS visibility, cached revisits, visible brand fallback, source replacement and refresh. All APIs mocked.", + ); + await context.close(); +} finally { + releaseReplacement?.(); + await browser?.close(); + server.closeAllConnections(); + await new Promise((resolveClose) => server.close(() => resolveClose())); + await rm(temporary, { recursive: true, force: true }); +} diff --git a/scripts/compose-placeholder.ts b/scripts/compose-placeholder.ts new file mode 100644 index 0000000..92cf18c --- /dev/null +++ b/scripts/compose-placeholder.ts @@ -0,0 +1,45 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; + +// Preserve the complete Figma SVG export as an image; never redraw or recolor its paths. +const brandFile = new URL('../public/static/brand/footer.svg', import.meta.url); +const outputFile = new URL('../public/static/brand/placeholder.svg', import.meta.url); +const manifestFile = new URL('../public/static/brand/placeholder.manifest.json', import.meta.url); +const brand = await readFile(brandFile); +const sha256 = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex'); +const width = 240; +const logoOpacity = 0.2; +const height = width * 44 / 124; +const x = (480 - width) / 2; +const y = (480 - height) / 2; +const svg = Buffer.from(` +花魁短剧内容占位 + + + +`); +const manifest = Buffer.from(JSON.stringify({ + file: 'public/static/brand/placeholder.svg', + width: 480, height: 480, sha256: sha256(svg), + source: 'Composition using the unmodified Figma brand export and the design panel background token.', + background: { color: '#26272f', source: 'Figma global panel token' }, + composition: { + type: 'svg-container', + embeddedImage: { + file: 'public/static/brand/footer.svg', + source: 'https://www.figma.com/design/1vUq4wf5xOs8ZOlmt26Q3O/?node-id=1-706', + nodeId: '1:706', sha256: sha256(brand), encoding: 'base64', unmodified: true, + x, y: Number(y.toFixed(6)), width, height: Number(height.toFixed(6)), opacity: logoOpacity, + }, + }, +}, null, 2) + '\n'); +if (process.argv.includes('--check')) { + const existing = await Promise.all([readFile(outputFile), readFile(manifestFile)]); + if (!existing[0].equals(svg) || !existing[1].equals(manifest)) { + throw new Error('Brand placeholder is out of date. Run tsx scripts/compose-placeholder.ts.'); + } + console.log('Brand placeholder matches the complete unmodified Figma footer.svg export.'); +} else { + await Promise.all([writeFile(outputFile, svg), writeFile(manifestFile, manifest)]); + console.log('Composed 480×480 brand placeholder from the complete unmodified Figma footer.svg export.'); +} diff --git a/scripts/compose-pwa-icons.ts b/scripts/compose-pwa-icons.ts new file mode 100644 index 0000000..515b9ab --- /dev/null +++ b/scripts/compose-pwa-icons.ts @@ -0,0 +1,30 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import sharp from "sharp"; +import { pwaConfig } from "../src/lib/pwa/config"; +import { brandAssets } from "../src/lib/brand-assets"; + +// Rasterize the existing Figma favicon paths unchanged; maskable icons need a +// separate opaque canvas with the entire mark inside the central safe circle. +const source = await readFile(new URL("../public" + brandAssets.iconSvg, import.meta.url)); +const outputs = [ + { file: pwaConfig.icons.small, size: 192, mark: 192 }, + { file: pwaConfig.icons.large, size: 512, mark: 512 }, + { file: pwaConfig.icons.maskable, size: 512, mark: 288 }, + { file: pwaConfig.icons.apple, size: 180, mark: 144 }, +]; +const directory = new URL("../public/static/pwa/", import.meta.url); +await mkdir(directory, { recursive: true }); +for (const { file, size, mark } of outputs) { + const image = await sharp(source, { density: 384 }).resize(mark, mark).png().toBuffer(); + const bytes = await sharp({ create: { + width: size, height: size, channels: 4, background: pwaConfig.themeColor, + } }).composite([{ input: image, gravity: "centre" }]).png().toBuffer(); + const path = new URL("../public" + file, import.meta.url); + if (process.argv.includes("--check")) { + const existing = await readFile(path); + if (!existing.equals(bytes)) throw new Error(`${file} is stale. Run npm run assets:pwa.`); + } else { + await writeFile(path, bytes); + } +} +console.log("PWA icons match the existing Figma favicon."); diff --git a/scripts/dev-charles.sh b/scripts/dev-charles.sh new file mode 100644 index 0000000..f7df1f2 --- /dev/null +++ b/scripts/dev-charles.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Node 必须在进程启动前收到代理开关和额外 CA,放在 .env.local 中太晚。 +if ! node -e 'process.exit(process.allowedNodeEnvironmentFlags.has("--use-env-proxy") ? 0 : 1)'; then + echo "[charles] 当前 $(node --version) 不支持原生环境代理,请先执行 nvm use 24(需要 Node >= 24.5 或 22.21)。" >&2 + exit 1 +fi + +export NODE_EXTRA_CA_CERTS="${NODE_EXTRA_CA_CERTS:-$HOME/.charles-ca.pem}" +if [[ ! -r "$NODE_EXTRA_CA_CERTS" ]]; then + echo "[charles] 找不到可读的根证书:$NODE_EXTRA_CA_CERTS" >&2 + echo "请在 Charles 的 Help → SSL Proxying → Save Charles Root Certificate 中导出 PEM,或通过 NODE_EXTRA_CA_CERTS 指定路径。" >&2 + exit 1 +fi + +export NODE_USE_ENV_PROXY=1 +export HTTP_PROXY="${CHARLES_PROXY_URL:-http://127.0.0.1:8888}" +export HTTPS_PROXY="$HTTP_PROXY" +export NO_PROXY="localhost,127.0.0.1,::1${NO_PROXY:+,$NO_PROXY}${no_proxy:+,$no_proxy}" +# 统一大小写变量,避免命中其他已导出的代理。 +export http_proxy="$HTTP_PROXY" +export https_proxy="$HTTPS_PROXY" +export no_proxy="$NO_PROXY" + +cd "$project_root" +echo "[charles] 已启用 Node 环境代理,请确认 Charles 已启动并开启目标域名的 SSL Proxying。" +# 复用普通开发入口,保留环境配置、客户端/模板编译和默认端口。 +exec node --import tsx "$project_root/scripts/next-env.ts" development dev "$@" diff --git a/scripts/export-page-routes.ts b/scripts/export-page-routes.ts new file mode 100644 index 0000000..350e1b4 --- /dev/null +++ b/scripts/export-page-routes.ts @@ -0,0 +1,71 @@ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { resolve, dirname } from 'node:path'; +import { pageRoutes } from '../src/server/pages/routes'; +import { ROUTE_MIGRATIONS, toPublicPath } from '../src/lib/routes'; + +const labels: Record = { + Index: '首页', SearchMobile: '移动端搜索', Search: '搜索结果', + About: '关于我们', Help: '帮助中心', Terms: '服务条款', Privacy: '隐私政策', + Compliance2257: '2257 声明', DMCA: '版权声明', DownloadApp: '下载 App', + Nav: '官方导航', HomeRoad: '回家的路', List: '短剧列表', MoreList: '短剧推荐栏目', + MoreSeriesList: '剧集推荐栏目', Series: '剧集列表', Topic: '专题列表', + TopicDetail: '专题详情', Rank: '排行榜', Tags: '标签详情', + RecentlyAdded: '最新更新', PopularRecommend: '热门推荐', Play: '短剧播放', + SeriesPlay: '剧集播放', Feedback: '意见反馈', +}; + +type CatalogRoute = { id: string; label: string; path: string; legacyPaths: string[]; selectable: boolean }; +const routes = new Map(); +for (const [legacy, handler] of pageRoutes) { + if (!labels[handler]) throw new Error('Missing page label: ' + handler); + const path = toPublicPath(legacy); + let entry = routes.get(path); + if (!entry) { + entry = { id: handler + ':' + path, label: labels[handler], path, legacyPaths: [], selectable: !path.includes(':') }; + routes.set(path, entry); + } + if (!entry.legacyPaths.includes(legacy)) entry.legacyPaths.push(legacy); +} +const catalog = JSON.stringify({ version: 1, routes: [...routes.values()] }, null, 2) + '\n'; +const begin = ''; +const end = ''; +const inventory = [ + begin, + '以下清单由 `npm run routes:export` 从实际路由注册生成,包含全部页面、筛选和分页模式。含 `:参数` 的路径需要填写真实值;后台导航下拉只展示可直接访问的固定入口,详情页和外链可手动输入。', + '', + '| 页面 | 原路径 | 当前路径 |', + '| --- | --- | --- |', + ...[...routes.values()].map(item => `| ${item.label} | ${item.legacyPaths.map(path => '`' + path + '`').join('、')} | \`${item.path}\` |`), + '', + end, +].join('\n'); +const root = resolve(import.meta.dirname, '..'); +const args = process.argv.slice(2); +const check = args.includes('--check'); +const adminIndex = args.indexOf('--admin'); +for (let i = 0; i < args.length; i++) { + if (args[i] === '--admin') { i++; continue; } + if (args[i] !== '--check') throw new Error('Usage: routes:export [--check] [--admin ]'); +} +if (adminIndex >= 0 && (!args[adminIndex + 1] || args[adminIndex + 1].startsWith('--'))) throw new Error('--admin requires the admin repository path'); +const readmePath = resolve(root, 'README.md'); +const readme = await readFile(readmePath, 'utf8'); +const start = readme.indexOf(begin), finish = readme.indexOf(end); +if (start < 0 || finish < start) throw new Error('README route inventory markers are missing'); +const updatedReadme = readme.slice(0, start) + inventory + readme.slice(finish + end.length); +const outputs: [string, string][] = [[resolve(root, 'src/lib/page-route-catalog.json'), catalog], [readmePath, updatedReadme]]; +if (adminIndex >= 0) { + const adminRoot = resolve(args[adminIndex + 1]!); + const pkg = JSON.parse(await readFile(resolve(adminRoot, 'package.json'), 'utf8')); + if (pkg.name !== 'hkdj-web-admin') throw new Error('--admin must target the Huakui web admin repository'); + outputs.push([resolve(adminRoot, 'src/constants/hkdj-web-routes.json'), catalog]); +} +for (const [path, content] of outputs) { + if (check) { + if (await readFile(path, 'utf8') !== content) throw new Error('Route catalog is stale: ' + path); + } else { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); + } +} +console.log(`${check ? 'Verified' : 'Exported'} ${ROUTE_MIGRATIONS.length} migrations, ${routes.size} page patterns, ${[...routes.values()].filter(item => item.selectable).length} navigation choices.`); diff --git a/scripts/figma-icon-manifest.json b/scripts/figma-icon-manifest.json new file mode 100644 index 0000000..d53d719 --- /dev/null +++ b/scripts/figma-icon-manifest.json @@ -0,0 +1,2485 @@ +{ + "fileKey": "1vUq4wf5xOs8ZOlmt26Q3O", + "source": "https://www.figma.com/design/1vUq4wf5xOs8ZOlmt26Q3O/?node-id=1-45", + "description": "站内 SVG 图标逐项对应 Figma 页面实例或原始组件。保存 Figma REST 返回的完整原始字节,不修改 root 尺寸、viewBox、形状、颜色与图层;清单记录真实导出尺寸,显示尺寸由 CSS 或 HTML 宽高属性决定。未找到准确对应项时保留原文件。", + "assets": [ + { + "file": "public/static/images/18jia.svg", + "nodeId": "1:5149", + "role": "badge", + "nodeType": "FRAME", + "sourcePath": "/.me版/WEB/02 弹窗/01. 年龄确认 · 弹层/年龄确认/badge", + "sourceSize": { + "width": 64, + "height": 64 + }, + "size": { + "width": 64, + "height": 64 + }, + "palette": [ + "#ff0f53" + ], + "usedBy": [], + "previousSha256": "605f9a9bc5152de8e29a248e804bd040f40e119789fce5304533a36871479cf5", + "note": "年龄确认的 64px badge frame,包含真实设计 18+ 字形与圆形边框。", + "sha256": "aa064ad81c04d7b3f1e6824650bd638101254d3ca2a4d7db5677be24e1c16351" + }, + { + "file": "public/static/images/arrow-right-line.svg", + "nodeId": "1:14640", + "role": "icon/arrow-right", + "nodeType": "INSTANCE", + "componentId": "1:87", + "sourcePath": "/.me版/WEB/10 空状态/01. 没有内容 · 我的收藏/04-空态/empty/btn/去首页逛逛/icon/arrow-right", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [], + "previousSha256": "7ea9a62e765195a733018bbab8dad3320f7a87964ef68c277af7aeb4f30bc374", + "sha256": "f3134c3a35a574244ac9ead3502509a9ad517a33937f940a6852aed322889e8e" + }, + { + "file": "public/static/images/auth/award.svg", + "nodeId": "1:7862", + "role": "icon/award", + "nodeType": "INSTANCE", + "componentId": "1:160", + "sourcePath": "/.me版/WEB/06 登录注册/04. 创建账号/登录弹窗/form/input/邀请码(选填)/field/icon/award", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/auth/AuthForm.tsx" + ], + "sha256": "5ac5bd80a6c5232fa1eef4345aae12442e0f1430e6ebc50a3aa2f324dd8a97a4" + }, + { + "file": "public/static/images/bell.svg", + "nodeId": "1:373", + "role": "icon/bell", + "nodeType": "INSTANCE", + "componentId": "1:364", + "sourcePath": "/.me版/00 元件/花魁/公告栏/端=PC/tag/icon/bell", + "sourceSize": { + "width": 14.000000953674316, + "height": 14.000000953674316 + }, + "size": { + "width": 14, + "height": 14 + }, + "palette": [ + "#141414" + ], + "usedBy": [ + "src/features/catalog/HomePage.tsx" + ], + "previousSha256": "237645481a1d0d79cf3acfc92061750f8e340fbe0ff8250c7662e35492b0c63b", + "sha256": "cd9a1be45c62c8c2193b019194d2a42cfa21bfb792ea8b60d6a3e5214272af96" + }, + { + "file": "public/static/images/bookmark.svg", + "nodeId": "1:1241", + "role": "icon/bookmark", + "nodeType": "INSTANCE", + "componentId": "1:125", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/info/actions/btn/收藏/icon/bookmark", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/features/playback/DramaWatchPage.tsx", + "src/features/playback/SeriesWatchPage.tsx" + ], + "previousSha256": "363e79a49d1eff12a76b89bf07304d222b5fc9a5e2ee9fb6550b7455a0e0e600", + "sha256": "428dc2aeeb9284dd998d52daada32953df7aeed2ccc4ae84e82944ff0cd9c1d6" + }, + { + "file": "public/static/images/brand-android.svg", + "nodeId": "1:21153", + "role": "icon/brand-android", + "nodeType": "INSTANCE", + "componentId": "1:277", + "sourcePath": "/.me版/H5/05 播放详情/11. 安装引导 · Android/安装引导 · Android/btn/下载 Android 版/icon/brand-android", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/providers/SiteDialogs.tsx" + ], + "previousSha256": "ea9401c4ac24c5bfdea9784868e343b4f07ea1184a9b6c6937ec1630a40bb965", + "sha256": "13d8bc56ae49a3daba7fe8f764e78904abe5f0d517d7d27bb7ee505775350109" + }, + { + "file": "public/static/images/brand-apple.svg", + "nodeId": "1:20339", + "role": "icon/brand-apple", + "nodeType": "INSTANCE", + "componentId": "1:275", + "sourcePath": "/.me版/H5/05 播放详情/09. 安装引导 · iOS 商店版/安装引导 · iOS 商店版/商店卡/btn/brand-apple/icon/brand-apple", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/providers/SiteDialogs.tsx" + ], + "previousSha256": "0690e132fdade3e8357f6e6b7896899d388bd91be75fa264e52db1a55f305e97", + "sha256": "4d14c561d238dca9f6e8b6725ad193e79bb72a22216ae30aa6d002287268be8f" + }, + { + "file": "public/static/images/brand-instagram.svg", + "nodeId": "1:716", + "role": "icon/brand-instagram", + "nodeType": "INSTANCE", + "componentId": "1:269", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/99-页尾/top/brand/社群/social/Instagram/icon/brand-instagram", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [], + "previousSha256": "c824adf019da0f93ea950a064d7aed4e91ad5df1b0adc671f5484e61d5f71404", + "sha256": "f3ce0fba508e66f0738213be5bc73dbf9328ec9a28a36bc56166ba46cf0dcac1" + }, + { + "file": "public/static/images/brand-telegram.svg", + "nodeId": "1:714", + "role": "icon/brand-telegram", + "nodeType": "INSTANCE", + "componentId": "1:267", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/99-页尾/top/brand/社群/social/Telegram/icon/brand-telegram", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/styles/pages/site-navigation.css", + "src/components/layout/SiteFooter.tsx", + "src/features/content/SiteNavigationPage.tsx" + ], + "previousSha256": "b379bfc56fd25c1766815ac6c985437debf9053f06108ec4f486714e35f1de68", + "sha256": "d444639d39dd798e4531a75b03ae34ac12a9482d3f75dfdb2f0157b5c3b73f6d" + }, + { + "file": "public/static/images/brand-tiktok.svg", + "nodeId": "1:718", + "role": "icon/brand-tiktok", + "nodeType": "INSTANCE", + "componentId": "1:271", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/99-页尾/top/brand/社群/social/TikTok/icon/brand-tiktok", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [], + "previousSha256": "0bc72dda5f1f3fb9e0f1ea3b51a4d052a58f4071d5ec62ce05a1ab3bfa83a4b9", + "sha256": "eb9168914f44d149c5ce04c8f771c7c72b583d5b0d3c81f2c5a8742123f78d69" + }, + { + "file": "public/static/images/brand-x.svg", + "nodeId": "1:712", + "role": "icon/brand-x", + "nodeType": "INSTANCE", + "componentId": "1:265", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/99-页尾/top/brand/社群/social/X/icon/brand-x", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/layout/SiteFooter.tsx", + "src/features/content/SiteNavigationPage.tsx" + ], + "previousSha256": "fb75b543aa87056ae53e10b563fd63a5f6d0d19508eacee6dbca9468ae937a0f", + "sha256": "7f19e0069b421ecf521d07b941487fdb8aff89318c0edef879b8a37d47cb48b6" + }, + { + "file": "public/static/images/brand-youtube.svg", + "nodeId": "1:720", + "role": "icon/brand-youtube", + "nodeType": "INSTANCE", + "componentId": "1:273", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/99-页尾/top/brand/社群/social/YouTube/icon/brand-youtube", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [], + "previousSha256": "9795b110beace2b8a0b79d0d5dd57a576a5330192b129eb9c8ff082756120d4c", + "sha256": "50ecf21fb5cc45f6a11520c43f93558daa55fb406b4037410d115f9055389dda" + }, + { + "file": "public/static/images/check-circle.svg", + "nodeId": "1:4307", + "role": "icon/check-circle", + "nodeType": "INSTANCE", + "componentId": "1:78", + "sourcePath": "/.me版/WEB/05 播放详情/07. VIP 专享 · 弹窗/VIP 专享弹窗/perks/perk/icon/check-circle", + "sourceSize": { + "width": 14.000000953674316, + "height": 14.000000953674316 + }, + "size": { + "width": 14, + "height": 14 + }, + "palette": [ + "#ffc000" + ], + "usedBy": [], + "previousSha256": "68beba957d6a818d9a66fb68ceb549d8b46f6cf139099bc14d6ea13876b07c3e", + "sha256": "141816ed6897179059b725a74d3dc07d7a162c72affb3c1d887b42a5ac0781bc" + }, + { + "file": "public/static/images/check.svg", + "nodeId": "1:12103", + "role": "check", + "nodeType": "FRAME", + "sourcePath": "/.me版/WEB/07 会员与金币/01. 金币充值/05-支付/支付方式/pm/微信支付/check", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [], + "previousSha256": "9c70b175d51f65161f659ba72800c693c240fd9cd82dc23fda8cb6a45a54d143", + "note": "支付方式未选中状态,导出完整 18px check frame,保留 white 24% 描边。", + "sha256": "9c70b175d51f65161f659ba72800c693c240fd9cd82dc23fda8cb6a45a54d143" + }, + { + "file": "public/static/images/checked.svg", + "nodeId": "1:12097", + "role": "check", + "nodeType": "FRAME", + "sourcePath": "/.me版/WEB/07 会员与金币/01. 金币充值/05-支付/支付方式/pm/支付宝/check", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#ff0f53", + "#ffffff" + ], + "usedBy": [], + "previousSha256": "e01413edb7ef57e4e8cc5c93e4f53bb74273b844bdf025ea86da7870506d6715", + "note": "支付方式选中状态,导出完整 18px check frame,包含 accent 圆形和 white 勾。", + "sha256": "9a03bd676df6326fcc3fb7184d16629acba997c6c34fee27cc893d40ff8a83e5" + }, + { + "file": "public/static/images/chevron-left.svg", + "nodeId": "1:16063", + "role": "icon/chevron-left", + "nodeType": "INSTANCE", + "componentId": "1:210", + "sourcePath": "/.me版/H5/03 分类与二级页/03. 专题 · 全部/01-Nav/btn/chevron-left/icon/chevron-left", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/catalog/Interactions.tsx", + "src/features/account/AuthPage.tsx", + "src/features/catalog/DiscoveryPages.tsx", + "src/features/content/AboutPage.tsx", + "src/features/content/CompliancePage.tsx", + "src/features/content/DMCAPage.tsx", + "src/features/content/FeedbackPage.tsx", + "src/features/content/HelpPage.tsx", + "src/features/content/PrivacyPage.tsx", + "src/features/content/TermsPage.tsx" + ], + "previousSha256": "d430d9a9d4ba824f6e5a1d2e31d42b75e1013c134f324890c16dae304615276d", + "sha256": "b3ef5542c264473e22252ba4065a41b5f24866a07ff3a1b143dcf23e798df99b" + }, + { + "file": "public/static/images/chevron-right.svg", + "nodeId": "1:522", + "role": "icon/chevron-right", + "nodeType": "INSTANCE", + "componentId": "1:81", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/06-精选推荐/rowHead/link/icon/chevron-right", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/styles/pages/help.css", + "src/components/catalog/Interactions.tsx", + "src/components/catalog/Pagination.tsx", + "src/components/layout/SiteHeader.tsx", + "src/components/payment/TransactionHistory.tsx", + "src/features/account/FavoritesPage.tsx", + "src/features/account/ProfilePage.tsx", + "src/features/catalog/HomePage.tsx", + "src/features/catalog/BrowsePages.tsx", + "src/features/catalog/DiscoveryPages.tsx", + "src/features/playback/Comments.tsx", + "src/features/playback/DramaWatchPage.tsx", + "src/features/playback/SeriesWatchPage.tsx", + "src/features/playback/Shared.tsx" + ], + "previousSha256": "9f749e18f32f4e9528d5b6cbc22f2a2a22def25de76b09e91ab3ac94ae587a50", + "sha256": "6e34475807c933e505a1f0f7a0437e7a1cdf8d74e38c3be0317e5d7c4c0fb4f9" + }, + { + "file": "public/static/images/clock-dim.svg", + "nodeId": "1:14238", + "role": "icon/clock", + "nodeType": "INSTANCE", + "componentId": "1:67", + "sourcePath": "/.me版/WEB/09 规则/05. 意见反馈/03-正文/cols/right/联系/r/icon/clock", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/content/FeedbackPage.tsx" + ], + "previousSha256": "884a1d7f45ba55c102ce8c0961062ff1e4d406f38235c2705bc163d16f2787f2", + "sha256": "d97d08c41840831e609ce03b789c1da2e480274edd7b93a6168b6fcb48aab3df" + }, + { + "file": "public/static/images/close.svg", + "nodeId": "1:3850", + "role": "icon/x", + "nodeType": "INSTANCE", + "componentId": "1:97", + "sourcePath": "/.me版/WEB/05 播放详情/06. 金币解锁 · 弹窗/金币解锁弹窗/close/icon/x", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/account/PasswordDialog.tsx", + "src/components/auth/AuthDialog.tsx", + "src/components/layout/SiteHeader.tsx", + "src/components/providers/SiteDialogs.tsx", + "src/features/account/HistoryPage.tsx", + "src/features/catalog/DiscoveryPages.tsx", + "src/features/content/FeedbackPage.tsx", + "src/features/playback/CoinDialog.tsx" + ], + "previousSha256": "c4748b8e3520c4dac0cc4f07d9648dee63e1587478a38e9992d9c9745f6ad810", + "sha256": "f4196d3bc14dcb8ad18144f269dc53798b09bebd8278d73ae564f41219abb4c6" + }, + { + "file": "public/static/images/copy.svg", + "nodeId": "1:236", + "role": "icon/copy", + "nodeType": "COMPONENT", + "sourcePath": "/.me版/00 元件/花魁/icon/copy", + "sourceSize": { + "width": 24, + "height": 24 + }, + "size": { + "width": 24, + "height": 24 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/catalog/Interactions.tsx", + "src/features/playback/Shared.tsx" + ], + "previousSha256": "ebee129b89366497285ebdf692fee5da487f243b1a45c90944221b6a7cea52b5", + "note": "设计中只有 white copy 主组件,保留原始 24px 导出尺寸;页面显示尺寸由 CSS 或 HTML 宽高属性决定。", + "sha256": "f38507d7813ecbb8a4c06b90a49df429ba553311afdd8330d80c60a1892524cc" + }, + { + "file": "public/static/images/download.svg", + "nodeId": "1:438", + "role": "icon/download", + "nodeType": "INSTANCE", + "componentId": "1:51", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/02-Hero/01-Nav/right/btn/下载 App/icon/download", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/layout/SiteFooter.tsx", + "src/components/layout/SiteHeader.tsx", + "src/components/providers/SiteDialogs.tsx", + "src/components/providers/SiteProvider.tsx", + "src/features/catalog/HomePage.tsx", + "src/features/content/AboutPage.tsx", + "src/features/content/HelpPage.tsx", + "src/features/content/SiteNavigationPage.tsx", + "src/features/playback/CoinDialog.tsx", + "src/features/playback/PlaybackStage.tsx" + ], + "previousSha256": "dfe69684c50fc46f705bafae2fe5958f21120291b19f0307d1ab8a35b36e38d8", + "sha256": "89b385ece8308ca74520c4a38455be7e84ed23168df81553389bca8f6bdb2b5c" + }, + { + "file": "public/static/images/error-tip.svg", + "nodeId": "1:7471", + "role": "icon/alert-circle", + "nodeType": "INSTANCE", + "componentId": "1:239", + "sourcePath": "/.me版/WEB/06 登录注册/03. 登录 · 输入报错/登录弹窗/form/input/密码/error/icon/alert-circle", + "sourceSize": { + "width": 14.000000953674316, + "height": 14.000000953674316 + }, + "size": { + "width": 14, + "height": 14 + }, + "palette": [ + "#ff4d4f" + ], + "usedBy": [ + "src/components/auth/AuthForm.tsx" + ], + "previousSha256": "2b6a6e0a2aa7cea20b49c19f48608c12778730740147fbb939249111d43431a1", + "sha256": "fe986e43ef90a0bf0e8308cebb82bc3e177dbc585e981c0705176f9742713146" + }, + { + "file": "public/static/images/eye.svg", + "nodeId": "1:6708", + "role": "icon/eye", + "nodeType": "INSTANCE", + "componentId": "1:136", + "sourcePath": "/.me版/WEB/06 登录注册/01. 登录 · 密码登录/登录弹窗/form/input/密码/field/icon/eye", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/auth/AuthForm.tsx", + "src/features/account/SettingsPage.tsx" + ], + "previousSha256": "818638651a9bafc45f25731f51c2c82f647814c9c6aec23a8991d8e731b355fc", + "sha256": "fd16803f7596e6a3894c2083ca1918939dbe5fc0b975ab1c175706120f7311d2" + }, + { + "file": "public/static/images/favorites-empty/arrow-right.svg", + "nodeId": "1:14640", + "role": "icon/arrow-right", + "nodeType": "INSTANCE", + "componentId": "1:87", + "sourcePath": "/.me版/WEB/10 空状态/01. 没有内容 · 我的收藏/04-空态/empty/btn/去首页逛逛/icon/arrow-right", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/account/AccountEmpty.tsx", + "src/features/account/FavoritesPage.tsx" + ], + "previousSha256": "57ba15ca3733937923dad690d8b104f581df7370e7df92a9245dc6cb8f390580", + "sha256": "f3134c3a35a574244ac9ead3502509a9ad517a33937f940a6852aed322889e8e" + }, + { + "file": "public/static/images/favorites-empty/bookmark.svg", + "nodeId": "1:14636", + "role": "icon/bookmark", + "nodeType": "INSTANCE", + "componentId": "1:125", + "sourcePath": "/.me版/WEB/10 空状态/01. 没有内容 · 我的收藏/04-空态/empty/icon/icon/bookmark", + "sourceSize": { + "width": 40, + "height": 40 + }, + "size": { + "width": 40, + "height": 40 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/account/FavoritesPage.tsx" + ], + "previousSha256": "89948524e464d8bce84d0da3441961cddc7b2b6dcb611bf2b4c201817882a788", + "sha256": "8ed980b7baeb80b5a739c4b8e78aa446285135c72cc3fe30c62b7c4dccd95087" + }, + { + "file": "public/static/images/favorites/sliders.svg", + "nodeId": "1:11442", + "role": "icon/sliders", + "nodeType": "INSTANCE", + "componentId": "1:255", + "sourcePath": "/.me版/WEB/08 我的/04. 我的收藏/04-我的收藏/hr/btn/管理/icon/sliders", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/features/account/FavoritesPage.tsx" + ], + "previousSha256": "7298d8f08eb53febd4dedf335027a449bf1cfddf12b795c2ea5fbf5367802da7", + "sha256": "b74bc9bfb1c08f992dcb881e5f477ba175458b3de8d2c8d150f006ac6a43d954" + }, + { + "file": "public/static/images/feedback/help-circle.svg", + "nodeId": "1:14218", + "role": "icon/help-circle", + "nodeType": "INSTANCE", + "componentId": "1:185", + "sourcePath": "/.me版/WEB/09 规则/05. 意见反馈/03-正文/cols/right/快捷/q/icon/help-circle", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/content/FeedbackPage.tsx" + ], + "previousSha256": "976851c86a63805e98181a707e0359d4311db39c4bccec9a524b82860b0c7195", + "sha256": "1f9fcb4b4c7e57ac7184ee8ed4eaa6603fcc3b5d9208f2e0b68ad25cae17238f" + }, + { + "file": "public/static/images/film.svg", + "nodeId": "1:459", + "role": "icon/film", + "nodeType": "INSTANCE", + "componentId": "1:58", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/02-Hero/hero/内容/main/left/cta/btn/预告片/icon/film", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/catalog/HeroReact.tsx", + "src/components/layout/MobileTabbar.tsx", + "src/features/playback/PlaybackStage.tsx", + "src/lib/view-helpers.ts", + "src/server/pages/catalog.ts", + "src/server/seo/policy.ts" + ], + "previousSha256": "bd055b0ab56a1076f77ec5376189f2ff54852e12d5816f4894c185271883b2e2", + "sha256": "b7b65e68344e92bc26ad016897526d31f7ee3b9beccd79fe0837fb1edcb904f1" + }, + { + "file": "public/static/images/film2.svg", + "nodeId": "1:15516", + "role": "icon/film", + "nodeType": "INSTANCE", + "componentId": "1:58", + "sourcePath": "/.me版/H5/01 首页/01. 首页 · 未登录/99-TabBar/tab/短剧/icon/film", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "2ea250bebf6c8f7fed349832655e871b5c94991cb6759805300c492ba670452f", + "sha256": "c65620bbf92cfd7fbbfe485b9e3334c9076706c64b9d74b76c11ebfa5862e6b6" + }, + { + "file": "public/static/images/heart.svg", + "nodeId": "1:1238", + "role": "icon/heart", + "nodeType": "INSTANCE", + "componentId": "1:123", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/info/actions/btn/点赞/icon/heart", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/features/playback/Comments.tsx", + "src/features/playback/DramaWatchPage.tsx", + "src/features/playback/SeriesWatchPage.tsx" + ], + "previousSha256": "b1218c51ecf631c1fc16b7a9ed2dbe004286a6e80c5c3a201fb30bfedcf9c273", + "sha256": "fa19153efaaba1f8c8aab5d1ea885c4a7d02ead8d0f9e63f32ccfb3e834ff482" + }, + { + "file": "public/static/images/help-circle.svg", + "nodeId": "1:14218", + "role": "icon/help-circle", + "nodeType": "INSTANCE", + "componentId": "1:185", + "sourcePath": "/.me版/WEB/09 规则/05. 意见反馈/03-正文/cols/right/快捷/q/icon/help-circle", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [], + "previousSha256": "dc5d1b9dcf34d7208e1e9c61586a4f6c9f416c6eed715661a761afb07fb53857", + "sha256": "1f9fcb4b4c7e57ac7184ee8ed4eaa6603fcc3b5d9208f2e0b68ad25cae17238f" + }, + { + "file": "public/static/images/home.svg", + "nodeId": "1:14744", + "role": "icon/home", + "nodeType": "INSTANCE", + "componentId": "1:282", + "sourcePath": "/.me版/WEB/10 空状态/02. 406/02-404/btns/btn/返回首页/icon/home", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/analytics/tracker.ts", + "src/components/layout/MobileTabbar.tsx", + "src/components/layout/SiteHeader.tsx", + "src/features/content/SiteNavigationPage.tsx", + "src/features/content/NotFoundPage.tsx", + "src/server/pages/catalog.ts", + "src/server/seo/tdk.ts", + "src/server/services/shared.ts" + ], + "previousSha256": "58472559aa70597426bcc30a61e492611581234946208b5760299de7097ad382", + "sha256": "82efb5bfa45a2d47f788f59aa6cf749ba200512bd09e0451c85a4d433703a09f" + }, + { + "file": "public/static/images/image-dim.svg", + "nodeId": "1:14199", + "role": "icon/image", + "nodeType": "INSTANCE", + "componentId": "1:200", + "sourcePath": "/.me版/WEB/09 规则/05. 意见反馈/03-正文/cols/form/截图/slots/slot/icon/image", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/content/FeedbackPage.tsx" + ], + "previousSha256": "aef50e2a07bc112c73a45a440a86a1513ce6f91098acfb8728af7dd1c19f7c3d", + "sha256": "dd8a6fa843ae71fb9c3c07542e0a5ae034a2fe45edb6d5d75f350451666ee234" + }, + { + "file": "public/static/images/link_award.svg", + "nodeId": "1:1350", + "role": "icon/award", + "nodeType": "INSTANCE", + "componentId": "1:160", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/官方入口/row/it/icon/award", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/catalog/PlayLinks.tsx" + ], + "previousSha256": "a62fd41290add894ffe374f337157018f5f62ff7303d888af86c352c64587194", + "sha256": "507a7be9ac8ee493ba8f6fe553f226c53898b923115aebd6087f1e453b147cc1" + }, + { + "file": "public/static/images/link_globe.svg", + "nodeId": "1:1337", + "role": "icon/globe", + "nodeType": "INSTANCE", + "componentId": "1:100", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/官方入口/row/it/icon/globe", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/catalog/PlayLinks.tsx" + ], + "previousSha256": "ed605ec6d0e96c56ea4da5399b77adb621eea15c698e1a667aa3984a468008f1", + "sha256": "12867df8705b75f60dd5d1786e5dfb94781c6ccbcb197a7f36e627f0bfbf2956" + }, + { + "file": "public/static/images/link_refresh-cw.svg", + "nodeId": "1:1341", + "role": "icon/refresh-cw", + "nodeType": "INSTANCE", + "componentId": "1:127", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/官方入口/row/it/icon/refresh-cw", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/catalog/PlayLinks.tsx" + ], + "previousSha256": "cb93b78c6e56e7272a5ae12ecbbdc02cb886bff4b6c59a0de7a1a9714207905f", + "sha256": "f2ecf1d1fa1d490c2b94dec320500127ce8502da088e9dd5561666eb2a29d3dc" + }, + { + "file": "public/static/images/link_send.svg", + "nodeId": "1:1358", + "role": "icon/send", + "nodeType": "INSTANCE", + "componentId": "1:146", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/官方入口/row/it/icon/send", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/catalog/PlayLinks.tsx" + ], + "previousSha256": "832fe88b2f489b0f74a2d7e1bc467effe57ca65bd1cf4b50b5f3ae8e0b7c9211", + "sha256": "4fd918f420dd157577d83ae7886a8cf6629df6be76d9b68305e81b58dbd8d10a" + }, + { + "file": "public/static/images/link_shield.svg", + "nodeId": "1:1345", + "role": "icon/shield", + "nodeType": "INSTANCE", + "componentId": "1:74", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/官方入口/row/it/icon/shield", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/catalog/PlayLinks.tsx" + ], + "previousSha256": "e12e7ac5c2cdb9f1e0595b2f315da40786548ec6cb9ede18f1c538449ac85c05", + "sha256": "bed770c5b290ac7568f0270744323a52181472196de032138efb87a73e3bed60" + }, + { + "file": "public/static/images/link_smartphone.svg", + "nodeId": "1:1354", + "role": "icon/smartphone", + "nodeType": "INSTANCE", + "componentId": "1:55", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/官方入口/row/it/icon/smartphone", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/catalog/PlayLinks.tsx", + "src/features/content/SiteNavigationPage.tsx" + ], + "previousSha256": "4fc84c8d03bd0695fac3056d70d60f5901382db8f268198c16af14d212a226cb", + "sha256": "00efa2f884bb6a02cee1aa7e231e03dc419369957372159bde07f6aa5eec87d9" + }, + { + "file": "public/static/images/lock-dim.svg", + "nodeId": "1:6706", + "role": "icon/lock", + "nodeType": "INSTANCE", + "componentId": "1:157", + "sourcePath": "/.me版/WEB/06 登录注册/01. 登录 · 密码登录/登录弹窗/form/input/密码/field/icon/lock", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/auth/AuthForm.tsx" + ], + "previousSha256": "eabed912f39add207cd6f8290027bf26a9c65dfc464d17d2b87e5ef21dfd7512", + "sha256": "163265abbfa4c31f4e785534022049b145c849aadca643549908af9c77d6b624" + }, + { + "file": "public/static/images/lock.svg", + "nodeId": "1:2095", + "role": "icon/lock", + "nodeType": "INSTANCE", + "componentId": "1:157", + "sourcePath": "/.me版/WEB/05 播放详情/03. 播放详情 · 金币解锁/03-播放器/player/lockHint/btns/btn/金币解锁本集 38 金币/icon/lock", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/features/account/RechargePage.tsx", + "src/features/playback/PlaybackStage.tsx" + ], + "previousSha256": "ef4f9760bb8658f7afb4164b754316ba5db1a02678fc1f17bda2146f090818a6", + "sha256": "47c36e1b3e5a5d9d56de11b90baaceb579aebdbdf559250175337e19a0558908" + }, + { + "file": "public/static/images/logout.svg", + "nodeId": "1:5940", + "role": "icon/log-out", + "nodeType": "INSTANCE", + "componentId": "1:285", + "sourcePath": "/.me版/WEB/02 弹窗/03. 导航 · 用户菜单/用户菜单/item/退出登录/icon/log-out", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "b1bb9482abeb2f719e1a84ed2503f77ed2f48f057709d136c73eb5033a617a2f", + "sha256": "71bc95afcdc62f673ff31101f67d09620939f68e6d40451a673264cc22d22c59" + }, + { + "file": "public/static/images/mail-dim.svg", + "nodeId": "1:13764", + "role": "icon/mail", + "nodeType": "INSTANCE", + "componentId": "1:189", + "sourcePath": "/.me版/WEB/09 规则/02. 常见问题/03-正文/cols/right/联系/em/icon/mail", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/content/FeedbackPage.tsx", + "src/features/content/HelpPage.tsx" + ], + "previousSha256": "6a89da0ebf7b5926d5d92a6198a94b2f62415eb7dcc57bb9645863b42f599542", + "sha256": "a089c3103aca300a0bb94c95acbde33dfba5605b54cec69079995d1a750189c3" + }, + { + "file": "public/static/images/mail.svg", + "nodeId": "1:14747", + "role": "icon/mail", + "nodeType": "INSTANCE", + "componentId": "1:189", + "sourcePath": "/.me版/WEB/10 空状态/02. 406/02-404/btns/btn/联系我们/icon/mail", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/features/content/NotFoundPage.tsx" + ], + "previousSha256": "bd8736f2d7b0e85a34bb1fdf66dcb0f152f054faa9a2f469ae901ac3858cd55e", + "sha256": "5d6d32fd6cb5c1facbdad6321689a94e808fc7904f4b3b5a666fb65d075e59d6" + }, + { + "file": "public/static/images/menu_dj.svg", + "nodeId": "1:22007", + "role": "icon/film", + "nodeType": "INSTANCE", + "componentId": "1:58", + "sourcePath": "/.me版/H5/02 弹窗/03. 菜单抽屉 · 未登录/菜单抽屉/nav/item/短剧/icon/film", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/lib/view-helpers.ts" + ], + "previousSha256": "66bb4984e1101757e3e94de8862690c65a33695dbb9368651f94e19aa586131f", + "sha256": "ac657405e63cd438ce3f33c2fb8d8f1e7bdbf49ee5c8fe76e9d754041fbd1df3" + }, + { + "file": "public/static/images/menu_gkls.svg", + "nodeId": "1:5926", + "role": "icon/clock", + "nodeType": "INSTANCE", + "componentId": "1:67", + "sourcePath": "/.me版/WEB/02 弹窗/03. 导航 · 用户菜单/用户菜单/list/item/观看历史/icon/clock", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "f55acde03b98664f36d09c4b61edf4e978787ef6532d06865c7d106bc377aed5", + "sha256": "adbd1d4a03d45e33519ff8dd3c00d7648cd90c58e88917bc9938aa645e370d23" + }, + { + "file": "public/static/images/menu_grzx.svg", + "nodeId": "1:5917", + "role": "icon/user", + "nodeType": "INSTANCE", + "componentId": "1:243", + "sourcePath": "/.me版/WEB/02 弹窗/03. 导航 · 用户菜单/用户菜单/list/item/个人中心/icon/user", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "56ed4cc797c67719ff0b60524e2f9ce4415ecd259b479c5d09892925238efe17", + "sha256": "49a88af16763d69572ed2a71b30c47a0e66cb3530e1446e7be75e6f59b44191d" + }, + { + "file": "public/static/images/menu_jbcz.svg", + "nodeId": "1:5930", + "role": "icon/zap", + "nodeType": "INSTANCE", + "componentId": "1:70", + "sourcePath": "/.me版/WEB/02 弹窗/03. 导航 · 用户菜单/用户菜单/list/item/金币充值/icon/zap", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "e846e7ea54133078226a53084b315a2985c35cc2a7424997b7cafc7b15d9877f", + "sha256": "2e6191c0ed01be6f5915b084a35f1bd970e87ff95a59b71d87434454b6338727" + }, + { + "file": "public/static/images/menu_jj.svg", + "nodeId": "1:22010", + "role": "icon/tv", + "nodeType": "INSTANCE", + "componentId": "1:113", + "sourcePath": "/.me版/H5/02 弹窗/03. 菜单抽屉 · 未登录/菜单抽屉/nav/item/剧集/icon/tv", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/lib/view-helpers.ts" + ], + "previousSha256": "953baf323f5910fbccb2506b33075897aff4287a5d9f6d24b83672d86d81c591", + "sha256": "9dc802c77b08162cde34ac3171c93e45672293efb30cd772ba8fb87c233d14a0" + }, + { + "file": "public/static/images/menu_phb.svg", + "nodeId": "1:22016", + "role": "icon/trending-up", + "nodeType": "INSTANCE", + "componentId": "1:163", + "sourcePath": "/.me版/H5/02 弹窗/03. 菜单抽屉 · 未登录/菜单抽屉/nav/item/排行榜/icon/trending-up", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/lib/view-helpers.ts" + ], + "previousSha256": "077383df246770e1e74e09ef4140d538bedace8566cf40f5061993504bce8085", + "sha256": "9e4d42afb61b5ccf4b4eb9510522afedb9d729221b72ca420e88931e115a0369" + }, + { + "file": "public/static/images/menu_sc.svg", + "nodeId": "1:5921", + "role": "icon/bookmark", + "nodeType": "INSTANCE", + "componentId": "1:125", + "sourcePath": "/.me版/WEB/02 弹窗/03. 导航 · 用户菜单/用户菜单/list/item/我的收藏/icon/bookmark", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "acd1d04ce8e2b487a26027c7c3c589c874d97f34d5a476d5ad255eeabac1ebcf", + "sha256": "764b61936f89a53847cdd853dc10d7e3c7c32e6c1b70aae78520d042dd963c2c" + }, + { + "file": "public/static/images/menu_zhsz.svg", + "nodeId": "1:5935", + "role": "icon/sliders", + "nodeType": "INSTANCE", + "componentId": "1:255", + "sourcePath": "/.me版/WEB/02 弹窗/03. 导航 · 用户菜单/用户菜单/list/item/账号设置/icon/sliders", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [], + "previousSha256": "eadcc671a5cd351d867f475dbe05216ecfd04696a30b8c3cca47b13364c6a5cf", + "sha256": "43292419bbd9f286228c118182361d1ad0412320baa9283a8503dee5233ec44c" + }, + { + "file": "public/static/images/menu_zt.svg", + "nodeId": "1:22013", + "role": "icon/layers", + "nodeType": "INSTANCE", + "componentId": "1:109", + "sourcePath": "/.me版/H5/02 弹窗/03. 菜单抽屉 · 未登录/菜单抽屉/nav/item/专题/icon/layers", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/lib/view-helpers.ts" + ], + "previousSha256": "0a5d8202f2c35edb6bb3829cdc27ba5a4c94ebfcb4caac08bc4371ab7ca7ed7f", + "sha256": "d70990952ca6582d4b59525f454e0f3e9aa658f7f6e2e3cf0561166384f9b09e" + }, + { + "file": "public/static/images/menu.svg", + "nodeId": "1:15264", + "role": "icon/menu", + "nodeType": "INSTANCE", + "componentId": "1:93", + "sourcePath": "/.me版/H5/01 首页/01. 首页 · 未登录/02-Hero/01-Nav/left/btn/menu/icon/menu", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "ac2eb5981af14d847829306f93e06cceccd7368ca15fd0a681630c17b4ae7d92", + "sha256": "373cd01dac8b3f56ba279af26fdc9de356c7a13ef8f42c51905b31e74b020939" + }, + { + "file": "public/static/images/message-circle.svg", + "nodeId": "1:1247", + "role": "icon/message-circle", + "nodeType": "INSTANCE", + "componentId": "1:144", + "sourcePath": "/.me版/WEB/05 播放详情/01. 播放详情 · 默认/04-信息/info/actions/btn/反馈/icon/message-circle", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/features/playback/DramaWatchPage.tsx", + "src/features/playback/SeriesWatchPage.tsx" + ], + "previousSha256": "b2fa8b2435a3f75e2aafb734e85488a0d1df8b54303337d08479fe7d5142aaaa", + "sha256": "e5f4bc7474bdfe87f38203df46e137be29d8a44b6f1492904bd32074d8cd9197" + }, + { + "file": "public/static/images/mobile-tabbar/film-active.svg", + "nodeId": "1:15932", + "role": "icon/film", + "nodeType": "INSTANCE", + "componentId": "1:58", + "sourcePath": "/.me版/H5/03 分类与二级页/01. 短剧 · 全部/99-TabBar/tab/短剧/icon/film", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ff0f53" + ], + "usedBy": [], + "sha256": "7307359620fa7a0c9d2b3f2cd87f39b1fd2ec19980f0a47eb41946723947abcb" + }, + { + "file": "public/static/images/mobile-tabbar/film-muted.svg", + "nodeId": "1:15516", + "role": "icon/film", + "nodeType": "INSTANCE", + "componentId": "1:58", + "sourcePath": "/.me版/H5/01 首页/01. 首页 · 未登录/99-TabBar/tab/短剧/icon/film", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [], + "sha256": "c65620bbf92cfd7fbbfe485b9e3334c9076706c64b9d74b76c11ebfa5862e6b6" + }, + { + "file": "public/static/images/mobile-tabbar/home-active.svg", + "nodeId": "1:15513", + "role": "icon/home", + "nodeType": "INSTANCE", + "componentId": "1:282", + "sourcePath": "/.me版/H5/01 首页/01. 首页 · 未登录/99-TabBar/tab/首页/icon/home", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ff0f53" + ], + "usedBy": [], + "sha256": "7f8a765cc90be98e2c2d19d75854e8ab969e7eae4b2d352f9f83ccf78cbc111f" + }, + { + "file": "public/static/images/mobile-tabbar/home-muted.svg", + "nodeId": "1:15929", + "role": "icon/home", + "nodeType": "INSTANCE", + "componentId": "1:282", + "sourcePath": "/.me版/H5/03 分类与二级页/01. 短剧 · 全部/99-TabBar/tab/首页/icon/home", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [], + "sha256": "a4a9b01f129c2465fc21c2c9ff6be4a576e84794a4fd2bc8fb3e25804e651c85" + }, + { + "file": "public/static/images/mobile-tabbar/tv-active.svg", + "nodeId": "1:16055", + "role": "icon/tv", + "nodeType": "INSTANCE", + "componentId": "1:113", + "sourcePath": "/.me版/H5/03 分类与二级页/02. 剧集 · 全部/99-TabBar/tab/剧集/icon/tv", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ff0f53" + ], + "usedBy": [], + "sha256": "5bec816fe512af2ca4947479eb0a312bf52298eb44ce2de9d4a3dee67da81a55" + }, + { + "file": "public/static/images/mobile-tabbar/tv-muted.svg", + "nodeId": "1:15519", + "role": "icon/tv", + "nodeType": "INSTANCE", + "componentId": "1:113", + "sourcePath": "/.me版/H5/01 首页/01. 首页 · 未登录/99-TabBar/tab/剧集/icon/tv", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [], + "sha256": "4ab720b7644b723626040ad36503bda9da8cefc38247e2c0f4ced1b44e5a2dd7" + }, + { + "file": "public/static/images/mobile-tabbar/user-active.svg", + "nodeId": "1:23647", + "role": "icon/user", + "nodeType": "INSTANCE", + "componentId": "1:243", + "sourcePath": "/.me版/H5/08 我的/01. 我的 · 未登录/99-TabBar/tab/我的/icon/user", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ff0f53" + ], + "usedBy": [], + "sha256": "635f8a585771960af4a988ef3924cf71f57f5691b04f4f38e22f203e28a0da64" + }, + { + "file": "public/static/images/mobile-tabbar/user-muted.svg", + "nodeId": "1:15522", + "role": "icon/user", + "nodeType": "INSTANCE", + "componentId": "1:243", + "sourcePath": "/.me版/H5/01 首页/01. 首页 · 未登录/99-TabBar/tab/我的/icon/user", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [], + "sha256": "0a9dd9c8e552d25a27aa3f5ad5094f439ac8b6cf0c71a23c44ec93f4814a5e49" + }, + { + "file": "public/static/images/nav_star.svg", + "nodeId": "1:6338", + "role": "icon/star", + "nodeType": "INSTANCE", + "componentId": "1:72", + "sourcePath": "/.me版/WEB/02 弹窗/04. 导航 · 分类下拉/分类下拉/col/榜单/q/编辑精选/icon/star", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "03df085bb4a00f2c718f1d54e45fa860e54ae98fac7a60208fee2d593d065ba2", + "sha256": "0f660e0ba6f5ff4d26f7df010213b62e7f45a4cdb69962bb6ec7fd54fde78e03" + }, + { + "file": "public/static/images/nav_trend.svg", + "nodeId": "1:6332", + "role": "icon/trending-up", + "nodeType": "INSTANCE", + "componentId": "1:163", + "sourcePath": "/.me版/WEB/02 弹窗/04. 导航 · 分类下拉/分类下拉/col/榜单/q/热播榜/icon/trending-up", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "8aa9971ef67b4214116ad6cd1683bf0ca90a5985f25df59ff1fcd32df7347929", + "sha256": "4219bd4eac20e8864700056120f564c3d5bdc8fa0b0311833e82e1fa3b98bebb" + }, + { + "file": "public/static/images/nav_zap.svg", + "nodeId": "1:6335", + "role": "icon/zap", + "nodeType": "INSTANCE", + "componentId": "1:70", + "sourcePath": "/.me版/WEB/02 弹窗/04. 导航 · 分类下拉/分类下拉/col/榜单/q/最新上架/icon/zap", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "91cdd133c6479659f7bb0d4cb3b0009b39993c5181b02477ed3fb1da0cd348c1", + "sha256": "61f340d7472d47cd451613206d360f324b7df53eab2e51504dc270231da28463" + }, + { + "file": "public/static/images/ok.svg", + "nodeId": "1:13532", + "role": "ok", + "nodeType": "FRAME", + "sourcePath": "/.me版/WEB/07 会员与金币/04. 支付成功 · 弹窗/支付成功/ok", + "sourceSize": { + "width": 72, + "height": 72 + }, + "size": { + "width": 72, + "height": 72 + }, + "palette": [ + "#ff0f53" + ], + "usedBy": [ + "src/server/api.ts", + "src/server/handler.ts" + ], + "previousSha256": "b7101e2f105b30e249a71857d096579af7637212aab0672ccc581d39de3dc1b7", + "note": "支付成功的 72px ok frame,包含真实设计 check 与圆形边框。", + "sha256": "53a888f29f4a1e7e5080f17b11cf2c8be551d249ad575d78261d85da377c652e" + }, + { + "file": "public/static/images/play_hollow.svg", + "nodeId": "1:456", + "role": "icon/play", + "nodeType": "INSTANCE", + "componentId": "1:46", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/02-Hero/hero/内容/main/left/cta/btn/立即播放/icon/play", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/catalog/Cards.tsx", + "src/components/catalog/HeroReact.tsx", + "src/components/providers/SiteDialogs.tsx", + "src/features/account/HistoryPage.tsx", + "src/features/account/PurchasedPage.tsx", + "src/features/catalog/BrowsePages.tsx", + "src/features/catalog/DiscoveryPages.tsx", + "src/features/playback/DramaWatchPage.tsx", + "src/features/playback/SeriesWatchPage.tsx" + ], + "previousSha256": "c67bcebef7e6ea8a91b5e83f1b329bef7ce74eb9e25c0c71bf1307f565758bd1", + "note": "导出新版立即播放按钮中的 white 轮廓三角实例,沿用原文件路径以保持继续观看功能。", + "sha256": "dd8c71dffd15dfab49a4d79987cfd5896a74c6f595f365eee794ab377e5d9daf" + }, + { + "file": "public/static/images/play.svg", + "nodeId": "1:467", + "role": "icon/play", + "nodeType": "INSTANCE", + "componentId": "1:46", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/02-Hero/hero/内容/main/主视觉/横版母版/btn/play/icon/play", + "sourceSize": { + "width": 27, + "height": 27 + }, + "size": { + "width": 27, + "height": 27 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/app/layout.tsx", + "src/components/analytics/tracker.ts", + "src/components/catalog/Cards.tsx", + "src/components/catalog/HeroReact.tsx", + "src/features/content/FeedbackPage.tsx", + "src/features/playback/PlaybackStage.tsx", + "src/server/pages/index.ts", + "src/server/seo/policy.ts", + "src/server/services/shared.ts" + ], + "previousSha256": "880d6ecb5d5b81c3d646133383f1b3ae95d2d7693df9f934c543642d71de3a75", + "sha256": "a4bbd42dc6cfd770fe6b924789bd4bfb01c2c3fd35912452e65eef4c195a1698" + }, + { + "file": "public/static/images/plus-dim.svg", + "nodeId": "1:14196", + "role": "icon/plus", + "nodeType": "INSTANCE", + "componentId": "1:182", + "sourcePath": "/.me版/WEB/09 规则/05. 意见反馈/03-正文/cols/form/截图/slots/slot/icon/plus", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/content/FeedbackPage.tsx" + ], + "previousSha256": "7dc5a5e05cc1503099b36db52e95e49bfa8528bc877517793c925bea31e45768", + "sha256": "16eef44ef588f8e6db40f47f3485b0288e4f885c56784794b61550d7993a45f4" + }, + { + "file": "public/static/images/plus.svg", + "nodeId": "1:462", + "role": "icon/plus", + "nodeType": "INSTANCE", + "componentId": "1:182", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/02-Hero/hero/内容/main/left/cta/btn/plus/icon/plus", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/catalog/HeroReact.tsx" + ], + "previousSha256": "0cf85893a0d0a60ee09468f5b4e960f63ab6fca83cf6803e6dc3b6612a1e9f52", + "sha256": "4022045e68fc8a655e398f0a8d611491092ed2e653e3b580bb3bbcfa91dd4215" + }, + { + "file": "public/static/images/recharge/award.svg", + "nodeId": "1:12027", + "role": "icon/award", + "nodeType": "INSTANCE", + "componentId": "1:160", + "sourcePath": "/.me版/WEB/07 会员与金币/01. 金币充值/03-余额/余额卡/r/it/icon/award", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#ffc000" + ], + "usedBy": [], + "previousSha256": "b99ff94ceaf28d44f5f3dc22bb5b30b531a8f5e793cb50d7b6b67fe3f466e78f", + "sha256": "ae973b91a6cd7befb555de627742e5a31967f314fb2aa4a717387fdb2922cfae" + }, + { + "file": "public/static/images/recharge/back.svg", + "nodeId": "1:16063", + "role": "icon/chevron-left", + "nodeType": "INSTANCE", + "componentId": "1:210", + "sourcePath": "/.me版/H5/03 分类与二级页/03. 专题 · 全部/01-Nav/btn/chevron-left/icon/chevron-left", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/layout/SubpageHeader.tsx" + ], + "previousSha256": "d430d9a9d4ba824f6e5a1d2e31d42b75e1013c134f324890c16dae304615276d", + "sha256": "b3ef5542c264473e22252ba4065a41b5f24866a07ff3a1b143dcf23e798df99b" + }, + { + "file": "public/static/images/recharge/check-circle.svg", + "nodeId": "1:12053", + "role": "icon/check-circle", + "nodeType": "INSTANCE", + "componentId": "1:78", + "sourcePath": "/.me版/WEB/07 会员与金币/01. 金币充值/04-套餐/套餐/pkg/¥100/top/icon/check-circle", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#ffc000" + ], + "usedBy": [ + "src/components/payment/RechargeDialog.tsx", + "src/features/account/RechargePage.tsx" + ], + "previousSha256": "03882fadabb2ef103de1eca3113e841c08587b6e624e0f34146cddac7b91b384", + "sha256": "9f575d60a49cc1969b34619bc0240efd9f75a195ce6dfc7a098318fdfa295afc" + }, + { + "file": "public/static/images/recharge/check.svg", + "nodeId": "1:12098", + "role": "icon/check", + "nodeType": "INSTANCE", + "componentId": "1:76", + "sourcePath": "/.me版/WEB/07 会员与金币/01. 金币充值/05-支付/支付方式/pm/支付宝/check/icon/check", + "sourceSize": { + "width": 12, + "height": 12 + }, + "size": { + "width": 12, + "height": 12 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/payment/RechargeDialog.tsx", + "src/features/account/RechargePage.tsx" + ], + "previousSha256": "1497c3d6455db5b3e187217bd0b65134901206ed910c352535d0ac77a29a1c70", + "sha256": "fa1fda386d5c0d53dbc1a1b8e3b9a45cc3a6b844bf9eed0c5a2fbd80915dbd52" + }, + { + "file": "public/static/images/recharge/close.svg", + "nodeId": "1:3850", + "role": "icon/x", + "nodeType": "INSTANCE", + "componentId": "1:97", + "sourcePath": "/.me版/WEB/05 播放详情/06. 金币解锁 · 弹窗/金币解锁弹窗/close/icon/x", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/payment/RechargeDialog.tsx" + ], + "previousSha256": "c4748b8e3520c4dac0cc4f07d9648dee63e1587478a38e9992d9c9745f6ad810", + "sha256": "f4196d3bc14dcb8ad18144f269dc53798b09bebd8278d73ae564f41219abb4c6" + }, + { + "file": "public/static/images/recharge/download.svg", + "nodeId": "1:2098", + "role": "icon/download", + "nodeType": "INSTANCE", + "componentId": "1:51", + "sourcePath": "/.me版/WEB/05 播放详情/03. 播放详情 · 金币解锁/03-播放器/player/lockHint/btns/appFree/icon/download", + "sourceSize": { + "width": 12, + "height": 12 + }, + "size": { + "width": 12, + "height": 12 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [ + "src/components/payment/RechargeDialog.tsx", + "src/components/payment/TransactionHistory.tsx" + ], + "previousSha256": "3ab47e093cb51c8e6ede29f7a28f31f77d54944b40138d2057a388ce59ea7a6e", + "sha256": "1a6356f495b2b843e2bbb86bfa06daa31b908218474b05767ab2e00ab1e2c431" + }, + { + "file": "public/static/images/recharge/help.svg", + "nodeId": "1:22573", + "role": "icon/help-circle", + "nodeType": "INSTANCE", + "componentId": "1:185", + "sourcePath": "/.me版/H5/07 会员与金币/01. 金币充值/01-Nav/btn/help-circle/icon/help-circle", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/layout/SubpageHeader.tsx" + ], + "previousSha256": "4ebf901c7c4b99b89a7028ce7c709611ae665d1948773c2fc93ff06fa6fe4f5e", + "sha256": "20139746a08e7cccc548f00da9cd637809f7af31bd47c80ce5eb4d49253e0f8a" + }, + { + "file": "public/static/images/recharge/lock.svg", + "nodeId": "1:12017", + "role": "icon/lock", + "nodeType": "INSTANCE", + "componentId": "1:157", + "sourcePath": "/.me版/WEB/07 会员与金币/01. 金币充值/03-余额/余额卡/r/it/icon/lock", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#ffc000" + ], + "usedBy": [], + "previousSha256": "8725c76325ec7415953da5eee9a03516b2a18f4514a42c684903014f40042b97", + "sha256": "b81dc90f062427d9bccac1b3a1a393858e829924a93e2dae54af66dfc12ec5f5" + }, + { + "file": "public/static/images/recharge/zap.svg", + "nodeId": "1:12022", + "role": "icon/zap", + "nodeType": "INSTANCE", + "componentId": "1:70", + "sourcePath": "/.me版/WEB/07 会员与金币/01. 金币充值/03-余额/余额卡/r/it/icon/zap", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#ffc000" + ], + "usedBy": [], + "previousSha256": "437ca538522e8fc792371a9bff0568fd50c2be3693aaaf16b292f5cb5fce99e9", + "sha256": "8af7f5a60bfa624230b695fa201fc909dc06cd07f6aa0f20830c79c41d8bbb9b" + }, + { + "file": "public/static/images/search_h5.svg", + "nodeId": "1:15269", + "role": "icon/search", + "nodeType": "INSTANCE", + "componentId": "1:179", + "sourcePath": "/.me版/H5/01 首页/01. 首页 · 未登录/02-Hero/01-Nav/right/btn/search/icon/search", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/catalog/Interactions.tsx", + "src/components/layout/SiteHeader.tsx" + ], + "previousSha256": "e39c4a027978950fa013cd56683b2818dc5b0703f00a5fccc298639a7958e062", + "sha256": "51055c65353311cd40353c02d4de649ecc901e2c5fb01cf161531a12a020a76c" + }, + { + "file": "public/static/images/search.svg", + "nodeId": "1:435", + "role": "icon/search", + "nodeType": "INSTANCE", + "componentId": "1:179", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/02-Hero/01-Nav/right/search/icon/search", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/analytics/tracker.ts", + "src/components/layout/SiteHeader.tsx", + "src/features/catalog/DiscoveryPages.tsx", + "src/lib/view-helpers.ts", + "src/server/pages/index.ts", + "src/server/react/presentation.ts", + "src/server/services/shared.ts" + ], + "previousSha256": "0fe686e0e2779fc56f8ed8071a2db8ca7892df132bbb7dac0cab9591fb4ac01f", + "sha256": "fd8a72c0cf07f6b2d9ed54a18fc77e23bb7ef769ceef2b5f92a994c1427bae28" + }, + { + "file": "public/static/images/send.svg", + "nodeId": "1:13761", + "role": "icon/send", + "nodeType": "INSTANCE", + "componentId": "1:146", + "sourcePath": "/.me版/WEB/09 规则/02. 常见问题/03-正文/cols/right/联系/btn/提交意见反馈/icon/send", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/features/content/FeedbackPage.tsx", + "src/features/content/HelpPage.tsx", + "src/features/playback/Comments.tsx" + ], + "previousSha256": "eb0eb3cb6e07f95ac35026c4d0a4ecb55a942c342f03103a307f37fcc359b790", + "sha256": "5c6409a0f072f24f7846ba6f3cb264f1bdd225a5a7a5cfae70cdac3387c55e5d" + }, + { + "file": "public/static/images/settings/eye.svg", + "nodeId": "1:11829", + "role": "icon/eye", + "nodeType": "INSTANCE", + "componentId": "1:136", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/side/nav/隐私设置/icon/eye", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [], + "previousSha256": "2d29d0739f466703a7076ffae6a553fdb8f534d7bb5a4e1ae2b52f2585ba81ab", + "sha256": "56d139307cae802c8350f3398306535a446c7c3d730a7513b3bc7c1227acb34d" + }, + { + "file": "public/static/images/settings/field-shield.svg", + "nodeId": "1:11849", + "role": "icon/shield", + "nodeType": "INSTANCE", + "componentId": "1:74", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/form/grid/input/账号/field/icon/shield", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/account/SettingsPage.tsx" + ], + "sha256": "4d67d70ed57c916b408cd49ebd85e26894cb9e069bda9e7eb09794c3b6f0e6a6" + }, + { + "file": "public/static/images/settings/field-user.svg", + "nodeId": "1:11844", + "role": "icon/user", + "nodeType": "INSTANCE", + "componentId": "1:243", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/form/grid/input/昵称/field/icon/user", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/account/SettingsPage.tsx" + ], + "sha256": "f3f785ac5c3917c42deab24ee89b04bc2f0895315cbb1976e72406ec0345e2cf" + }, + { + "file": "public/static/images/settings/image.svg", + "nodeId": "1:11837", + "role": "icon/image", + "nodeType": "INSTANCE", + "componentId": "1:200", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/form/avatar-row/t/btn/更换头像/icon/image", + "sourceSize": { + "width": 16, + "height": 16 + }, + "size": { + "width": 16, + "height": 16 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/features/account/SettingsPage.tsx" + ], + "previousSha256": "95293b8dec0172ffdd0922c140e34a08d9dcfcba973d5a441f174e09e2f1968b", + "sha256": "780c8319f157186bc313b175ff7a76046dc73acd5048d27cfe8f450cf6997df4" + }, + { + "file": "public/static/images/settings/lock.svg", + "nodeId": "1:11867", + "role": "icon/lock", + "nodeType": "INSTANCE", + "componentId": "1:157", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/form/grid/input/登录密码/field/icon/lock", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/account/SettingsPage.tsx" + ], + "previousSha256": "eabed912f39add207cd6f8290027bf26a9c65dfc464d17d2b87e5ef21dfd7512", + "sha256": "163265abbfa4c31f4e785534022049b145c849aadca643549908af9c77d6b624" + }, + { + "file": "public/static/images/settings/mail.svg", + "nodeId": "1:11861", + "role": "icon/mail", + "nodeType": "INSTANCE", + "componentId": "1:189", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/form/grid/input/邮箱/field/icon/mail", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/features/account/SettingsPage.tsx" + ], + "previousSha256": "b9f3b17b8bac1dcdf2675aebbce19c5cd6604fb7d3134dc8a6e43594c2fe83eb", + "sha256": "d4a4c5f7a578996cd6672d9d943ad347d594b7d04dc978a3bd70b3fa9c7b5ec0" + }, + { + "file": "public/static/images/settings/message.svg", + "nodeId": "1:11826", + "role": "icon/message-circle", + "nodeType": "INSTANCE", + "componentId": "1:144", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/side/nav/通知偏好/icon/message-circle", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [], + "previousSha256": "a5cae361b0271f5a6f3e6870c51a44805a69c2a86b249624a28e6401e2437122", + "sha256": "db1c09489d6684c85718807251ca13be06361b868091f7b1d3d58ada144a7f17" + }, + { + "file": "public/static/images/settings/shield.svg", + "nodeId": "1:11823", + "role": "icon/shield", + "nodeType": "INSTANCE", + "componentId": "1:74", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/side/nav/安全设置/icon/shield", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#b9bbc2" + ], + "usedBy": [], + "previousSha256": "dfb690a8b5984669d1e997e802e07879f17027a3a3e0421d39ca0be0d73f96e4", + "sha256": "737f8efcf1bfac3ed1180120ecffbef8edd43632a9065e9f35986fffaddddd26" + }, + { + "file": "public/static/images/settings/user.svg", + "nodeId": "1:11820", + "role": "icon/user", + "nodeType": "INSTANCE", + "componentId": "1:243", + "sourcePath": "/.me版/WEB/08 我的/06. 账号设置/04-账号设置/cols/side/nav/基本资料/icon/user", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#ff0f53" + ], + "usedBy": [], + "previousSha256": "ec9e80eecfccad83e3aa4c1d346084c3d759e20f38a3822b3053fce2e7e42086", + "sha256": "365eeeefefdf5b855045d166a7f58437ce585eb9b23d4588e93f4dcdb45cf2c1" + }, + { + "file": "public/static/images/share.svg", + "nodeId": "1:464", + "role": "icon/share-2", + "nodeType": "INSTANCE", + "componentId": "1:194", + "sourcePath": "/.me版/WEB/01 首页/01. 首页 · 未登录/02-Hero/hero/内容/main/left/cta/btn/share-2/icon/share-2", + "sourceSize": { + "width": 22, + "height": 22 + }, + "size": { + "width": 22, + "height": 22 + }, + "palette": [ + "#ffffff" + ], + "usedBy": [ + "src/components/catalog/Interactions.tsx", + "src/features/playback/Shared.tsx" + ], + "previousSha256": "8b85e507000cdc4d2c1b8d5e63aac1ddc464ebdd68a4fe8cc04c8b26ff13e97e", + "sha256": "573ea6675faab4425ecea3dd5505c0b69c756fba7c366fcaa22d956ddd0eed78" + }, + { + "file": "public/static/images/trending-up.svg", + "nodeId": "1:16316", + "role": "icon/trending-up", + "nodeType": "INSTANCE", + "componentId": "1:163", + "sourcePath": "/.me版/H5/03 分类与二级页/05. 排行榜/03-列表/ranks/rank/1/t/heat/icon/trending-up", + "sourceSize": { + "width": 11, + "height": 11 + }, + "size": { + "width": 11, + "height": 11 + }, + "palette": [ + "#ff0f53" + ], + "usedBy": [ + "src/components/catalog/Cards.tsx" + ], + "previousSha256": "2deea582b4d41a60c3d0e0be4a21340c6cde6a262f2c41736753f8e1ed8d096e", + "sha256": "8fad544a86e4c93f045e283890be678bb6492d6e791612374bcb7c1453dfb9d9" + }, + { + "file": "public/static/images/user.svg", + "nodeId": "1:6701", + "role": "icon/user", + "nodeType": "INSTANCE", + "componentId": "1:243", + "sourcePath": "/.me版/WEB/06 登录注册/01. 登录 · 密码登录/登录弹窗/form/input/账号/field/icon/user", + "sourceSize": { + "width": 18, + "height": 18 + }, + "size": { + "width": 18, + "height": 18 + }, + "palette": [ + "#7e818b" + ], + "usedBy": [ + "src/components/auth/AuthForm.tsx", + "src/components/layout/MobileTabbar.tsx", + "src/components/layout/SiteHeader.tsx", + "src/features/account/SettingsPage.tsx" + ], + "previousSha256": "9a0972207e2a39e2a03e0c287c3ab5ff2be7e685ccd37b15d381ea901f8c007f", + "sha256": "f3f785ac5c3917c42deab24ee89b04bc2f0895315cbb1976e72406ec0345e2cf" + }, + { + "file": "public/static/images/install/notice.svg", + "nodeId": "1:20353", + "role": "安装引导提示 · alert-circle · dim", + "sourcePath": ".me版 / H5 / 05 播放详情 / 09. 安装引导 · iOS 商店版 / 安装引导 · iOS 商店版 / tip / icon/alert-circle", + "sourceSize": { + "width": 14.000000953674316, + "height": 14.000000953674316 + }, + "size": { + "width": 14, + "height": 14 + }, + "palette": [ + "#7e818b" + ], + "sha256": "437b85ef3e5d38e25d1790a3f94ba0a1e718b6b15827716f6ea76b90d1d3e70a", + "usedBy": [ + "src/components/providers/SiteDialogs.tsx" + ] + }, + { + "file": "public/static/images/install/external-link.svg", + "nodeId": "1:20356", + "role": "安装引导前往 App Store · external-link · white", + "sourcePath": ".me版 / H5 / 05 播放详情 / 09. 安装引导 · iOS 商店版 / 安装引导 · iOS 商店版 / btn/前往 App Store / icon/external-link", + "sourceSize": { + "width": 20, + "height": 20 + }, + "size": { + "width": 20, + "height": 20 + }, + "palette": [ + "#ffffff" + ], + "sha256": "b240b2b431f0537f6d7f11740b26ee9dd7e76f82b57f0797f496db1a3a89bcdd", + "usedBy": [ + "src/components/providers/SiteDialogs.tsx" + ] + } + ], + "retained": [ + { + "file": "public/static/images/arrow-up-right.svg", + "size": { + "width": 14, + "height": 14 + }, + "sha256": "43e4df8ca5faf648e0a5693293e4f6826a07e4fff0e931450c371bf2a08d6dfe", + "usedBy": [ + "src/features/content/SiteNavigationPage.tsx" + ], + "reason": "设计没有 arrow-up-right 素材,保留官方导航入口箭头。" + }, + { + "file": "public/static/images/close2.svg", + "size": { + "width": 36, + "height": 36 + }, + "sha256": "4ef5e9890077448208177302c428a28ede5127fa8a332f2f5611182f9fe13120", + "usedBy": [], + "reason": "设计中只有独立 x 图标,没有原 36px 带圆环关闭图标的对应素材;保留未使用旧资源。" + }, + { + "file": "public/static/images/external-link.svg", + "size": { + "width": 18, + "height": 18 + }, + "sha256": "00ecb261a6f37d8debe68a3605c7fbf2b4665860a62bd440f3c765610592384a", + "usedBy": [], + "reason": "设计 external-link 实例仅有 white;没有现有 accent 外链图标的同色素材,保留未使用旧资源。" + }, + { + "file": "public/static/images/eye-off.svg", + "size": { + "width": 18, + "height": 18 + }, + "sha256": "4166b2108fa332954afc20635f0bb29147bcaadbe52ddf9ba5adba66189c37f3", + "usedBy": [ + "src/components/auth/AuthForm.tsx" + ], + "reason": "设计没有 eye-off 图标,保留密码可见性切换所需原图。" + }, + { + "file": "public/static/images/gift-dim.svg", + "size": { + "width": 18, + "height": 18 + }, + "sha256": "3de050c2249172cc617c8d0f8ae3cf66797095b1443102d27e14f0ba1eeeb5d0", + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "reason": "设计没有 gift 组件或实例;现有绑定账号入口继续保留原图。" + }, + { + "file": "public/static/images/gift.svg", + "size": { + "width": 22, + "height": 22 + }, + "sha256": "92e260aa0c112b531618cc6d09c458c48aef9efcd5871e9b4e4a461e95c80997", + "usedBy": [ + "src/features/content/SiteNavigationPage.tsx", + "src/features/playback/CoinDialog.tsx" + ], + "reason": "设计没有 gift 组件或实例;现有充值福利与导航福利入口继续保留原图。" + }, + { + "file": "public/static/images/menu_sy.svg", + "size": { + "width": 18, + "height": 18 + }, + "sha256": "c97f75d578e9b3fc58874ed712379772e3ff19056177d2c14a9d610de86da342", + "usedBy": [ + "src/lib/view-helpers.ts" + ], + "reason": "设计菜单抽屉的 18px home 只有选中 accent 实例,现有文件同时服务未选中入口;没有同角色 muted 实例,保留避免非首页导航常亮。" + }, + { + "file": "public/static/images/nav_grid.svg", + "size": { + "width": 16, + "height": 16 + }, + "sha256": "2e151a0a6348660d5f4f1c8179a33a5fb0cf710c9ab123e1c67478377a814d1b", + "usedBy": [ + "src/components/layout/SiteHeader.tsx" + ], + "reason": "设计有 white grid 主组件,但没有现有分类菜单使用的 muted 实例;保留原图,避免更改分类菜单的色彩角色。" + }, + { + "file": "public/static/images/purchased-empty/bag-play.svg", + "size": { + "width": 40, + "height": 40 + }, + "sha256": "442b727d5535c5cd47c171bfcf9eb20dc119c74bd8c1fbdd65b7d3160b22f4ff", + "usedBy": [ + "src/features/account/PurchasedPage.tsx" + ], + "reason": "设计未包含已购剧集的 bag-play 空状态图标;保留原图,避免将收藏或购物图标误用为已购内容。" + }, + { + "file": "public/static/images/recharge/coin-star.svg", + "size": { + "width": 8, + "height": 8 + }, + "sha256": "6ee86a0f042726e252177dac74dca146279c879c130901385bf03dbd87c3f532", + "usedBy": [ + "src/components/payment/RechargeDialog.tsx" + ], + "reason": "设计金币已改为独立金币组件,没有原 8px coin-star 子图形;保留原局部资源,避免裁切或手绘金币。" + } + ] +} diff --git a/scripts/native-site-smoke.ts b/scripts/native-site-smoke.ts new file mode 100644 index 0000000..0184b99 --- /dev/null +++ b/scripts/native-site-smoke.ts @@ -0,0 +1,1071 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFile, mkdtemp, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, extname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { chromium, type Page, type BrowserContext } from "@playwright/test"; +import { installGuideData } from "../src/server/services/install-guide"; +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. +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const temporary = await mkdtemp(join(tmpdir(), "huakui-native-site-")), + bundle = join(temporary, "site.js"); +await build({ + stdin: { + resolveDir: root, + loader: "tsx", + 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'; +import {SiteNavigationPage} from '@/features/content/SiteNavigationPage'; +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
} +function render(data){app.render(data.requestPath==='/navigation/'?:

真实 React 共享组件隔离检查

点击下载
);}window.__rerender=render;render(window.__fixture); +`, + }, + bundle: true, + platform: "browser", + format: "iife", + outfile: bundle, + tsconfig: join(root, "tsconfig.json"), + banner: { js: 'var process={env:{NODE_ENV:"development"}};' }, + define: { + "process.env.NODE_ENV": '"development"', + "process.env.__NEXT_ROUTER_BASEPATH": '""', + }, +}); +const styles = [ + "base", + "header", + "footer", + "eighteen", + "dialogAd", + "downloadPop", + "installGuide", + "toast", + "nav", +]; +const siteStyles = await createStyleFixture(styles); +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url || "/", "http://" + request.headers.host), + path = url.pathname; + if (path === siteStyles.href) { + response.setHeader("Content-Type", "text/css;charset=utf-8"); + response.end(siteStyles.css); + return; + } + if (path === "/site.js") { + response.setHeader("Content-Type", "application/javascript"); + 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") + "/")) { + response.writeHead(403).end(); + return; + } + const mime: Record = { + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".webp": "image/webp", + ".woff2": "font/woff2", + }; + response.setHeader( + "Content-Type", + mime[extname(file)] || "application/octet-stream", + ); + response.end(await readFile(file)); + return; + } + if (path.startsWith("/mock/")) { + response.setHeader("Content-Type", "text/html"); + response.end("

Isolated destination

"); + return; + } + const fallback = url.origin + "/mock/fallback", + configured = url.searchParams.get("configured") !== "0", + user = url.searchParams.has("user"); + const guide = installGuideData( + fallback, + configured + ? { + iosStoreURL: url.origin + "/mock/ios-store", + iosProfileURL: url.origin + "/mock/ios-profile", + androidAPKURL: url.origin + "/mock/android.apk", + deepLinkURL: url.origin + "/mock/launch", + } + : { + iosStoreURL: "", + iosProfileURL: "", + androidAPKURL: "", + deepLinkURL: "", + }, + ); + const ads = url.searchParams.has("ads") + ? [ + { + id: url.searchParams.has("bad") + ? "bad" + : url.searchParams.has("stack") + ? "stack-1" + : "1", + images: url.searchParams.has("bad") + ? "/mock/bad-image.png" + : "/mock/encrypted-a.png", + link: url.origin + "/mock/ad-1", + title: "广告一", + location: 1, + }, + { + id: url.searchParams.has("stack") ? "stack-2" : "2", + images: "/mock/encrypted-b.png", + link: url.origin + "/mock/ad-2", + title: "广告二", + location: 2, + }, + ] + : []; + const data = { + requestPath: path, + pageKey: "home", + pageName: "首页", + trackerAppId: "isolated", + isLoggedIn: user, + isRegistered: false, + userName: "隔离游客", + userID: 12345, + recharge: 1234, + avatar: "/static/images/profile/avatar.png", + favoriteCount: 3, + installGuide: guide, + appConfig: { appAddr: fallback, dialogList: ads }, + config: { appAddr: fallback }, + categoryList: [ + { name: "首页", urlValue: "/" }, + { name: "短剧", urlValue: "/dramas/" }, + { + name: "友情链接", urlValue: "#", categoryType: 2, + translationList: [ + { name: "黄果视频", urlValue: "https://huangguo.com", isFriendAds: false }, + { name: "爱情专题", urlValue: "/topic/romance/" }, + { name: "下载 App", urlValue: "/download-app/" }, + ], + }, + { name: "下载 App", urlValue: "/app/download/" }, + ], + themeList: [{ id: 3, slug: "city", name: "都市" }], + homeConfigPlaylets: [{ id: 1, slug: "popular", name: "热门推荐" }], + searchTrends: [{ Rank: 1, Title: "都市", Heat: "1 万", Badge: "热" }], + }; + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.end( + '' + + `` + + '
', + ); + } catch { + response.writeHead(404).end(); + } +}); +await new Promise((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}`; +const image = Buffer.from( + await readFile(join(root, "public/static/images/profile/avatar.png")), + ), + 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 }; +const events: EventRecord[] = [], + errors: string[] = [], + missing: string[] = [], + unexpected: string[] = [], + destinations: string[] = []; +let browser: Awaited> | undefined; +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{}`, + }); + await context.route("**/*", async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.hostname === "avatars.test") { + await route.fulfill({ + body: image, + contentType: "application/octet-stream", + }); + return; + } + if (url.origin !== origin) { + await route.fulfill({ body: "", contentType: "text/plain" }); + return; + } + if (url.pathname.startsWith("/api/")) { + if (url.pathname === "/api/web/search/suggest") { + const keyword = url.searchParams.get("keyword") || ""; + await route.fulfill({ + json: { + code: 200, + keyword, + feature: { + title: keyword + "作品", + cover: "/static/images/profile/avatar.png", + url: "/drama/suggest/", + meta: "全 8 集", + }, + items: [ + { + title: keyword, + query: keyword, + kind: "搜索", + url: "/search/" + encodeURIComponent(keyword) + "/", + }, + ], + }, + }); + return; + } + if (url.pathname === "/api/auth/session") { + await route.fulfill({ json: { code: 200, data: null } }); + return; + } + unexpected.push(url.pathname); + await route.fulfill({ status: 500, json: { code: 500, data: null } }); + return; + } + if (url.pathname === "/mock/bad-image.png") { + await route.fulfill({ status: 500, body: "" }); + return; + } + if (url.pathname.startsWith("/mock/encrypted-")) { + imageRequested++; + if (delayImage && url.pathname.endsWith("a.png")) + await new Promise((done) => { + releaseImage = done; + }); + await route.fulfill({ + body: image, + contentType: "application/octet-stream", + }); + return; + } + if (url.pathname.startsWith("/mock/")) destinations.push(url.pathname); + await route.continue(); + }); + context.on("page", (page) => { + page.on("pageerror", (error) => errors.push(error.message)); + page.on("response", (response) => { + if ( + response.status() >= 400 && + new URL(response.url()).pathname.startsWith("/static/") + ) + missing.push(response.url()); + }); + }); +}; +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 }) => { + const canvas = document.querySelector(".hk-site-footer .hk-app-download-qr canvas"); + if (!canvas || canvas.getAttribute("data-qr") !== value) return false; + const pixels = canvas.getContext("2d")?.getImageData(0, 0, canvas.width, canvas.height).data; + if (!pixels || !pixels.some((channel, index) => index % 4 === 3 && channel > 0)) return false; + return !previous || canvas.toDataURL() !== previous; + }, { value, previous }); + const result = await page.locator(".hk-site-footer .hk-app-download-qr canvas").evaluate((node) => { + const canvas = node as HTMLCanvasElement; + const pixels = canvas.getContext("2d")!.getImageData(0, 0, canvas.width, canvas.height).data; + let dark = 0, light = 0; + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index + 3] !== 255) continue; + if (pixels[index] === 0 && pixels[index + 1] === 0 && pixels[index + 2] === 0) dark++; + if (pixels[index] === 255 && pixels[index + 1] === 255 && pixels[index + 2] === 255) light++; + } + const bounds = canvas.getBoundingClientRect(); + return { width: canvas.width, height: canvas.height, displayWidth: bounds.width, displayHeight: bounds.height, dark, light, png: canvas.toDataURL() }; + }); + assert.equal(result.width, 104); + assert.equal(result.height, 104); + assert(result.displayWidth > 0); + assert.equal(result.displayWidth, result.displayHeight); + assert(result.dark > 100 && result.light > 100, "QR canvas contains opaque black modules and a white background"); + return result.png; +}; +const expectOverflow = async (page: Page, value: string) => + assert.equal( + await page.locator("body").evaluate((node) => node.style.overflow), + value, + ); +const closeGuide = async (page: Page) => { + await page.keyboard.press("Escape"); + await page.waitForSelector("[data-install-guide], [data-download-pop]", { + state: "detached", + }); + await expectOverflow(page, ""); +}; +try { + const chrome = + process.env.CHROME_PATH || + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + browser = await chromium.launch({ + headless: true, + ...(existsSync(chrome) ? { executablePath: chrome } : {}), + }); + // Two visible dialogs share only the highest layer's keyboard/focus ownership. + const stacked = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + }); + await configure(stacked); + const stack = await stacked.newPage(); + await stack.goto(origin + "/?auth=login"); + await ready(stack); + await stack.locator("[data-eighteen]").waitFor(); + await stack.waitForTimeout(100); + assert.equal( + await stack.evaluate( + () => !!document.activeElement?.closest("[data-eighteen]"), + ), + true, + ); + await stack.keyboard.press("Tab"); + assert.equal( + await stack.evaluate( + () => !!document.activeElement?.closest("[data-eighteen]"), + ), + true, + ); + await stack.keyboard.press("Escape"); + assert.equal(await stack.locator("[data-eighteen]").isVisible(), true); + await stack.locator("[data-eighteen-enter]").click(); + await stack.waitForSelector("[data-eighteen]", { state: "detached" }); + // The account system was removed: no login dialog is queued or opened on demand. + await stack.evaluate(() => + document.dispatchEvent(new CustomEvent("auth:required")), + ); + await stack.waitForTimeout(100); + assert.equal(await stack.locator("[data-login-pop], [data-register-pop], [data-recharge-pop]").count(), 0); + await expectOverflow(stack, ""); + await stack.goto(origin + "/?ads=1&stack=1"); + await ready(stack); + await stack.locator("[data-dialog-ad].hk-is-open").waitFor(); + // A higher-priority site dialog suspends the advertisement queue and resumes it on close. + await stack.evaluate(() => + document.querySelector(".hk-site-header__download")?.click(), + ); + await stack.locator("[data-download-pop]").waitFor(); + assert.equal(await stack.locator("[data-dialog-ad]").count(), 0); + await stack.keyboard.press("Escape"); + await stack.waitForSelector("[data-download-pop]", { state: "detached" }); + await stack.locator("[data-dialog-ad].hk-is-open").waitFor(); + assert.equal( + await stack + .locator("[data-dialog-ad-id]") + .getAttribute("data-dialog-ad-id"), + "stack-1", + ); + await stack.keyboard.press("Escape"); + await stack.waitForFunction( + () => + document + .querySelector("[data-dialog-ad-id]") + ?.getAttribute("data-dialog-ad-id") === "stack-2", + ); + 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({ + viewport: { width: 1440, height: 900 }, + }); + await configure(context); + const page = await context.newPage(); + await page.goto(origin + "/?ads=1"); + await ready(page); + await page.locator("[data-eighteen]").waitFor(); + await expectOverflow(page, "hidden"); + assert.equal(imageRequested, 0); + await page.keyboard.press("Escape"); + assert.equal(await page.locator("[data-eighteen]").isVisible(), true); + await page.locator("[data-eighteen-enter]").focus(); + await page.keyboard.press("Shift+Tab"); + assert.equal( + await page + .locator("[data-eighteen-exit]") + .evaluate((el) => el === document.activeElement), + true, + ); + await page.keyboard.press("Tab"); + assert.equal( + await page + .locator("[data-eighteen-enter]") + .evaluate((el) => el === document.activeElement), + true, + ); + delayImage = true; + await page.locator("[data-eighteen-enter]").click(); + await page.waitForFunction(() => !document.querySelector("[data-eighteen]")); + 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", + ); + assert.equal( + await page.evaluate( + () => (window as unknown as { __ageClosed: number }).__ageClosed, + ), + 1, + ); + delayImage = false; + releaseImage?.(); + await page.locator("[data-dialog-ad].hk-is-open").waitFor({ timeout: 15000 }); + assert.equal( + await page + .locator(".hk-advertisement-dialog-image") + .evaluate( + (el: HTMLImageElement) => + el.currentSrc.startsWith("blob:") && el.naturalWidth > 0, + ), + true, + ); + await expectOverflow(page, "hidden"); + const outboundPromise = context.waitForEvent("page"); + await page.locator(".hk-advertisement-dialog-cta").click(); + const outbound = await outboundPromise; + await outbound.waitForLoadState(); + assert.equal(new URL(outbound.url()).pathname, "/mock/ad-1"); + await outbound.close(); + await page.locator("[data-dialog-ad-close]").click(); + await page.waitForFunction( + () => + document + .querySelector("[data-dialog-ad-id]") + ?.getAttribute("data-dialog-ad-id") === "2", + ); + await page.goto(origin + "/dramas/?ads=1"); + await ready(page); + await page.locator("[data-dialog-ad].hk-is-open").waitFor(); + assert.equal( + await page.locator("[data-dialog-ad-id]").getAttribute("data-dialog-ad-id"), + "2", + ); + assert.equal(await page.locator("[data-eighteen]").count(), 0); + await page.keyboard.press("Escape"); + await page.waitForSelector("[data-dialog-ad]", { state: "detached" }); + await expectOverflow(page, ""); + await page.goto(origin + "/series/?ads=1"); + await ready(page); + await page.waitForTimeout(100); + assert.equal(await page.locator("[data-dialog-ad]").count(), 0); + assert.equal( + 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(); + assert.equal( + 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"); + await ready(page); + assert.equal( + await page.locator( + "[data-login-open], [data-register-open], .hk-site-header__login, [data-user-menu-toggle], .hk-user-menu, [data-header-recharge], a[href^='/account']", + ).count(), + 0, + "Header renders no login, user menu or account entry even for a guest session", + ); + // Configured navigation uses the same links on hover, keyboard and touch. + const friendMenu = page.locator('[data-navigation-submenu="desktop"]'), + friendToggle = friendMenu.locator(".hk-site-navigation__submenu-toggle"), + friendPanel = friendMenu.locator(".hk-site-navigation__submenu"); + assert.equal(await friendToggle.getAttribute("aria-expanded"), "false"); + assert.equal(await friendPanel.isVisible(), false); + assert.equal(await friendToggle.getAttribute("aria-controls"), await friendPanel.getAttribute("id")); + assert.equal(await friendPanel.locator('a[data-navigation-name="爱情专题"]').getAttribute("href"), "/collection/romance/"); + await friendToggle.hover(); + await friendPanel.waitFor({ state: "visible" }); + const toggleBounds = await friendToggle.boundingBox(), + panelBounds = await friendPanel.boundingBox(); + assert.ok(toggleBounds && panelBounds); + assert.ok(panelBounds.x >= 0 && panelBounds.x + panelBounds.width <= 1440); + await page.mouse.move(toggleBounds.x + toggleBounds.width / 2, (toggleBounds.y + toggleBounds.height + panelBounds.y) / 2); + await page.waitForTimeout(40); + assert.equal(await friendPanel.isVisible(), true, "pointer can cross the gap into the submenu"); + await friendPanel.locator("a").first().hover(); + assert.equal(await friendToggle.getAttribute("aria-expanded"), "true"); + await page.locator("#outside").click(); + await friendPanel.waitFor({ state: "hidden" }); + await page.keyboard.press("Tab"); + await friendToggle.focus(); + await friendPanel.waitFor({ state: "visible" }); + await page.keyboard.press("Tab"); + assert.equal(await friendPanel.locator("a").first().evaluate((node) => node === document.activeElement), true); + await page.keyboard.press("Escape"); + await friendPanel.waitFor({ state: "hidden" }); + assert.equal(await friendToggle.evaluate((node) => node === document.activeElement), true); + assert.equal(await friendToggle.getAttribute("aria-expanded"), "false"); + await friendToggle.press("Space"); + await friendPanel.waitFor({ state: "visible" }); + await friendToggle.press("Space"); + await friendPanel.waitFor({ state: "hidden" }); + await friendToggle.press("Enter"); + await friendPanel.waitFor({ state: "visible" }); + await page.locator("#outside").click(); + await friendPanel.waitFor({ state: "hidden" }); + await friendToggle.hover(); + const friendWindowPromise = context.waitForEvent("page"); + await friendPanel.locator('a[data-navigation-name="黄果视频"]').click(); + const friendWindow = await friendWindowPromise; + await friendWindow.waitForLoadState(); + assert.equal(friendWindow.url(), "https://huangguo.com/"); + await friendWindow.close(); + await friendPanel.waitFor({ state: "hidden" }); + await friendToggle.hover(); + await friendPanel.locator("[data-download-open]").click(); + await page.locator("[data-download-pop]").waitFor(); + await friendPanel.waitFor({ state: "hidden" }); + await closeGuide(page); + // A real canvas is generated from the same URL as the footer download link. + const primaryQR = await footerQR(page, origin + "/mock/fallback"); + await page.evaluate((official) => { + const fixture = window as unknown as { + __fixture: { appConfig: Record; config: Record }; + __rerender: (data: Record) => void; + }; + fixture.__rerender({ + ...fixture.__fixture, + appConfig: { ...fixture.__fixture.appConfig, appAddr: "", officialAppAddr: official }, + config: { ...fixture.__fixture.config, appAddr: "", officialAppAddr: official }, + }); + }, origin + "/mock/official"); + await footerQR(page, origin + "/mock/official", primaryQR); + assert.equal( + await page.locator('[data-navigation-key="footer_download"]').getAttribute("href"), + origin + "/mock/official", + ); + await page.evaluate(() => { + const fixture = window as unknown as { + __fixture: { appConfig: Record; config: Record }; + __rerender: (data: Record) => void; + }; + fixture.__rerender({ + ...fixture.__fixture, + appConfig: { ...fixture.__fixture.appConfig, appAddr: "", officialAppAddr: "" }, + config: { ...fixture.__fixture.config, appAddr: "", officialAppAddr: "" }, + }); + }); + await page.waitForSelector(".hk-site-footer .hk-app-download-qr img[src=\"/static/brand/placeholder.svg\"]"); + assert.equal(await page.locator(".hk-site-footer .hk-app-download-qr canvas").count(), 0); + assert.equal( + await page.locator('[data-navigation-key="footer_download"]').getAttribute("href"), + "/app/download/", + ); + await page.evaluate(() => { + const fixture = window as unknown as { __fixture: Record; __rerender: (data: Record) => void }; + fixture.__rerender(fixture.__fixture); + }); + assert.equal(await footerQR(page, origin + "/mock/fallback"), primaryQR); + const input = page.locator("[data-header-search-input]"); + await input.focus(); + assert.equal( + await page.locator("[data-header-history-chips]").innerText(), + "历史词", + ); + await page.locator("[data-header-history-clear]").click(); + assert.equal(await page.locator("[data-header-search-history]").count(), 0); + await input.press("Escape"); + assert.equal( + await page.locator("[data-header-search-dropdown]").isVisible(), + false, + ); + await input.focus(); + await page.locator("#outside").click(); + assert.equal( + await page.locator("[data-header-search-dropdown]").isVisible(), + false, + ); + await input.fill("a/b"); + await page.waitForSelector(".hk-search-dropdown__feature"); + assert.equal( + await page.locator(".hk-search-dropdown__feature strong").textContent(), + "a/b作品", + ); + await input.press("ArrowDown"); + assert.equal( + await page.locator(".hk-search-dropdown__item.hk-is-active").count(), + 1, + ); + await input.press("Enter"); + await page.waitForURL(origin + "/search/a%2Fb/"); + assert.equal( + await page.evaluate(() => + JSON.parse(localStorage.getItem("hgdj_search_history") || "[]").includes( + "a/b", + ), + ), + true, + ); + await page.goto(origin + "/?user=1"); + await ready(page); + await page.evaluate(() => { + document.cookie = "token=isolated; Path=/"; + document.cookie = "avatar=v3%2Fimage%2Fguest.png; Path=/"; + document.dispatchEvent(new CustomEvent("auth:login")); + }); + await page.waitForFunction( + () => + document.querySelector("#display-avatar")?.getAttribute("data-value") === + "v3/image/guest.png", + ); + assert.equal( + await page.evaluate(() => + document.cookie.includes("avatar=v3%2Fimage%2Fguest.png"), + ), + true, + ); + await page.evaluate(() => { + document.cookie = + "avatar=%2Fstatic%2Fimages%2Fprofile%2Favatar.png; Path=/"; + document.dispatchEvent(new CustomEvent("auth:login")); + }); + await page.waitForFunction( + () => + document.querySelector("#display-avatar")?.getAttribute("data-value") === + "/static/images/profile/avatar.png", + ); + await page.evaluate(() => { + ( + window as unknown as { + __setAuthSession: (user: Record) => void; + } + ).__setAuthSession({ + token: "isolated", + userId: 12345, + type: 1, + nickName: "旧版注册用户", + }); + }); + await page.waitForFunction( + () => + document + .querySelector("#display-avatar") + ?.getAttribute("data-registered") === "true", + ); + assert.equal( + await page.evaluate(() => + document.cookie + .split("; ") + .find((value) => value.startsWith("isRegister=")), + ), + "isRegister=1", + "Client auth cookie registration matches the BFF type=1 fallback", + ); + // A new RSC props snapshot must replace retained client authentication state. + await page.evaluate(() => { + ( + window as unknown as { + __fixture: Record; + __rerender: (data: Record) => void; + } + ).__rerender({ + ...(window as unknown as { __fixture: Record }) + .__fixture, + userName: "RSC 更新用户", + recharge: 4321, + }); + }); + await page.waitForFunction( + () => + document.querySelector("#display-avatar")?.getAttribute("data-name") === + "RSC 更新用户", + ); + assert.equal(await page.locator("#display-avatar").getAttribute("data-balance"), "4321"); + assert.equal(await page.locator("[data-header-recharge], [data-user-menu-toggle]").count(), 0); + // PC download uses the real fallback and restores focus/scroll after Escape. + await page.locator(".hk-site-header__download").click(); + await page.locator("[data-download-pop]").waitFor(); + assert.equal( + await page.locator(".hk-app-download-dialog__download").getAttribute("href"), + origin + "/mock/fallback", + ); + await expectOverflow(page, "hidden"); + await page.keyboard.press("Shift+Tab"); + assert.equal( + await page + .locator(".hk-app-download-dialog__download") + .evaluate((el) => el === document.activeElement), + true, + ); + await page.keyboard.press("Tab"); + assert.equal( + await page + .locator(".hk-app-download-dialog__close") + .evaluate((el) => el === document.activeElement), + true, + ); + await closeGuide(page); + assert.equal( + await page + .locator(".hk-site-header__download") + .evaluate((el) => el === document.activeElement), + true, + ); + await page.locator('[data-navigation-key="footer_download"]').click(); + await page.locator("button[data-download-close]").click(); + await expectOverflow(page, ""); + await context.close(); + for (const device of ["ios", "android"] as const) + for (const configured of [true, false]) { + const mobile = await browser.newContext({ + viewport: { width: 375, height: 812 }, + isMobile: true, + deviceScaleFactor: 1, + userAgent: + device === "ios" + ? "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" + : "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/129.0.0.0 Mobile Safari/537.36", + }); + await configure(mobile, true); + const p = await mobile.newPage(); + await p.goto(origin + "/?configured=" + (configured ? "1" : "0")); + await ready(p); + await p.locator(".hk-site-header__download").click(); + const guide = p.locator("[data-install-guide]"); + await guide.waitFor(); + await p.waitForFunction( + (platform) => + document + .querySelector("[data-install-guide]") + ?.getAttribute("data-platform") === platform, + device, + ); + assert.equal(await p.locator("[data-download-pop]").count(), 0); + await expectOverflow(p, "hidden"); + const panel = device === "ios" ? "store" : "android"; + assert.equal( + await guide.locator(`[data-install-panel="${panel}"]`).isVisible(), + true, + ); + assert.equal( + await guide.locator("[data-install-tabs]").isVisible(), + device === "ios", + ); + assert.equal( + await guide + .locator(`[data-install-action="${panel}"]`) + .getAttribute("href"), + origin + + (configured + ? device === "ios" + ? "/mock/ios-store" + : "/mock/android.apk" + : "/mock/fallback"), + ); + await guide.locator(".hk-installation-guide__close").focus(); + await p.keyboard.press("Shift+Tab"); + assert.equal( + await guide + .locator( + configured + ? "[data-install-launch]" + : `[data-install-action="${panel}"]`, + ) + .evaluate((el) => el === document.activeElement), + true, + ); + await p.keyboard.press("Tab"); + assert.equal( + await guide + .locator(".hk-installation-guide__close") + .evaluate((el) => el === document.activeElement), + true, + ); + if (device === "ios") { + await guide.locator('[data-install-tab="store"]').focus(); + await p.keyboard.press("ArrowRight"); + assert.equal( + await guide + .locator('[data-install-tab="profile"]') + .getAttribute("aria-selected"), + "true", + ); + assert.equal( + await guide.locator('[data-install-panel="profile"]').isVisible(), + true, + ); + assert.equal( + await guide + .locator('[data-install-action="profile"]') + .getAttribute("href"), + origin + (configured ? "/mock/ios-profile" : "/mock/fallback"), + ); + await p.keyboard.press("Home"); + assert.equal( + await guide + .locator('[data-install-tab="store"]') + .getAttribute("aria-selected"), + "true", + ); + await p.keyboard.press("End"); + assert.equal( + await guide + .locator('[data-install-tab="profile"]') + .getAttribute("aria-selected"), + "true", + ); + } + const action = guide.locator( + `[data-install-action="${device === "ios" ? "profile" : "android"}"]`, + ), + downloadPromise = mobile.waitForEvent("page"); + await action.click(); + const download = await downloadPromise; + await download.waitForLoadState(); + assert.equal( + new URL(download.url()).pathname, + configured + ? device === "ios" + ? "/mock/ios-profile" + : "/mock/android.apk" + : "/mock/fallback", + ); + await download.close(); + await closeGuide(p); + assert.equal( + await p + .locator(".hk-site-header__download") + .evaluate((el) => el === document.activeElement), + true, + ); + // Drawer swaps to the download guide without leaving its scroll lock active. + await p.locator("[data-drawer-open]").click(); + await p.locator("[data-drawer]").waitFor(); + await expectOverflow(p, "hidden"); + assert.equal( + await p.locator("[data-drawer] :is(.hk-drawer__user, [data-login-open], [data-register-open], a[href^='/account'])").count(), + 0, + "Drawer has no user block or account links", + ); + if (device === "ios" && configured) { + const group = p.locator('[data-navigation-submenu="mobile"]'), + toggle = group.locator(".hk-drawer__nav-toggle"), + submenu = group.locator(".hk-drawer__submenu"); + assert.equal(await toggle.getAttribute("aria-expanded"), "false"); + assert.equal(await submenu.isVisible(), false); + assert.equal(await toggle.getAttribute("aria-controls"), await submenu.getAttribute("id")); + await toggle.click(); + await submenu.waitFor({ state: "visible" }); + assert.equal(await toggle.getAttribute("aria-expanded"), "true"); + const friendPromise = mobile.waitForEvent("page"); + await submenu.locator('a[data-navigation-name="黄果视频"]').click(); + const friend = await friendPromise; + await friend.waitForLoadState(); + assert.equal(friend.url(), "https://huangguo.com/"); + await friend.close(); + await p.waitForSelector("[data-drawer]", { state: "detached" }); + await expectOverflow(p, ""); + await p.locator("[data-drawer-open]").click(); + await toggle.click(); + await submenu.waitFor({ state: "visible" }); + await toggle.click(); + await submenu.waitFor({ state: "hidden" }); + await toggle.click(); + await p.setViewportSize({ width: 375, height: 480 }); + await submenu.locator("a").last().scrollIntoViewIfNeeded(); + assert.equal(await submenu.locator("a").last().isVisible(), true); + assert.equal(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + await p.setViewportSize({ width: 375, height: 812 }); + await submenu.locator('a[data-navigation-name="爱情专题"]').click(); + await p.waitForURL(origin + "/collection/romance/"); + await ready(p); + assert.equal(await p.locator("[data-drawer]").count(), 0); + await expectOverflow(p, ""); + await p.locator("[data-drawer-open]").click(); + await p.locator("[data-drawer]").waitFor(); + } + await p.keyboard.press("Escape"); + await p.waitForSelector("[data-drawer]", { state: "detached" }); + assert.equal( + await p + .locator("[data-drawer-open]") + .evaluate((el) => el === document.activeElement), + true, + ); + await expectOverflow(p, ""); + await p.locator("[data-drawer-open]").click(); + if (device === "ios" && configured) { + const group = p.locator('[data-navigation-submenu="mobile"]'); + await group.locator(".hk-drawer__nav-toggle").click(); + await group.locator(".hk-drawer__submenu [data-download-open]").click(); + } else await p.locator(".hk-drawer__links [data-download-open]").click(); + await p.waitForSelector("[data-drawer]", { state: "detached" }); + await guide.waitFor(); + await expectOverflow(p, "hidden"); + await closeGuide(p); + assert.equal( + await p + .locator("[data-drawer-open]") + .evaluate((el) => el === document.activeElement), + true, + ); + await p.locator('[data-navigation-key="footer_download_mobile"]').click(); + await guide.waitFor(); + await guide.locator(".hk-installation-guide__close").click(); + await expectOverflow(p, ""); + await p.locator(".hk-app-cta__download").click(); + await guide.waitFor(); + await closeGuide(p); + // Original installation-guide behavior closes across the desktop breakpoint. + await p.locator("body").evaluate((el) => (el.style.overflow = "clip")); + await p.locator(".hk-app-cta__download").click(); + await guide.waitFor(); + await p.setViewportSize({ width: 1024, height: 900 }); + await p.waitForSelector("[data-install-guide], [data-download-pop]", { + state: "detached", + timeout: 5000, + }); + await expectOverflow(p, "clip"); + await p.locator("body").evaluate((el) => (el.style.overflow = "")); + await p.setViewportSize({ width: 375, height: 812 }); + if (configured) { + await p.locator(".hk-site-header__download").click(); + await guide.locator("[data-install-launch]").click(); + await p.waitForURL(origin + "/mock/launch"); + } + assert.equal( + await p.evaluate( + () => document.documentElement.scrollWidth > innerWidth, + ), + false, + ); + await mobile.close(); + } + // Navigation has no site header/age/ad gate, but its download entries work. + const navContext = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + }); + await configure(navContext); + const nav = await navContext.newPage(); + await nav.goto(origin + "/navigation/"); + await nav.waitForSelector(".hk-site-navigation-app"); + assert.equal( + await nav + .locator(".hk-site-header,.hk-site-footer,[data-eighteen],[data-dialog-ad]") + .count(), + 0, + ); + await nav.locator(".hk-site-navigation-app").click(); + await nav.locator("[data-download-pop]").waitFor(); + await closeGuide(nav); + await nav.setViewportSize({ width: 375, height: 812 }); + await nav.locator(".hk-site-navigation-icon[data-download-open]").click(); + await nav.locator("[data-install-guide]").waitFor(); + await closeGuide(nav); + await navContext.close(); + const underage = await browser.newContext(); + await configure(underage); + const exit = await underage.newPage(); + await exit.goto(origin); + await exit.locator("[data-eighteen-exit]").click(); + await exit.waitForURL("https://www.google.com/"); + assert.equal(exit.url(), "https://www.google.com/"); + await underage.close(); + assert.deepEqual(errors, []); + assert.deepEqual(missing, []); + assert.deepEqual(unexpected, []); + assert.ok(destinations.includes("/mock/ad-1")); + assert.ok(destinations.includes("/mock/ios-profile")); + 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.", + ); +} finally { + releaseImage?.(); + await browser?.close(); + await new Promise((done) => server.close(() => done())); + await rm(temporary, { recursive: true, force: true }); +} diff --git a/scripts/native-ssr-smoke.ts b/scripts/native-ssr-smoke.ts new file mode 100644 index 0000000..0b11759 --- /dev/null +++ b/scripts/native-ssr-smoke.ts @@ -0,0 +1,1118 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { createServer, type IncomingHttpHeaders } from "node:http"; +import { + mkdir, + readFile, + readdir, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { gunzipSync, gzipSync } from "node:zlib"; +import { chromium, type Page } from "@playwright/test"; + +// Test the optimized App Router response, not a renderer or a development server. +// All authentication, content and mutations terminate at this loopback mock. +type RecordData = Record; +interface UpstreamCall { + case: string; + path: string; + headers: IncomingHttpHeaders; + body: RecordData; + cookies: string[]; +} +const output = process.env.NATIVE_SSR_OUTPUT || "/tmp/hkdj-native-ssr-smoke"; +const calls: UpstreamCall[] = []; +const results: RecordData[] = []; + +async function expectPageStyles(page: Page, required: string[], absent: string[] = []) { + const tokens = new Set((await page.locator("html").getAttribute("data-hk-page-styles") || "").split(/\s+/)); + for (const token of required) assert.ok(tokens.has(token), `Missing page style scope: ${token}`); + for (const token of absent) assert.ok(!tokens.has(token), `Inactive page style scope: ${token}`); + assert.equal(await page.locator('link[rel="stylesheet"][href^="/static/css/"]').count(), 0); + assert.ok(await page.locator('link[rel="stylesheet"][href*="/_next/"][href$=".css"]').count() > 0, "Next serves compiled CSS chunks"); +} +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); +const sitemapDir = join(output, "sitemaps"); +const handoffDir = join( + tmpdir(), + "huakui-next-ssr", + createHash("sha256") + .update(process.cwd() + "|production") + .digest("hex") + .slice(0, 16), +); +let previewLogs = ""; +let browser: Awaited> | undefined; +let navigationPreview: ReturnType | undefined; +let fixtureSequence = 0; +let bigList = false; +let listUnavailable = false; +// The account system was removed; SSR and the BFF must never reach these upstream APIs. +const removedUpstream = [ + "/api/app/auth/login", + "/api/app/auth/register", + "/api/app/auth/logout", + "/api/app/user/center/data", + "/api/app/playlet/history/list", + "/api/app/playlet/collect/list", + "/api/app/recharge/package/list", + "/api/app/comment/send", + "/api/app/comment/list", + "/api/app/coin/buy", + "/api/app/playlet/chapter/unlock", +]; +const removedCalls = (caseName?: string) => + calls.filter( + (call) => + (!caseName || call.case === caseName) && + removedUpstream.some((path) => call.path.split("?")[0] === path), + ); + +function user(token: string) { + const guest = token.startsWith("guest-"); + const suffix = token.replace(/[^a-zA-Z0-9]/g, ""); + return { + userId: guest ? 900 : token === "registered-B" ? 802 : 801, + token, + accountNo: "account_" + suffix, + nickName: "用户_" + suffix, + isRegister: !guest, + type: guest ? 0 : 1, + hasPassword: !guest, + recharge: guest ? 18 : 88, + avatar: "", + vipType: 0, + currentVipExpire: "", + }; +} +function work(id = 41, title = "隔离短剧") { + return { + id, + slug: "isolated-" + id, + title, + playName: title, + cover: "/static/favicon.png", + totalEpisode: 2, + updateStatus: 2, + countBrowse: 48, + countLike: 4, + countCollect: 2, + status: 1, + themeNames: ["都市"], + themeIds: [1], + themeSlugs: ["city"], + tagNames: ["剧情"], + tagIds: [1], + tagSlugs: ["plot"], + }; +} +function readToken(headers: IncomingHttpHeaders): string { + return String(headers.authorization || ""); +} +function nameFor(token: string): string { + return user(token).nickName; +} +const mock = createServer(async (req, res) => { + try { + const url = new URL(req.url || "/", "http://upstream"); + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const body = JSON.parse( + Buffer.concat(chunks).toString() || "{}", + ) as RecordData; + const caseName = String(req.headers["x-smoke-case"] || "unmarked"); + const sequence = ++fixtureSequence; + const cookies = [ + `wire_${sequence}=${caseName}; Path=/wire/${sequence}; HttpOnly; SameSite=Lax`, + `wire_root_${sequence}=ok; Path=/; Expires=Wed, 21 Oct 2037 07:28:00 GMT; SameSite=Lax`, + ]; + calls.push({ + case: caseName, + path: url.pathname + url.search, + headers: { ...req.headers }, + body, + cookies, + }); + res.setHeader("Set-Cookie", cookies); + const send = (data: unknown = {}, code = 200, status = 200) => { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + code, + data, + tip: code === 200 ? "" : "隔离测试上游失败", + }), + ); + }; + const token = readToken(req.headers); + if (url.pathname === "/api/app/auth/guest") { + send(user("guest-" + caseName)); + return; + } + if (url.pathname === "/api/app/auth/getInfo") { + send(user(token)); + return; + } + if (url.pathname === "/api/app/config/info") { + send({ + config: { + appAddr: "https://download.example.test/real-entry", + gmailAddr: "support@example.test", + foreverUrlAddr: canonical + "/ignored/path?tracking=1", + }, + newAddressInfo: {}, + seoInfo: { + title: "花魁短剧", + description: "隔离站点描述", + keywords: ["花魁短剧", "短剧"], + webSiteName: "花魁短剧", + canonicalUrl: canonical, + }, + seoNavInfo: {}, + navPageList: [], + advitise: {}, + reportSdkConfig: "", + }); + return; + } + if (url.pathname === "/api/app/translation/category/list") { + send({ + categoryList: [ + { name: "首页", urlValue: "/", categoryType: 1 }, + { name: "短剧", urlValue: "/list/", categoryType: 1 }, + ], + }); + return; + } + if (url.pathname === "/api/app/theme/list") { + send({ + list: [{ id: 1, name: "都市", slug: "city", status: true, rank: 1 }], + }); + return; + } + if (url.pathname === "/api/app/playlet/home") { + send({ + seoInfo: { + title: "SSR首页标题", + description: "SSR首页描述", + keywords: ["首屏", "短剧"], + }, + announcements: [], + homeBannerData: [], + homeConfigData: [], + advitise: {}, + }); + return; + } + if (url.pathname === "/api/app/playlet/hot-like") { + send({ hotSearch: [], guessLike: [] }); + return; + } + if (url.pathname === "/api/app/playlet/tag/options") { + send({ list: [] }); + return; + } + if (url.pathname === "/api/app/playlet/all/list") { + if (listUnavailable && Number(url.searchParams.get("pageSize")) === 24) { + send({}, 503, 503); + return; + } + const list = + bigList && Number(url.searchParams.get("pageSize")) === 24 + ? Array.from({ length: 24 }, (_, i) => + work( + 100 + i, + `大快照${i}_` + randomBytes(1100).toString("base64url"), + ), + ) + : [work(), work(42, "隔离短剧二")]; + send({ + list, + pageNum: Number(url.searchParams.get("pageNum") || 1), + pageSize: Number(url.searchParams.get("pageSize") || 24), + totalCount: 72, + seoInfo: { + title: "短剧列表专属标题", + description: "短剧列表专属描述", + keywords: ["列表测试"], + }, + }); + return; + } + if (url.pathname === "/api/app/playlet/detail") { + if (url.searchParams.get("slug") === "missing") { + send({}); + return; + } + if (caseName === "recover" && token === "expired-content") { + send({}, 1002); + return; + } + send({ + ...work(), + slug: "isolated", + desc: "播放页真实剧情简介", + onlineAt: "2026-10-01 10:00:00", + preview: "/preview.mp4", + playletChapterInfo: [ + { id: 401, currentEpisode: 1, payType: 0, canWatch: true }, + { id: 402, currentEpisode: 2, payType: 0, canWatch: true }, + ], + relatedRecommendData: [], + seoInfo: { + title: "作品标题", + description: "作品描述", + keywords: ["作品测试"], + }, + advitise: {}, + }); + return; + } + if (url.pathname === "/api/app/playlet/chapter/detail") { + send({ + id: Number(url.searchParams.get("chapterId") || 401), + playletId: 41, + currentEpisode: + Number(url.searchParams.get("chapterId")) === 402 ? 2 : 1, + canWatch: true, + payType: 0, + videoUrl: "/signed/isolated.m3u8?token=preserved", + mediaTime: 12, + balance: 18, + countLike: 4, + countCollect: 2, + }); + return; + } + if (url.pathname === "/api/app/playlet/search") { + send({ + keyword: url.searchParams.get("keyword"), + list: [work(), work(42)], + totalCount: 2, + pageNum: 1, + pageSize: 25, + }); + return; + } + send({ list: [], totalCount: 0, pageNum: 1, pageSize: 4 }); + } catch (error) { + res.statusCode = 500; + res.end(JSON.stringify({ code: 500, tip: String(error) })); + } +}); + +await mkdir(sitemapDir, { recursive: true }); +await writeFile( + join(sitemapDir, "sitemap.xml"), + `${canonical}/sitemaps/sitemap-content.xml\n`, +); +await writeFile( + join(sitemapDir, "sitemap-content.xml"), + `${canonical}/drama/isolated/`, +); +await writeFile(verifyPath, "native-ssr-verification-file"); +await new Promise((done) => mock.listen(0, "127.0.0.1", done)); +const bound = mock.address(); +if (!bound || typeof bound === "string") + throw new Error("Mock address unavailable"); +const reserve = createServer(); +await new Promise((done) => reserve.listen(0, "127.0.0.1", done)); +const reserved = reserve.address(); +if (!reserved || typeof reserved === "string") + throw new Error("Preview address unavailable"); +const port = reserved.port; +await new Promise((done) => reserve.close(() => done())); +const origin = `http://127.0.0.1:${port}`; +const preview = spawn( + process.execPath, + [ + resolve("node_modules/next/dist/bin/next"), + "start", + "--port", + String(port), + "--hostname", + "127.0.0.1", + ], + { + env: { + ...process.env, + NODE_ENV: "production", + APP_ENV: "production", + UPSTREAM_API_URL: `http://127.0.0.1:${bound.port}`, + UPSTREAM_TIMEOUT_MS: "3000", + SEO_BAIDU_SITE_VERIFICATION: "native-ssr-baidu-code", + SEO_BAIDU_HTML_FILE: verifyFilename, + SEO_INDEXNOW_KEY: indexNow, + SITEMAP_DIR: sitemapDir, + NAVIGATION_ONLY: "false", + NEXT_TELEMETRY_DISABLED: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }, +); +preview.stdout?.on("data", (chunk) => { + previewLogs += String(chunk); +}); +preview.stderr?.on("data", (chunk) => { + previewLogs += String(chunk); +}); + +function headers(caseName: string, token = ""): Headers { + return new Headers({ + "x-smoke-case": caseName, + "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`, + } + : {}), + }); +} +function caseCalls(caseName: string): UpstreamCall[] { + return calls.filter((call) => call.case === caseName); +} +function cookiesForwarded(response: Response, caseName: string) { + const actual = response.headers.getSetCookie(); + for (const call of caseCalls(caseName)) + for (const cookie of call.cookies) { + const pair = cookie.split(";")[0]; + assert.ok( + actual.some((item) => item.split(";")[0] === pair), + `${caseName}: missing upstream Set-Cookie ${pair}`, + ); + } + assert.equal( + actual.some( + (cookie) => + cookie.startsWith("wire_root_") && !cookie.includes("21 Oct 2037"), + ), + false, + "Expires comma must not split cookies", + ); +} +function noInternalLeak(response: Response, body: string) { + for (const name of response.headers.keys()) + assert.equal( + /^x-hk-|^x-middleware-request-x-hk-/i.test(name), + false, + "Internal response header " + name, + ); + assert.equal( + /x-hk-page(?:-id)?["':=]/i.test(body), + false, + "Internal snapshot serialized to HTML", + ); +} +function noDuplicateBuild(caseName: string, allowedRecovery = false) { + const counts = new Map(); + for (const call of caseCalls(caseName)) + counts.set(call.path, (counts.get(call.path) || 0) + 1); + for (const [path, count] of counts) + assert.equal( + count, + allowedRecovery && path.startsWith("/api/app/playlet/detail?") ? 2 : 1, + `${caseName}: duplicate upstream ${path}`, + ); +} +function meta(html: string, name: string): string { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return ( + html.match( + new RegExp( + `]+(?:name|property)="${escaped}"[^>]+content="([^"]*)"`, + "i", + ), + )?.[1] || "" + ); +} +function link(html: string, rel: string): string { + return ( + html.match( + new RegExp(`]+rel="${rel}"[^>]+href="([^"]*)"`, "i"), + )?.[1] || "" + ); +} +function graphs(html: string): RecordData[] { + return [ + ...html.matchAll( + /]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi, + ), + ].flatMap((match) => { + const parsed = JSON.parse(match[1]); + return parsed["@graph"] || [parsed]; + }); +} +async function request( + caseName: string, + path: string, + token = "", + init: RequestInit = {}, +) { + const requestHeaders = headers(caseName, token); + new Headers(init.headers).forEach((value, key) => + requestHeaders.set(key, value), + ); + const response = await fetch(origin + path, { + ...init, + headers: requestHeaders, + redirect: "manual", + }); + const html = await response.text(); + if (!["/robots.txt", "/llms.txt"].includes(path)) cookiesForwarded(response, caseName); + noInternalLeak(response, html); + return { response, html }; +} +async function files(): Promise { + try { + return (await readdir(handoffDir)).filter((name) => + /^[a-f0-9]{32}\.json\.gz$/.test(name), + ); + } catch { + return []; + } +} + +try { + const deadline = Date.now() + 30000; + while (true) { + if (preview.exitCode !== null) + throw new Error("Next exited: " + previewLogs); + try { + const health = await fetch(origin + "/healthz"); + if (health.ok) break; + } catch {} + if (Date.now() > deadline) + throw new Error("Next did not start: " + previewLogs); + await new Promise((done) => setTimeout(done, 150)); + } + console.log("native SSR: initial guest and fresh registered context"); + const guest = await request("guest", "/drama/isolated/?dc=smoke-first"); + assert.equal(guest.response.status, 200); + assert.ok(guest.html.includes(nameFor("guest-guest"))); + assert.equal(guest.html.includes("STALE_COOKIE"), false); + assert.equal( + caseCalls("guest").filter((call) => call.path === "/api/app/auth/guest") + .length, + 1, + ); + assert.ok( + guest.response.headers + .getSetCookie() + .some((cookie) => cookie.startsWith("token=guest-guest;")), + ); + assert.ok( + guest.response.headers + .getSetCookie() + .some((cookie) => cookie.startsWith("channel=smoke-first;")), + ); + noDuplicateBuild("guest"); + assert.deepEqual(removedCalls("guest"), [], "Playback SSR no longer loads recharge packages"); + const registered = await request("registered", "/dramas/", "registered-A"); + assert.equal(registered.response.status, 200); + assert.ok(registered.html.includes(nameFor("registered-A"))); + assert.equal(registered.html.includes("STALE_COOKIE"), false); + noDuplicateBuild("registered"); + for (const call of caseCalls("registered")) { + assert.equal(call.headers["x-real-ip"], "203.0.113.42"); + assert.equal(call.headers["user-agent"], userAgent); + assert.equal( + 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, + ); + const privateRequest = /\/auth\//.test( + call.path, + ); + if (privateRequest) { + assert.equal(call.headers.authorization, "registered-A"); + assert.ok( + String(call.headers.cookie).includes("client_cookie=must_forward"), + ); + } else { + assert.equal(call.headers.authorization, undefined); + assert.equal(call.headers.cookie, undefined); + } + } + results.push({ + initialGuest: true, + freshUser: true, + completeUpstreamCookies: true, + singleBuild: true, + headerForwarding: true, + publicAuthIsolation: true, + }); + + console.log("native SSR: content session recovery before first byte"); + const recover = await request( + "recover", + "/drama/isolated/", + "expired-content", + ); + assert.equal(recover.response.status, 200); + assert.ok(recover.html.includes(nameFor("guest-recover"))); + assert.equal(recover.html.includes(nameFor("expired-content")), false); + assert.ok( + recover.response.headers + .getSetCookie() + .some((cookie) => cookie.startsWith("token=guest-recover;")), + ); + assert.equal( + caseCalls("recover").filter((call) => call.path === "/api/app/auth/guest") + .length, + 1, + ); + noDuplicateBuild("recover", true); + const detailCalls = caseCalls("recover").filter((call) => + call.path.startsWith("/api/app/playlet/detail?"), + ); + assert.deepEqual( + detailCalls.map((call) => call.headers.authorization), + ["expired-content", "guest-recover"], + ); + const chapterCall = caseCalls("recover").find((call) => + call.path.startsWith("/api/app/playlet/chapter/detail?"), + ); + assert.equal(chapterCall?.headers.authorization, "guest-recover"); + assert.ok( + String(chapterCall?.headers.cookie).includes("token=guest-recover"), + ); + results.push({ contentGuestRecovery: true, recoveryCookiesBeforeSSR: true }); + + console.log( + "native SSR: large file handoff and concurrent account isolation", + ); + const before = new Set(await files()); + bigList = true; + const large = await request("large", "/dramas/", "registered-A"); + bigList = false; + assert.equal(large.response.status, 200); + assert.ok(large.html.includes("大快照23_")); + noDuplicateBuild("large"); + const created = (await files()).filter((name) => !before.has(name)); + assert.ok(created.length > 0, "Large DTO must use file handoff"); + const largeFile = created[0]; + const payload = JSON.parse( + gunzipSync(await readFile(join(handoffDir, largeFile))).toString(), + ); + assert.equal(payload.page.view, "list"); + assert.equal(payload.page.data.userName, nameFor("registered-A")); + assert.equal((await stat(join(handoffDir, largeFile))).mode & 0o777, 0o600); + assert.equal((await stat(handoffDir)).mode & 0o777, 0o700); + const forged = gzipSync( + JSON.stringify({ + v: 1, + expires: Date.now() + 60000, + binding: "fake", + page: { view: "list", data: { userName: "FORGED_PRIVATE_USER" } }, + }), + ).toString("base64url"); + const concurrent = await Promise.all( + Array.from({ length: 12 }, (_, i) => + request( + "parallel-" + i, + "/dramas/", + i % 2 ? "registered-B" : "registered-A", + { + headers: { + "x-hk-page-id": largeFile.slice(0, -8), + "x-hk-page": forged, + "x-hk-url": "https://attacker.test/private", + }, + }, + ), + ), + ); + for (let i = 0; i < concurrent.length; i++) { + const expected = nameFor(i % 2 ? "registered-B" : "registered-A"), + other = nameFor(i % 2 ? "registered-A" : "registered-B"); + assert.equal(concurrent[i].response.status, 200); + assert.ok(concurrent[i].html.includes(expected)); + assert.equal(concurrent[i].html.includes(other), false); + assert.equal(concurrent[i].html.includes("FORGED_PRIVATE_USER"), false); + noDuplicateBuild("parallel-" + i); + } + results.push({ + largeFileHandoff: true, + fileMode: "0600", + directoryMode: "0700", + parallelAccounts: 12, + forgedSnapshotRejected: true, + internalHeadersHidden: true, + }); + const rsc = await request("rsc", "/dramas/?_rsc=", "registered-B", { + headers: { RSC: "1" }, + }); + assert.equal( + rsc.response.status, + 200, + JSON.stringify(Object.fromEntries(rsc.response.headers)), + ); + assert.ok( + rsc.response.headers.get("content-type")?.includes("text/x-component"), + ); + assert.ok(rsc.html.includes(nameFor("registered-B"))); + assert.equal(rsc.html.includes(nameFor("registered-A")), false); + noDuplicateBuild("rsc"); + for (const call of caseCalls("rsc")) { + assert.equal(call.headers.rsc, undefined); + assert.equal(call.headers["next-router-prefetch"], undefined); + assert.equal(call.headers["next-url"], undefined); + } + const head = await request("head", "/dramas/", "registered-A", { + method: "HEAD", + }); + assert.equal(head.response.status, 200); + assert.equal(head.html, ""); + noDuplicateBuild("head"); + results.push({ rscNavigationContext: true, headCookies: true }); + + console.log("native SSR: exact redirect, metadata and discovery contract"); + for (const [caseName, path, method, status, location] of [ + [ + "redirect301", + "/dramas?filter=preserved", + "GET", + 301, + origin + "/dramas/?filter=preserved", + ], + [ + "legacy-alias301", + "/play/story%2Fa?sign=a+b&sign=%2b", + "GET", + 301, + origin + "/drama/story%2Fa/?sign=a+b&sign=%2b", + ], + // Removed account pages (legacy and public) permanently return home without a query. + ["legacy-account301", "/setting/", "HEAD", 301, origin + "/"], + ["legacy-login301", "/login/?next=%2Fprofile", "GET", 301, origin + "/"], + ["account301", "/account/", "GET", 301, origin + "/"], + ["account-settings301", "/account/settings/", "GET", 301, origin + "/"], + ["account-recharge301", "/account/recharge/?pkg=1", "HEAD", 301, origin + "/"], + ["account-login301", "/account/login", "GET", 301, origin + "/"], + [ + "redirect307", + "/api/feedback/add/?dc=preserved", + "POST", + 307, + origin + "/api/feedback/add?dc=preserved", + ], + ["redirect302", "/?dc=attribution", "GET", 302, origin + "/"], + [ + "download302", + "/app/download/", + "GET", + 302, + "https://download.example.test/real-entry", + ], + ] as const) { + const { response } = await request(caseName, path, "", { method }); + assert.equal(response.status, status); + assert.equal( + new URL(response.headers.get("location") || "", origin).href, + location, + ); + if (caseName !== "download302") { + assert.equal(caseCalls(caseName).length, 0); + assert.equal(response.headers.getSetCookie().length, 0); + } + } + for (const [index, pathname] of [ + "/api/auth/login", + "/api/auth/register", + "/api/auth/logout", + "/api/playlet/collect/set", + "/api/playlet/like/set", + "/api/comment/send", + "/api/comment/list", + "/api/playlet/chapter/unlock", + "/api/coin/buy", + ].entries()) { + const removedAPI = await request("removed-api-" + index, pathname, "registered-A", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + assert.equal(removedAPI.response.status, 404, "Removed BFF route " + pathname); + const upstreamPath = "/api/app" + pathname.slice("/api".length); + assert.equal( + caseCalls("removed-api-" + index).some((call) => call.path.split("?")[0] === upstreamPath), + false, + "Removed BFF route " + pathname + " reached upstream", + ); + assert.deepEqual(removedCalls("removed-api-" + index), []); + } + const home = await request("home", "/", "registered-A"); + assert.equal(home.response.status, 200); + assert.equal(home.html.includes("继续观看"), false, "Home no longer renders viewing history"); + assert.deepEqual(removedCalls("home"), [], "Home no longer fetches viewing history"); + for (const marker of ["data-login-open", "hk-user-menu", 'href="/account/']) + assert.equal(home.html.includes(marker), false, "Home still renders account entry " + marker); + assert.ok(home.html.includes("SSR首页标题 - 花魁短剧")); + assert.equal(link(home.html, "canonical"), canonical + "/"); + assert.equal(meta(home.html, "description"), "SSR首页描述"); + assert.equal(meta(home.html, "og:locale"), "zh_CN"); + assert.equal(meta(home.html, "twitter:card"), "summary_large_image"); + assert.equal( + meta(home.html, "google-site-verification"), + "Y_ldHPiw-yBVgVw_jZ4UG5WJw-xjO31mBMfhflqMGw8", + ); + assert.equal( + meta(home.html, "baidu-site-verification"), + "native-ssr-baidu-code", + ); + assert.ok(graphs(home.html).some((node) => node["@type"] === "WebSite")); + assert.ok(graphs(home.html).some((node) => node["@type"] === "Organization")); + const list = await request( + "page2", + "/dramas/all/0/1/page/2/?dc=discard-canonical", + "registered-A", + ); + assert.equal(list.response.status, 200); + assert.equal( + link(list.html, "canonical"), + canonical + "/dramas/all/0/1/page/2/", + ); + assert.equal(link(list.html, "prev"), canonical + "/dramas/"); + assert.equal(link(list.html, "next"), canonical + "/dramas/all/0/1/page/3/"); + assert.ok(list.html.includes("第2页")); + assert.ok(graphs(list.html).some((node) => node["@type"] === "ItemList")); + noDuplicateBuild("page2"); + const episode = await request( + "episode", + "/drama/isolated/402/", + "registered-A", + ); + assert.equal(episode.response.status, 200); + assert.ok(meta(episode.html, "robots").includes("noindex")); + assert.equal(link(episode.html, "canonical"), ""); + assert.equal(graphs(episode.html).length, 0); + for (const [caseName, path] of [ + ["missing", "/unrecognized-native-route/"], + ["missing-work", "/drama/missing/"], + ["bad-page", "/dramas/all/0/1/page/100/"], + ["unknown-api", "/api/native-smoke-unknown"], + ["unknown-media", "/h5/native-smoke-unknown/"], + ] as const) { + const missing = await request(caseName, path, "registered-A"); + assert.equal(missing.response.status, 404); + assert.ok(meta(missing.html, "robots").includes("noindex")); + assert.equal(link(missing.html, "canonical"), ""); + assert.equal(graphs(missing.html).length, 0); + noDuplicateBuild(caseName); + } + const missingSitemap = await request("unknown-sitemap", "/sitemaps/sitemap-missing.xml", "registered-A"); + assert.equal(missingSitemap.response.status, 404); + assert.match(missingSitemap.response.headers.get("x-robots-tag") || "", /noindex/); + assert.match(missingSitemap.response.headers.get("content-type") || "", /text\/html/); + assert.ok(meta(missingSitemap.html, "robots").includes("noindex")); + assert.equal(link(missingSitemap.html, "canonical"), ""); + assert.equal(graphs(missingSitemap.html).length, 0); + noDuplicateBuild("unknown-sitemap"); + listUnavailable = true; + const unavailable = await request("unavailable", "/dramas/", "registered-A"); + listUnavailable = false; + assert.equal( + unavailable.response.status, + 503, + "Temporary upstream failure must retain a 503 HTML status", + ); + assert.ok(meta(unavailable.html, "robots").includes("noindex")); + assert.equal(link(unavailable.html, "canonical"), ""); + assert.equal(graphs(unavailable.html).length, 0); + assert.ok(unavailable.response.headers.get("retry-after")); + noDuplicateBuild("unavailable"); + const robots = await request("robots", "/robots.txt"); + assert.equal(robots.response.status, 200); + assert.ok(robots.html.includes("Sitemap: " + canonical + "/sitemap.xml")); + assert.ok(robots.html.includes("Host: canonical.drama.test")); + assert.equal(/Disallow: \/(?:profile|search|login)/.test(robots.html), false); + assert.equal(robots.response.headers.getSetCookie().length, 0); + assert.equal(robots.response.headers.get("cache-control"), null); + assert.equal(robots.response.headers.get("content-length"), String(Buffer.byteLength(robots.html))); + const llms = await request("llms", "/llms.txt"); + assert.equal(llms.response.status, 200); + assert.ok(llms.html.includes("# 花魁短剧")); + assert.ok(llms.html.includes(canonical + "/dramas/")); + assert.equal(llms.response.headers.getSetCookie().length, 0); + for (const path of ["/sitemap.xml", "/sitemaps/sitemap-content.xml"]) { + const xml = await request("sitemap" + path, path); + assert.equal(xml.response.status, 200); + assert.ok( + xml.response.headers.get("content-type")?.includes("text/xml"), + ); + assert.ok(xml.response.headers.get("last-modified")); + assert.equal(xml.response.headers.get("accept-ranges"), "bytes"); + assert.equal(xml.response.headers.get("content-length"), String(Buffer.byteLength(xml.html))); + assert.equal(xml.response.headers.get("cache-control"), null); + assert.equal(xml.response.headers.getSetCookie().length, 0); + assert.ok(xml.html.includes(canonical)); + assert.equal(caseCalls("sitemap" + path).length, 0); + } + const originalXML = await readFile(join(sitemapDir, "sitemap.xml"), "utf8"); + const fullXML = await request("sitemap-bytes", "/sitemap.xml?sig=a%2Bb%3D"); + assert.equal(fullXML.html, originalXML, "Go serves generated XML unchanged"); + const modified = fullXML.response.headers.get("last-modified")!; + const unchangedXML = await request("sitemap304", "/sitemap.xml", "", { headers: { "If-Modified-Since": modified } }); + assert.equal(unchangedXML.response.status, 304); + assert.equal(unchangedXML.html, ""); + assert.equal(unchangedXML.response.headers.get("content-type"), null); + const partXML = await request("sitemap206", "/sitemap.xml", "", { headers: { Range: "bytes=0-31" } }); + assert.equal(partXML.response.status, 206); + assert.equal(partXML.html, originalXML.slice(0, 32)); + assert.equal(partXML.response.headers.get("content-range"), `bytes 0-31/${Buffer.byteLength(originalXML)}`); + const multipartXML = await request("sitemap-multipart", "/sitemap.xml", "", { headers: { Range: "bytes=0-2,8-10" } }); + assert.equal(multipartXML.response.status, 206); + assert.match(multipartXML.response.headers.get("content-type") || "", /multipart\/byteranges; boundary=/); + assert.ok(multipartXML.html.includes(originalXML.slice(0, 3))); + const invalidRange = await request("sitemap416", "/sitemap.xml", "", { headers: { Range: "bytes=999999-" } }); + assert.equal(invalidRange.response.status, 416); + assert.equal(invalidRange.response.headers.get("content-range"), `bytes */${Buffer.byteLength(originalXML)}`); + const fileHEAD = await request("sitemap-head", "/sitemap.xml", "", { method: "HEAD" }); + assert.equal(fileHEAD.response.status, 200); + assert.equal(fileHEAD.html, ""); + assert.equal(fileHEAD.response.headers.get("content-length"), String(Buffer.byteLength(originalXML))); + const conditionalHEAD = await request("sitemap-head304", "/sitemap.xml", "", { method: "HEAD", headers: { "If-Modified-Since": modified } }); + assert.equal(conditionalHEAD.response.status, 304); + assert.equal(conditionalHEAD.response.headers.get("content-length"), null); + for (const [index, pathname] of ["/site%6Daps/sitemap-content.xml", "/sitemaps%2Fsitemap-content.xml", "/sitemap%2Exml", "/robot%73.txt"].entries()) { + const encoded = await request("discovery-encoded-" + index, pathname); + assert.equal(encoded.response.status, 200, pathname); + assert.match(encoded.response.headers.get("content-type") || "", pathname.includes("robot") ? /text\/plain/ : /text\/xml/); + assert.equal(encoded.response.headers.getSetCookie().length, 0); + assert.equal(encoded.html, pathname.includes("robot") ? robots.html : await readFile(join(sitemapDir, pathname.includes("content") ? "sitemap-content.xml" : "sitemap.xml"), "utf8")); + } + for (const [index, pathname] of ["/sitemap.xml%2F", "/sitemaps/sitemap-content.xml%2F", "/site%6Daps/sitemap-content.xml/"].entries()) { + const redirected = await request("discovery-encoded-tail-" + index, pathname + "?sig=a%2Bb%3D"); + assert.equal(redirected.response.status, 301, pathname); + assert.equal(new URL(redirected.response.headers.get("location")!, origin).href, origin + decodeURIComponent(pathname).slice(0, -1) + "?sig=a%2Bb%3D"); + assert.equal(redirected.response.headers.getSetCookie().length, 0); + } + for (const [index, pathname] of ["/robots.txt", "/llms.txt", "/sitemap.xml", "/sitemaps/invalid.xml"].entries()) { + for (const [methodIndex, method] of ["POST", "PUT", "DELETE", "OPTIONS"].entries()) { + const failed = await request(`discovery-method-${index}-${methodIndex}`, pathname, "registered-A", { method, headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "test=1" }); + assert.equal(failed.response.status, 404, method + " " + pathname); + assert.equal(failed.response.headers.get("allow"), "GET"); + assert.match(failed.response.headers.get("content-type") || "", /text\/html/); + assert.ok(meta(failed.html, "robots").includes("noindex")); + assert.equal(link(failed.html, "canonical"), ""); + assert.equal(graphs(failed.html).length, 0); + } + } + for (const [index, pathname] of ["/robots.txt/", "/sitemap.xml/", "/sitemaps/invalid.xml/", "/sitemaps/sitemap-a%2F.xml", "/sitemaps/sitemap-content%252Exml"].entries()) { + const missing = await request("discovery-invalid-" + index, pathname, "registered-A", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: "test=1" }); + assert.equal(missing.response.status, 404, pathname); + assert.match(missing.response.headers.get("content-type") || "", /text\/html/); + assert.equal(missing.response.headers.get("allow"), pathname.includes("%252E") ? "GET" : null); + assert.ok(meta(missing.html, "robots").includes("noindex")); + } + const verify = await request("verify", "/" + verifyFilename); + assert.equal(verify.response.status, 200); + assert.equal(verify.html, "native-ssr-verification-file"); + const now = await request("indexnow", "/" + indexNow + ".txt"); + assert.equal(now.response.status, 200); + assert.equal(now.html, indexNow); + // Go DHInit registers only navigation pages/static assets, including when files exist. + const navigationReserve = createServer(); + await new Promise(done => navigationReserve.listen(0, "127.0.0.1", done)); + const navigationBound = navigationReserve.address(); + if (!navigationBound || typeof navigationBound === "string") throw new Error("Navigation preview port unavailable"); + const navigationOrigin = `http://127.0.0.1:${navigationBound.port}`; + await new Promise(done => navigationReserve.close(() => done())); + navigationPreview = spawn(process.execPath, [resolve("node_modules/next/dist/bin/next"), "start", "--port", String(navigationBound.port), "--hostname", "127.0.0.1"], { + env: { ...process.env, APP_ENV: "production", NODE_ENV: "production", UPSTREAM_API_URL: `http://127.0.0.1:${bound.port}`, UPSTREAM_TIMEOUT_MS: "3000", SITEMAP_DIR: sitemapDir, NAVIGATION_ONLY: "true", NEXT_TELEMETRY_DISABLED: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + navigationPreview.stderr?.on("data", chunk => { previewLogs += "[navigation] " + String(chunk); }); + const navigationDeadline = Date.now() + 30000; + while (true) { + try { await fetch(navigationOrigin + "/robots.txt"); break; } + catch (error) { if (Date.now() >= navigationDeadline) throw error; await new Promise(done => setTimeout(done, 200)); } + } + for (const pathname of ["/robots.txt", "/robots.txt/", "/llms.txt", "/sitemap.xml", "/sitemaps/sitemap-content.xml"]) { + for (const method of ["GET", "HEAD", "POST"]) { + const response = await fetch(navigationOrigin + pathname, { method, headers: headers("navigation-discovery", "registered-A") }); + assert.equal(response.status, 404, "DHInit " + method + " " + pathname); + assert.match(response.headers.get("content-type") || "", /text\/html/); + assert.equal(response.headers.get("allow"), null); + assert.match(response.headers.get("x-robots-tag") || "", /noindex/); + const html = await response.text(); + if (method === "HEAD") assert.equal(html, ""); + else assert.ok(meta(html, "robots").includes("noindex")); + } + } + navigationPreview.kill("SIGTERM"); + navigationPreview = undefined; + results.push({ + redirects: [301, 307, 302], + removedAccountRedirectHome: true, + removedAccountAPIs404: true, + canonical: true, + pagination: true, + jsonLD: true, + verification: true, + notFound: true, + unavailable503: true, + robots: true, + llms: true, + sitemaps: true, + indexNow: true, + fileConditionalRequests: [304, 206, 416], + discoveryMethods: "Go HTML 404 with Allow GET", + navigationDiscovery404: true, + }); + + console.log( + "native SSR: real document navigation updates page CSS and metadata", + ); + browser = await chromium.launch({ + headless: true, + executablePath: + process.env.CHROME_PATH || + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + }); + const navigationErrors: string[] = []; + for (const mobile of [false, true]) { + const context = await browser.newContext({ + viewport: mobile + ? { width: 375, height: 812 } + : { width: 1440, height: 900 }, + extraHTTPHeaders: { + "x-smoke-case": mobile ? "browser-mobile" : "browser-desktop", + }, + }); + await context.addCookies([ + { name: "token", value: "registered-B", url: origin }, + ]); + await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1")); + await context.route("**/*", (route) => + route.request().url().startsWith(origin) + ? route.continue() + : route.abort(), + ); + const page = await context.newPage(); + page.on("pageerror", (error) => navigationErrors.push(error.message)); + let documents = 0; + page.on("request", (request) => { + if (request.isNavigationRequest()) documents++; + }); + if (!mobile) { + for (const path of ["/account/register/", "/account/login/", "/login/"]) { + await page.goto(origin + path, { waitUntil: "domcontentloaded" }); + assert.equal(new URL(page.url()).pathname, "/"); + assert.equal(await page.locator("body").getAttribute("class"), ""); + await expectPageStyles(page, ["home"]); + assert.ok((await page.title()).includes("SSR首页标题")); + const settled = documents; + await page.waitForTimeout(800); + assert.equal(documents, settled, "Desktop removed-account redirect must settle"); + assert.equal( + await page.locator("[data-login-pop], [data-register-pop], [data-login-open], [data-register-open], .hk-user-menu, .hk-user-chip, a[href^='/account']").count(), + 0, + "Home renders no login dialog or account entry", + ); + } + await page.screenshot({ path: join(output, "desktop-navigation.png") }); + } else { + await page.goto(origin + "/discover/search/", { waitUntil: "networkidle" }); + assert.equal( + await page.locator("body").getAttribute("class"), + "hk-mobile-search", + ); + const input = page.locator(".hk-mobile-search__input"); + await input.fill("a/b"); + await Promise.all([ + page.waitForURL(origin + "/search/a%2Fb/"), + input.press("Enter"), + ]); + await page.locator("[data-search-results]").waitFor(); + assert.equal( + await page.locator("body").getAttribute("class"), + "hk-search-page", + ); + await expectPageStyles(page, ["search-results"], ["mobile-search"]); + + assert.ok( + ( + await page.locator('head meta[name="robots"]').getAttribute("content") + )?.includes("noindex"), + ); + await page.screenshot({ + path: join(output, "mobile-search-navigation.png"), + fullPage: true, + }); + await page.goto(origin + "/account/", { waitUntil: "networkidle" }); + assert.equal(new URL(page.url()).pathname, "/"); + assert.equal(await page.locator("body").getAttribute("class"), ""); + await expectPageStyles(page, ["home"], ["mobile-search", "search-results"]); + assert.deepEqual( + await page.locator(".hk-mobile-tabbar__item").allTextContents(), + ["首页", "短剧", "剧集"], + "Mobile tabbar has no account tab", + ); + await page.locator("[data-drawer-open]").click(); + await page.locator("[data-drawer]").waitFor(); + assert.equal( + await page.locator("[data-drawer] :is(.hk-drawer__user, a[href^='/account'], [data-login-open])").count(), + 0, + "Drawer has no user block or account links", + ); + } + await context.close(); + } + assert.deepEqual(navigationErrors, []); + assert.deepEqual(removedCalls(), [], "No request reached a removed account upstream API"); + results.push({ + desktopRemovedAccountRedirect: true, + mobileSearchDocumentNavigation: true, + mobileRemovedAccountRedirectAndTabbar: true, + pageClassAndStylesRefresh: true, + browserErrors: 0, + }); + + await writeFile( + join(output, "results.json"), + JSON.stringify({ optimizedNext: true, results }, null, 2), + ); + await writeFile(join(output, "home.html"), home.html); + console.log( + JSON.stringify({ optimizedNext: true, results, output }, null, 2), + ); +} catch (error) { + await writeFile(join(output, "next.log"), previewLogs); + await writeFile( + join(output, "upstream.json"), + JSON.stringify(calls, null, 2), + ); + throw error; +} finally { + navigationPreview?.kill("SIGTERM"); + await browser?.close(); + preview.kill("SIGTERM"); + await new Promise((done) => mock.close(() => done())); + await unlink(verifyPath).catch(() => {}); +} diff --git a/scripts/next-env.ts b/scripts/next-env.ts new file mode 100644 index 0000000..5da1334 --- /dev/null +++ b/scripts/next-env.ts @@ -0,0 +1,51 @@ +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { parse } from "dotenv"; + +const [environment = "development", command = "dev", ...args] = + process.argv.slice(2); +if ( + !["development", "test", "production"].includes(environment) || + !["dev", "build", "start"].includes(command) +) { + throw new Error( + "Usage: tsx scripts/next-env.ts development|test|production dev|build|start", + ); +} +const config = (file: string) => + existsSync(file) ? parse(readFileSync(file)) : {}; +const childEnv: NodeJS.ProcessEnv = { + ...config(resolve(`config/${environment}.env`)), + ...config(resolve(`config/${environment}.local.env`)), + ...process.env, + APP_ENV: environment, + NODE_ENV: command === "dev" ? "development" : "production", +}; +if (command !== "build" && !childEnv.UPSTREAM_API_URL?.trim()) { + throw new Error( + `UPSTREAM_API_URL is required for ${environment}. Set config/${environment}.local.env or the process environment.`, + ); +} +// Turbopack 16.4 can repeatedly restart the root runtime chunk on this site's +// dynamic document routes. Keep stable development HMR and allow explicit opt-in. +if (command === "dev" || command === "build") await import("./build-pwa"); +const devBundler = + command === "dev" && + !args.some((arg) => ["--webpack", "--turbopack", "--turbo"].includes(arg)) + ? [childEnv.NEXT_DEV_BUNDLER === "turbopack" ? "--turbopack" : "--webpack"] + : []; +const child = spawn( + process.execPath, + [ + resolve("node_modules/next/dist/bin/next"), + command, + ...devBundler, + ...(command === "build" ? [] : ["--port", childEnv.PORT || "25121"]), + ...args, + ], + { stdio: "inherit", env: childEnv }, +); +for (const signal of ["SIGINT", "SIGTERM"] as const) + process.on(signal, () => child.kill(signal)); +child.on("exit", (code) => process.exit(code ?? 1)); diff --git a/scripts/package-standalone.ts b/scripts/package-standalone.ts new file mode 100644 index 0000000..a568e38 --- /dev/null +++ b/scripts/package-standalone.ts @@ -0,0 +1,97 @@ +import { cp, lstat, mkdir, readFile, readdir, realpath, rm, unlink, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, relative, resolve } from 'node:path'; +import { parse } from 'dotenv'; + +const environment = process.argv[2] || 'production'; +if (!['development', 'test', 'production'].includes(environment)) throw new Error('Unknown environment'); +const buildDirectory = environment === 'production' ? '.next' : environment === 'test' ? '.next-test' : '.next-dev'; +const destination = resolve('dist', environment); +const readConfig = async (file: string) => parse(await readFile(file).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return Buffer.alloc(0); + throw error; +})); +const config = { ...await readConfig(resolve('config', environment + '.env')), ...await readConfig(resolve('config', environment + '.local.env')), ...process.env }; +const sitemapDirectory = resolve(config.SITEMAP_DIR?.trim() || './template/pages/sitemaps'); +const inside = (base: string, target: string) => { + const suffix = relative(base, target); + return suffix === '' || (suffix !== '..' && !suffix.startsWith('../') && !isAbsolute(suffix)); +}; +// dist is replaceable build output. Never let packaging remove a backend writer's directory. +const canonicalSitemapDirectory = await realpath(sitemapDirectory).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return sitemapDirectory; + throw error; +}); +if (inside(destination, sitemapDirectory) || inside(destination, canonicalSitemapDirectory)) + throw new Error('SITEMAP_DIR must live outside the replaceable standalone package directory'); +for (const directory of [resolve('dist'), destination]) { + const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (info && (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory)) + throw new Error('Standalone output must be an ordinary build directory'); +} +await readFile(resolve(buildDirectory, 'BUILD_ID')); +await rm(destination, { recursive: true, force: true }); +await mkdir(destination, { recursive: true }); +await cp(resolve(buildDirectory, 'standalone'), destination, { recursive: true }); +// Next 16.4 may copy Node Proxy's trace without applying route tracing exclusions. +// Remove generated XML only from this copied release, never through a backend mount. +const copiedSitemaps: string[] = []; +const ordinaryDirectory = async (suffix: string) => { + let directory = destination; + for (const segment of ['', ...suffix.split(/[\\/]/).filter(Boolean)]) { + directory = resolve(directory, segment); + const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (!info) return false; + if (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory) + throw new Error('Sitemap cleanup requires ordinary directories inside the standalone package'); + } + return true; +}; +const stageRemoval = async (file: string) => { + const info = await lstat(file).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (!info) return; + if (!info.isFile() && !info.isSymbolicLink()) + throw new Error('Packaged sitemap XML must be a file, not a directory or special device'); + copiedSitemaps.push(file); +}; +for (const suffix of ['template/pages/sitemaps', 'data/sitemaps']) { + if (!await ordinaryDirectory(suffix)) continue; + const directory = resolve(destination, suffix); + for (const name of await readdir(directory)) { + if (name === 'sitemap.xml' || /^sitemap-[a-z0-9-]+\.xml$/.test(name)) + await stageRemoval(resolve(directory, name)); + } +} +const fallback = resolve(destination, 'template/pages/sitemap.xml'); +if (await ordinaryDirectory(relative(destination, dirname(fallback)))) await stageRemoval(fallback); +// unlink does not follow file symlinks and cannot recursively remove a directory. +for (const file of copiedSitemaps) await unlink(file); +await cp(resolve('public'), resolve(destination, 'public'), { recursive: true }); +await cp(resolve(buildDirectory, 'static'), resolve(destination, buildDirectory, 'static'), { recursive: true }); +await mkdir(resolve(destination, 'config'), { recursive: true }); +await cp(resolve('config', environment + '.env'), resolve(destination, 'config', environment + '.env')); +await cp(resolve('node_modules/dotenv'), resolve(destination, 'node_modules/dotenv'), { recursive: true }); +await writeFile(resolve(destination, 'start.mjs'), `import { readFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'dotenv'; +process.chdir(dirname(fileURLToPath(import.meta.url))); +// Next checks the deprecated root static/ before public/static/. An old Go +// deployment in this directory would silently override the bundled assets. +if (existsSync(resolve('static'))) throw new Error('Standalone must run in an isolated directory without a legacy static/ folder; it overrides public/static/.'); +const environment = ${JSON.stringify(environment)}; +const read = file => existsSync(file) ? parse(readFileSync(file)) : {}; +const config = { ...read(resolve('config', environment + '.env')), ...read(resolve('config', environment + '.local.env')), ...process.env, APP_ENV: environment, NODE_ENV: 'production' }; +if (!config.UPSTREAM_API_URL?.trim()) throw new Error('UPSTREAM_API_URL is required'); +Object.assign(process.env, config); +await import('./server.js'); +`); +console.log(`Standalone package ready: ${destination}; run node start.mjs inside it.`); diff --git a/scripts/page-presentation-smoke.ts b/scripts/page-presentation-smoke.ts new file mode 100644 index 0000000..cb9103b --- /dev/null +++ b/scripts/page-presentation-smoke.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { chromium } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; +import { presentations } from '../src/server/react/presentation'; +import { createStyleFixture } from './style-fixtures'; + +// Actual React layout effects and the shared scroll-lock hook run in an isolated +// browser. Rerenders/remounts model incoming page payloads without reloading . +const root = fileURLToPath(new URL('..', import.meta.url)); +const bundle = await build({ + stdin: { + resolveDir: root, + loader: 'tsx', + contents: ` +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import {PagePresentation} from '@/components/layout/PagePresentation'; +import {useScrollLock} from '@/components/ui/Dialog'; +function Lock({active}) {useScrollLock(active);return null;} +const root=createRoot(document.getElementById('root')); +let view='about',locked=false,key='shared-page'; +function render(){root.render(
);} +window.__pagePresentation=(next,remount=false)=>{view=next;key=remount?next:'shared-page';render();}; +window.__pageLock=(active)=>{locked=active;render();}; +render(); +`, + }, + bundle: true, + platform: 'browser', + format: 'iife', + write: false, + define: { 'process.env.NODE_ENV': '"development"' }, + tsconfig: fileURLToPath(new URL('../tsconfig.json', import.meta.url)), +}); +const styles = await createStyleFixture(['base', 'list', 'nav']); +const initial = presentations.about!; +const chrome = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const browser = await chromium.launch({ headless: true, ...(existsSync(chrome) ? { executablePath: chrome } : {}) }); +try { + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/*', route => route.abort()); + await page.setContent(`
`); + const documentIdentity = await page.evaluate(() => { + const token = Math.random().toString(36); + (window as unknown as { __documentIdentity: string }).__documentIdentity = token; + return token; + }); + await page.addScriptTag({ content: bundle.outputFiles[0]!.text }); + const managed = [...new Set(Object.values(presentations).flatMap(({ bodyClass }) => bodyClass.split(/\s+/).filter(Boolean)))]; + let mobile = false; + const verify = async (view: string, remount = false) => { + const expected = presentations[view] || presentations['404']!; + await page.evaluate(({ view, remount }) => { + (window as unknown as { __pagePresentation(view: string, remount: boolean): void }).__pagePresentation(view, remount); + }, { view, remount }); + await page.waitForFunction(({ tokens, classes, managed }) => { + const active = new Set(classes.split(/\s+/).filter(Boolean)); + return document.documentElement.getAttribute('data-hk-page-styles') === tokens && + managed.every(name => document.body.classList.contains(name) === active.has(name)); + }, { tokens: expected.styleTokens.join(' '), classes: expected.bodyClass, managed }); + const state = await page.evaluate(() => ({ + identity: (window as unknown as { __documentIdentity: string }).__documentIdentity, + htmlClasses: [...document.documentElement.classList], + other: document.documentElement.getAttribute('data-presentation-unrelated'), + classes: [...document.body.classList], + overflow: document.body.style.overflow, + probePadding: getComputedStyle(document.getElementById('page-style-probe')!).paddingTop, + })); + assert.equal(state.identity, documentIdentity, 'presentation updates must not reload the document'); + assert.ok(state.htmlClasses.includes('hk-pwa-standalone')); + assert.ok(state.htmlClasses.includes('hk-age-gate-ok')); + assert.equal(state.other, 'keep'); + for (const name of ['hk-is-managing', 'hk-drawer-open', 'external-plugin-state']) assert.ok(state.classes.includes(name), view + ' erased ' + name); + assert.equal(state.overflow, 'hidden', view + ' released a live scroll lock'); + if (view === 'nav') assert.equal(state.probePadding, mobile ? '64px' : '132px'); + if (view === 'list') assert.equal(state.probePadding, mobile ? '56px' : '72px'); + }; + await page.evaluate(() => (window as unknown as { __pageLock(active: boolean): void }).__pageLock(true)); + await page.waitForFunction(() => document.body.style.overflow === 'hidden'); + for (const removed of ['profile', 'setting', 'history', 'favorites', 'purchased', 'recharge', 'login', 'register']) assert.equal(presentations[removed], undefined, 'removed account view still has a presentation: ' + removed); + for (const view of Object.keys(presentations)) await verify(view); + for (const view of ['list', 'nav', 'help', 'privacy', 'about', '__missing-view', 'list']) await verify(view, true); + mobile = true; + await page.setViewportSize({ width: 375, height: 812 }); + for (const view of ['nav', 'list', 'about']) await verify(view); + await page.evaluate(() => (window as unknown as { __pageLock(active: boolean): void }).__pageLock(false)); + await page.waitForFunction(() => document.body.style.overflow === 'auto'); + assert.deepEqual(errors, []); + console.log(`Page presentation browser regression passed: ${Object.keys(presentations).length} views, remount/back transitions, real scoped CSS, PWA/temporary class preservation and shared scroll-lock restoration.`); +} finally { + await browser.close(); +} diff --git a/scripts/playback-smoke.ts b/scripts/playback-smoke.ts new file mode 100644 index 0000000..166a0fb --- /dev/null +++ b/scripts/playback-smoke.ts @@ -0,0 +1,685 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, basename } from "node:path"; +import { spawnSync } from "node:child_process"; +import { createServer } from "node:http"; +import { randomBytes } from "node:crypto"; +import { build } from "esbuild"; +import { chromium } from "@playwright/test"; +import { decryptImageBytes } from "../src/lib/media/images"; +import { createStyleFixture } from "./style-fixtures"; + +// Actual React components and official xgplayer/HLS, isolated from all production APIs. +const cwd = process.cwd(), + temp = await mkdtemp(join(tmpdir(), "huakui-playback-")); +const output = process.env.PLAYBACK_SMOKE_OUTPUT || "/tmp/hkdj-playback-smoke"; +const playbackStyles = await createStyleFixture(["base", "downloadPop", "installGuide", "play", "playLinks"]); +await mkdir(output, { recursive: true }); +const mp4 = join(temp, "fixture.mp4"), + key = randomBytes(16); +await writeFile(join(temp, "key.bin"), key); +await writeFile( + join(temp, "key.info"), + `/enkey?ticket=mock-key&keep=1\n${join(temp, "key.bin")}\n`, +); +function ffmpeg(args: string[]) { + const result = spawnSync( + "ffmpeg", + ["-hide_banner", "-loglevel", "error", "-y", ...args], + { encoding: "utf8" }, + ); + assert.equal( + result.status, + 0, + result.stderr || "ffmpeg is required for real media playback validation", + ); +} +ffmpeg([ + "-f", + "lavfi", + "-i", + "testsrc2=size=320x180:rate=24", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=44100", + "-t", + "12", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-g", + "48", + "-c:a", + "aac", + "-movflags", + "+faststart", + mp4, +]); +ffmpeg([ + "-i", + mp4, + "-c", + "copy", + "-hls_time", + "2", + "-hls_playlist_type", + "vod", + "-hls_key_info_file", + join(temp, "key.info"), + "-hls_segment_filename", + join(temp, "segment-%d.ts"), + join(temp, "signed.m3u8"), +]); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jEtQAAAAASUVORK5CYII=", + "base64", +); +const encrypted = decryptImageBytes( + png.buffer.slice( + png.byteOffset, + png.byteOffset + png.byteLength, + ) as ArrayBuffer, +); +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 {current.series?:}window.imageResolved=(window.imageResolved||0)+1} onResolveError={()=>window.imageErrors=(window.imageErrors||0)+1}/>window.failedImageResolved=true} onResolveError={()=>window.failedImageError=true}/>};createRoot(document.getElementById('root')).render();`, +); +const navStub = join(temp, "navigation.ts"); +await writeFile( + navStub, + "export const useRouter=()=>({back:()=>history.back(),refresh:()=>{},push:url=>location.assign(url),replace:url=>location.replace(url)});export const usePathname=()=>location.pathname;export const useSearchParams=()=>new URLSearchParams(location.search);", +); +await build({ + entryPoints: [entry], + bundle: true, + platform: "browser", + format: "iife", + outfile: join(temp, "app.js"), + tsconfig: join(cwd, "tsconfig.json"), + nodePaths: [join(cwd, "node_modules")], + define: { "process.env.NODE_ENV": '"production"' }, + plugins: [ + { + name: "next-router-test-context", + setup(builder) { + builder.onResolve({ filter: /^next\/navigation$/ }, () => ({ + path: navStub, + })); + }, + }, + ], +}); +const requests: { + url: string; + method: string; + body?: Record; + headers: Record; +}[] = []; +let origin = ""; +const allowedApis = new Set([ + "/api/auth/session", + "/api/auth/guest", + "/api/playlet/guess-like/list", + "/api/playlet/chapter/watch/report", +]); +const unexpectedApis: string[] = []; +const baseData = () => ({ + fixtureKey: "hls", + requestPath: "/drama/mock/101/", + isLoggedIn: true, + isRegistered: true, + userID: 1001, + userName: "测试用户", + avatar: "/static/brand/placeholder.svg", + appConfig: { appAddr: "https://download.example/real" }, + canWatch: true, + mainURL: "/h5/m3u8/signed.m3u8?ticket=mock-play&keep=1", + previewURL: origin + "/fixture.mp4?preview=1", + hasPreview: true, + chapterID: 101, + episode: 1, + playlet: { + id: 10, + slug: "mock", + playName: "播放测试", + cover: "/static/brand/placeholder.svg", + horizontalCover: "/static/brand/placeholder.svg", + totalEpisode: 2, + updateStatus: 2, + countCollect: 4, + countLike: 3, + isLike: 2, + isCollect: 2, + playletChapterInfo: [ + { id: 101, currentEpisode: 1, canWatch: true }, + { id: 102, currentEpisode: 2, payType: 1, needUnlock: true }, + ], + }, + trackerDrama: { + DramaID: 10, + DramaTitle: "播放测试", + VideoID: 101, + Episode: 1, + TotalEpisode: 2, + DramaDuration: 12, + DramaTypeID: "2", + DramaTypeName: "都市", + DramaTagKey: "3", + DramaTagName: "剧情", + }, + hotList: [], + guessLike: [ + { id: 30, slug: "guess", playName: "猜你喜欢测试", cover: "/static/brand/placeholder.svg", totalEpisode: 3, updateStatus: 2 }, + ], + related: [], + advitise: { nineGridList: [], detailbannerList: [] }, +}); +const server = createServer(async (req, res) => { + try { + const url = new URL(req.url!, origin || "http://localhost"); + let body = ""; + for await (const part of req) body += part; + requests.push({ + url: req.url!, + method: req.method!, + body: body ? JSON.parse(body) : undefined, + headers: req.headers, + }); + if (url.pathname === playbackStyles.href) { + res.setHeader("content-type", "text/css;charset=utf-8"); + 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 = {}; + if (url.pathname === "/api/auth/session") + data = { userId: 1001, isRegister: true, recharge: 12 }; + else if (!allowedApis.has(url.pathname)) { + // Like/collect/comment/unlock/coin APIs were removed with the account system. + unexpectedApis.push(url.pathname); + res.statusCode = 500; + res.end(JSON.stringify({ code: 500, data: null })); + return; + } + res.end(JSON.stringify({ code: 200, data })); + return; + } + if (url.pathname === "/encrypted.png") { + res.setHeader("content-type", "application/octet-stream"); + res.end(encrypted); + return; + } + if (url.pathname === "/missing-video.mp4") { + res.statusCode = 404; + res.end(); + return; + } + if (url.pathname === "/missing-image") { + res.statusCode = 404; + res.end(); + return; + } + if (url.pathname === "/enkey") { + res.setHeader("content-type", "application/octet-stream"); + res.end(key); + return; + } + let file: string | undefined; + if (url.pathname.startsWith("/static/")) + file = join(cwd, "public", url.pathname); + else if (url.pathname.startsWith("/h5/m3u8/")) + file = join(temp, basename(url.pathname)); + else if (["/app.js", "/app.css", "/fixture.mp4"].includes(url.pathname)) + file = join(temp, basename(url.pathname)); + if (file) { + const bytes = await readFile(file), + ext = file.split(".").pop()!; + res.setHeader( + "content-type", + ( + { + js: "application/javascript", + css: "text/css", + svg: "image/svg+xml", + png: "image/png", + m3u8: "application/vnd.apple.mpegurl", + ts: "video/mp2t", + mp4: "video/mp4", + } as Record + )[ext] || "application/octet-stream", + ); + res.setHeader("accept-ranges", "bytes"); + const range = /bytes=(\d+)-(\d*)/.exec(req.headers.range || ""); + if (range) { + const start = Number(range[1]), + end = range[2] ? Number(range[2]) : bytes.length - 1; + res.statusCode = 206; + res.setHeader("content-range", `bytes ${start}-${end}/${bytes.length}`); + res.end(bytes.subarray(start, end + 1)); + } else res.end(bytes); + return; + } + res.setHeader("content-type", "text/html;charset=utf-8"); + res.end( + `
', + ); + } catch (error) { + res.statusCode = 404; + res.end(String(error)); + } +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert(address && typeof address !== "string"); +origin = `http://127.0.0.1:${address.port}`; +const browser = await chromium.launch({ + executablePath: + process.env.CHROME_PATH || + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: true, +}); +const results = []; +try { + for (const [device, viewport] of [ + ["desktop", { width: 1440, height: 900 }], + ["mobile", { width: 375, height: 812 }], + ] as const) { + const context = await browser.newContext({ + viewport, + isMobile: device === "mobile", + ...(device === "mobile" + ? { + userAgent: + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 Quark/7.0", + } + : {}), + }); + await context.route("**/*", (route) => + new URL(route.request().url()).origin === origin + ? route.continue() + : route.abort(), + ); + const page = await context.newPage(), + errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(origin, { waitUntil: "networkidle" }); + await page.waitForFunction(() => !!document.querySelector("video")); + const video = page.locator("video"); + await page.locator(".hk-playback-player__center").click(); + await page.waitForFunction( + () => { + const video = document.querySelector("video"); + return !!video && !video.paused && video.currentTime > 0.2; + }, + { timeout: 15000 }, + ); + assert( + requests.some( + (req) => req.url === "/h5/m3u8/signed.m3u8?ticket=mock-play&keep=1", + ), + ); + assert(requests.some((req) => req.url === "/enkey?ticket=mock-key&keep=1")); + assert( + requests.some( + (req) => + req.url === "/api/playlet/chapter/watch/report" && + req.body?.chapterId === 101, + ), + ); + await page.locator(".xgplayer-fullscreen").click({ force: true }); + await page.waitForFunction( + () => + !!document.fullscreenElement || + !!document.querySelector(".xgplayer-is-cssfullscreen"), + ); + const full = await page.locator(".hk-video-player").boundingBox(); + assert.equal(Math.round(full!.width), viewport.width); + assert.equal(Math.round(full!.height), viewport.height); + // The native fullscreen transition resolves after the CSS state is visible. + await page.waitForTimeout(200); + await page.locator(".xgplayer-fullscreen").click({ force: true }); + await page.waitForFunction( + () => + !document.fullscreenElement && + !document.querySelector(".xgplayer-is-cssfullscreen"), + ); + await video.evaluate((node) => (node as HTMLVideoElement).pause()); + await page.waitForFunction(() => document.querySelector("video")!.paused); + await video.evaluate((node) => { + (node as HTMLVideoElement).currentTime = 7; + }); + await page.waitForFunction( + () => document.querySelector("video")!.currentTime >= 6.9, + ); + await video.evaluate((node) => { + const video = node as HTMLVideoElement; + video.playbackRate = 1.5; + return video.play(); + }); + await page.waitForTimeout(650); + await video.evaluate((node) => (node as HTMLVideoElement).pause()); + await video.evaluate((node) => { + const video = node as HTMLVideoElement; + video.currentTime = 11.8; + return video.play(); + }); + await page.waitForFunction(() => document.querySelector("video")!.ended); + const events = await page.evaluate( + () => + ( + window as unknown as { + sdkEvents: { method: string; payload: Record }[]; + } + ).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") + .getAttribute("data-decrypt-failed"), + "1", + ); + assert( + ( + await page.locator("#encrypted-image-test").getAttribute("src") + )?.startsWith("blob:"), + ); + assert.equal( + await page.evaluate( + () => (window as unknown as { imageResolved: number }).imageResolved, + ), + 1, + ); + assert.equal( + await page.evaluate( + () => + (window as unknown as { failedImageResolved?: boolean }) + .failedImageResolved, + ), + undefined, + ); + assert.equal( + await page.evaluate( + () => + (window as unknown as { failedImageError?: boolean }) + .failedImageError, + ), + 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 }[]; + } + ).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", + ); + await page.getByRole("button", { name: "正片", exact: true }).click(); + await page.waitForFunction( + () => document.querySelector("video")?.currentTime === 0, + ); + await page.locator(".hk-playback-player__center").click(); + await page.waitForFunction(() => !document.querySelector("video")!.paused); + // Like/collect actions and comments were removed; recommendations take the comment slot. + for (const name of ["点赞", "取消点赞", "收藏", "取消收藏", "发布"]) + assert.equal( + await page.getByRole("button", { name, exact: true }).count(), + 0, + name + " action must be removed", + ); + assert.equal(await page.locator(".hk-playback-comments, .hk-playback-composer").count(), 0); + assert.deepEqual( + await page.locator(".hk-playback-dual > *").evaluateAll((nodes) => nodes.map((node) => node.className)), + ["hk-playback-like", "hk-playback-hot"], + ); + assert.match( + (await page.locator(".hk-playback-like").textContent()) || "", + /猜你喜欢测试/, + ); + 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"), + ); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ path: join(output, device + "-player.png") }); + const paid = { + ...baseData(), + fixtureKey: "locked", + canWatch: false, + mainURL: "/h5/m3u8/paid-secret.m3u8?ticket=never-fetch", + hasPreview: true, + previewURL: origin + "/fixture.mp4?preview=locked", + }; + await page.evaluate( + (data) => + ( + window as unknown as { + changePlaybackFixture: (data: unknown) => void; + } + ).changePlaybackFixture(data), + paid, + ); + await page.waitForFunction(() => + document.querySelector('[data-play-mode="preview"]'), + ); + await page.getByRole("button", { name: "正片", exact: true }).click(); + assert.equal(await page.locator("video").count(), 0); + assert.equal( + await page.locator(".hk-playback-player__unlock-title").textContent(), + "本集请前往 App 观看", + ); + // The locked overlay offers only the app download; no coin unlock or recharge. + assert.equal( + await page.locator(".hk-playback-player__unlock-actions > *").count(), + 1, + ); + assert.match( + (await page.locator(".hk-playback-player__unlock-btn").textContent()) || "", + /前往 App 观看/, + ); + assert.equal( + await page.locator(".hk-playback-player__unlock").getByRole("button", { name: "去充值" }).count(), + 0, + ); + assert(!requests.some((req) => req.url.includes("paid-secret"))); + assert.match( + (await page.locator(".hk-playback-actions__btn--play").textContent()) || "", + /去 App 看/, + ); + // The play CTA opens the App-exclusive sheet; its download button and the + // overlay button both lead to the shared app download dialog. + await page.locator(".hk-playback-actions__btn--play").click(); + const sheet = page.locator(".hk-playback-coin"); + await sheet.waitFor({ state: "visible" }); + assert.equal(await sheet.locator(".hk-playback-coin__title").textContent(), "本集请前往 App 观看"); + await sheet.locator(".hk-playback-coin__skip").click(); + await sheet.waitFor({ state: "detached" }); + for (const entrance of [ + ".hk-playback-actions__btn--play", + ".hk-playback-player__unlock-btn", + ]) { + await page.locator(entrance).click(); + if (entrance === ".hk-playback-actions__btn--play") + await page.locator(".hk-playback-coin__pay").click(); + // Desktop shows the download dialog; mobile shows the installation guide. + const dialog = device === "desktop" ? "[data-download-pop]" : "[data-install-guide]"; + await page.locator(dialog).waitFor({ state: "visible" }); + assert.equal(await page.locator(".hk-playback-coin").count(), 0); + if (device === "desktop") + assert.equal( + await page.locator(".hk-app-download-dialog__download").getAttribute("href"), + "https://download.example/real", + ); + assert.equal( + await page.evaluate(() => document.body.style.overflow), + "hidden", + ); + if (entrance === ".hk-playback-player__unlock-btn") + await page.screenshot({ + path: join(output, device + "-locked-download.png"), + }); + await page.keyboard.press("Escape"); + await page.waitForSelector(dialog, { state: "detached" }); + assert.notEqual( + await page.evaluate(() => document.body.style.overflow), + "hidden", + ); + } + assert.equal(await page.locator("[data-recharge-modal]").count(), 0); + + assert.equal(await page.locator("video").count(), 0); + const failed = { + ...baseData(), + fixtureKey: "retry", + mainURL: origin + "/missing-video.mp4", + hasPreview: false, + previewURL: "", + }; + await page.evaluate( + (data) => + ( + window as unknown as { + changePlaybackFixture: (data: unknown) => void; + } + ).changePlaybackFixture(data), + failed, + ); + await page + .getByRole("button", { name: "重新加载" }) + .waitFor({ state: "visible" }); + const failures = requests.filter( + (req) => req.url === "/missing-video.mp4", + ).length; + await page.getByRole("button", { name: "重新加载" }).click(); + await page.waitForTimeout(500); + assert( + requests.filter((req) => req.url === "/missing-video.mp4").length > + failures, + ); + const series = { + ...baseData(), + fixtureKey: "series", + series: true, + canWatch: true, + videoID: 201, + videoURL: origin + "/fixture.mp4?main=series", + video: { + id: 201, + title: "影集测试", + coverImg: "/static/brand/placeholder.svg", + video: { url: origin + "/fixture.mp4", videoTime: 12 }, + }, + }; + await page.evaluate( + (data) => + ( + window as unknown as { + changePlaybackFixture: (data: unknown) => void; + } + ).changePlaybackFixture(data), + series, + ); + await page.waitForFunction( + () => document.querySelector("h1")?.textContent === "影集测试", + ); + assert.equal(await page.locator("video").count(), 1); + await page.locator(".hk-playback-player__center").click(); + await page.waitForFunction( + () => document.querySelector("video")!.currentTime > 0.2, + ); + assert(requests.some((req) => req.url === "/fixture.mp4?main=series")); + assert.deepEqual(errors, []); + assert.deepEqual(unexpectedApis, []); + results.push({ + device, + officialXgplayer: true, + aes128Hls: true, + signedQueriesPreserved: true, + playPauseSeek: true, + previewExcluded: true, + paidNoFetch: true, + chapterDestroy: true, + fullscreen: true, + errorRetry: true, + realMp4: true, + recommendationsAndTracking: true, + encryptedImageAndFallback: true, + lockedAppDownloadOnly: true, + }); + await context.close(); + } + await writeFile( + join(output, "results.json"), + JSON.stringify(results, null, 2), + ); + console.log(JSON.stringify({ output, results }, null, 2)); +} finally { + await browser.close(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(temp, { recursive: true, force: true }); +} diff --git a/scripts/prepare-production-runtime.ts b/scripts/prepare-production-runtime.ts new file mode 100644 index 0000000..ba5ff94 --- /dev/null +++ b/scripts/prepare-production-runtime.ts @@ -0,0 +1,141 @@ +import { cp, lstat, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; + +const [arch = 'x64', libc = 'glibc', ...extra] = process.argv.slice(2); +if (!['x64', 'arm64'].includes(arch) || !['glibc', 'musl'].includes(libc) || extra.length) { + throw new Error('Usage: tsx scripts/prepare-production-runtime.ts [x64|arm64] [glibc|musl]'); +} +const root = await realpath(resolve(dirname(fileURLToPath(import.meta.url)), '..')); +const dist = join(root, 'dist'); +const source = join(dist, 'production'); +const target = join(dist, `production-linux-${arch}-${libc}`); +if (dirname(target) !== dist || !/^production-linux-(x64|arm64)-(glibc|musl)$/.test(basename(target))) { + throw new Error('Runtime destination must be a fixed repository dist directory'); +} +for (const directory of [dist, source]) { + const info = await lstat(directory); + if (info.isSymbolicLink() || !info.isDirectory() || await realpath(directory) !== directory) { + throw new Error(`Unsafe build directory: ${directory}`); + } +} +const oldTarget = await lstat(target).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + return null; +}); +if (oldTarget?.isSymbolicLink()) throw new Error('Runtime destination cannot be a symbolic link'); + +async function requireFile(base: string, name: string) { + const path = join(base, name); + const info = await lstat(path); + if (!info.isFile() || info.size === 0 || !(await realpath(path)).startsWith(base + '/')) { + throw new Error(`Missing, empty or unsafe runtime file: ${name}`); + } +} +const required = ['start.mjs', 'server.js', '.next/BUILD_ID', 'public/sw.js', + 'config/production.env', 'config/secrets/m3u8.key']; +for (const file of required) await requireFile(source, file); +for (const directory of ['.next/static', 'public']) { + if (!(await lstat(join(source, directory))).isDirectory()) throw new Error(`Missing ${directory}`); +} +for (const file of ['package.json', 'package-lock.json']) await requireFile(root, file); +const originalLock = JSON.parse(await readFile(join(root, 'package-lock.json'), 'utf8')); +for (const file of await matchingFiles(join(source, 'node_modules'), /^package\.json$/)) { + const locked = originalLock.packages[relative(source, dirname(file))]; + if (locked && JSON.parse(await readFile(file, 'utf8')).version !== locked.version) { + throw new Error(`Traced dependency does not match the root lockfile: ${file}`); + } +} + +const npmVersion = spawnSync('npm', ['--version'], { cwd: root, encoding: 'utf8' }); +if (npmVersion.error || npmVersion.status !== 0) throw new Error('Cannot read the current npm version'); +const version = npmVersion.stdout.trim(); +const match = /^(\d+)\.(\d+)\.(\d+)(?:$|[-+])/.exec(version); +const [major = 0, minor = 0, patch = 0] = match ? match.slice(1).map(Number) : []; +if (major < 10 || major === 10 && (minor < 9 || minor === 9 && patch < 2)) { + throw new Error(`npm >=10.9.2 is required for cross-platform installation; found ${version}`); +} +// Only this validated, fixed dist destination can be removed; source and host dependencies stay intact. +const sharpPlatform = `${libc === 'musl' ? 'linuxmusl' : 'linux'}-${arch}`; +const packages: [string, RegExp][] = [ + [`@img/sharp-${sharpPlatform}`, /\.node$/], + [`@img/sharp-libvips-${sharpPlatform}`, /libvips.*\.so(?:\.\d+)*$/], +]; +await rm(target, { recursive: true, force: true }); +await cp(source, target, { + recursive: true, + filter: async path => { + const name = basename(path); + if (name.startsWith('.env') || name.endsWith('.local.env') || + name.startsWith('._') || name === '.DS_Store' || name.endsWith('.log')) return false; + const scope = basename(dirname(path)); + if (scope === '@next' && name.startsWith('swc-')) return false; + if (scope === '@img' && name.startsWith('sharp-') && name !== 'sharp-wasm32' && + !packages.some(([packageName]) => packageName === `@img/${name}`)) return false; + if ((await lstat(path)).isSymbolicLink()) throw new Error(`Runtime source contains a symbolic link: ${path}`); + return true; + }, +}); +for (const file of ['package.json', 'package-lock.json']) await cp(join(root, file), join(target, file)); +// Preserve Next's traced JS tree. Install only the two platform packages, never the full app dependency graph. +const nativeDir = await mkdtemp(join(dist, '.production-native-')); +try { + const optionalDependencies: Record = {}, records: Record = {}; + for (const [name] of packages) { + const entry = originalLock.packages[`node_modules/${name}`]; + if (!entry?.version || !entry.resolved || !entry.integrity) throw new Error(`Missing locked native package: ${name}`); + optionalDependencies[name] = entry.version; + const record = { ...entry, optional: true }; + delete record.dev; + delete record.devOptional; + records[`node_modules/${name}`] = record; + } + const manifest = { name: 'hkdj-native-runtime', version: '1.0.0', private: true, optionalDependencies }; + await writeFile(join(nativeDir, 'package.json'), JSON.stringify(manifest)); + await writeFile(join(nativeDir, 'package-lock.json'), JSON.stringify({ name: manifest.name, + version: manifest.version, lockfileVersion: 3, requires: true, packages: { '': manifest, ...records } })); + console.log(`Installing two locked Linux ${arch}/${libc} native packages with npm ${version}`); + const install = spawnSync('npm', ['ci', '--omit=dev', '--include=optional', '--ignore-scripts', + '--no-audit', '--no-fund', '--os=linux', `--cpu=${arch}`, `--libc=${libc}`], { cwd: nativeDir, stdio: 'inherit' }); + if (install.error || install.status !== 0) throw new Error(`Native npm ci failed (${install.status ?? install.error?.message})`); + for (const [name, pattern] of packages) { + const directory = join(nativeDir, 'node_modules', name); + const metadata = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')); + if (metadata.name !== name || metadata.version !== optionalDependencies[name] || + !metadata.os?.includes('linux') || !metadata.cpu?.includes(arch)) throw new Error(`Incorrect native package: ${name}`); + const binaries = await matchingFiles(directory, pattern); + if (!binaries.length) throw new Error(`Native runtime binary is missing: ${name}`); + for (const binary of binaries) await verifyELF(binary); + await cp(directory, join(target, 'node_modules', name), { recursive: true }); + } +} finally { await rm(nativeDir, { recursive: true, force: true }); } +for (const file of [...required, 'node_modules/dotenv/package.json', 'node_modules/sharp/package.json', + 'node_modules/detect-libc/package.json', 'node_modules/semver/package.json', 'node_modules/@img/colour/package.json']) { + await requireFile(target, file); +} +const runtimeRequire = createRequire(join(target, 'package.json')); +if (typeof runtimeRequire('dotenv').parse !== 'function') throw new Error('Runtime dotenv is unavailable'); + +async function matchingFiles(directory: string, pattern: RegExp): Promise { + const found: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) found.push(...await matchingFiles(path, pattern)); + else if (entry.isFile() && pattern.test(entry.name)) found.push(path); + } + return found; +} +async function verifyELF(path: string) { + const file = await open(path, 'r'); + const header = Buffer.alloc(64); + try { await file.read(header, 0, header.length, 0); } finally { await file.close(); } + if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) || + header[4] !== 2 || header[5] !== 1 || header.readUInt16LE(18) !== (arch === 'x64' ? 62 : 183)) { + throw new Error(`Native runtime is not a Linux ${arch} ELF binary: ${path}`); + } +} +// These public target details describe the package; environment values and credentials are never copied here. +await writeFile(join(target, 'runtime-target.json'), JSON.stringify({ platform: 'linux', arch, libc, packaging: 'standalone' }, null, 2) + '\n'); +console.log(`Linux runtime ready: ${target}`); diff --git a/scripts/pwa-smoke.ts b/scripts/pwa-smoke.ts new file mode 100644 index 0000000..818b5df --- /dev/null +++ b/scripts/pwa-smoke.ts @@ -0,0 +1,340 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, extname } from "node:path"; +import { build } from "esbuild"; +import { chromium, type Browser, type Page } from "@playwright/test"; +import manifest from "../src/app/manifest"; +import { createStyleFixture } from "./style-fixtures"; + +interface FixtureWindow extends Window { + setPwaMode(mode: "fullscreen" | "standalone", active: boolean): void; + setIosStandalone(active: boolean): void; + setNativeFullscreen(active: boolean): void; + mountPwaProvider(active: boolean): void; +} + +// Real React provider, shared dialog/tabbar and official player; no production API or CDN. +const cwd = process.cwd(); +const temp = await mkdtemp(join(tmpdir(), "huakui-pwa-")); +const output = process.env.PWA_SMOKE_OUTPUT || "/tmp/hkdj-pwa-smoke"; +let browser: Browser | undefined; +let server: Server | undefined; +let origin = ""; +let networkReads = 0; +const requests: string[] = []; +const results: Record[] = []; + +async function waitForInstalled(page: Page, expected: boolean) { + await page.waitForFunction( + (active) => document.documentElement.classList.contains("hk-pwa-standalone") === active, + expected, + ); +} + +async function fullscreen(page: Page, css: boolean, viewport: { width: number; height: number }) { + await page.locator(".xgplayer-fullscreen").click({ force: true }); + await page.waitForFunction( + (useCss) => useCss + ? !!document.querySelector(".xgplayer-is-cssfullscreen") + : !!document.fullscreenElement, + css, + ); + await page.waitForFunction( + ({ width, height }) => { + const rect = document.querySelector(".hk-video-player")!.getBoundingClientRect(); + return Math.abs(rect.left) < 1 && Math.abs(rect.top) < 1 && + Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1; + }, + viewport, + ); + const measurements = await page.locator(".hk-video-player").evaluate((element) => { + const style = getComputedStyle(element); + const controls = getComputedStyle(element.querySelector(".xgplayer-controls")!); + return { height: style.height, left: controls.left, right: controls.right, bottom: controls.bottom }; + }); + assert.equal(Math.round(parseFloat(measurements.height)), viewport.height, "Fullscreen uses the complete dynamic viewport"); + if (css) { + assert.equal(parseFloat(measurements.left), 20); + assert.equal(parseFloat(measurements.right), 20); + assert.equal(parseFloat(measurements.bottom), 34); + } + // XGPlayer's native transition can settle after its fullscreen class appears. + await page.waitForTimeout(250); + return measurements; +} + +async function exitFullscreen(page: Page) { + await page.locator(".xgplayer-fullscreen").click({ force: true }); + await page.waitForFunction(() => !document.fullscreenElement && !document.querySelector(".xgplayer-is-cssfullscreen")); + await page.waitForTimeout(200); + assert.notEqual(await page.evaluate(() => getComputedStyle(document.body).overflowY), "hidden", "Fullscreen exit restores page scrolling"); + await page.evaluate(() => window.scrollTo(0, 180)); + await page.waitForFunction(() => window.scrollY >= 179); +} + +try { + await mkdir(output, { recursive: true }); + const generated = spawnSync("ffmpeg", [ + "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", + "testsrc2=size=320x180:rate=24", "-t", "8", "-c:v", "libx264", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", join(temp, "fixture.mp4"), + ], { encoding: "utf8" }); + assert.equal(generated.status, 0, generated.stderr || "ffmpeg is required for real player validation"); + const entry = join(temp, "fixture.tsx"); + await writeFile(entry, ` +import {useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {PwaProvider} from '${cwd}/src/components/pwa/PwaProvider'; +import {VideoPlayer} from '${cwd}/src/components/player/VideoPlayer'; +import {MobileTabbar} from '${cwd}/src/components/layout/MobileTabbar'; +import {Dialog} from '${cwd}/src/components/ui/Dialog'; +function Fixture(){ + const [provider,setProvider]=useState(true),[dialog,setDialog]=useState(false); + window.mountPwaProvider=setProvider; + return <>{provider&&} +
花魁短剧PWA
+
+
+ +
独立播放器与安全区回归
+
+
+ + {dialog&&setDialog(false)}> +
+
} + ; +} +createRoot(document.getElementById('root')).render(); +`); + await build({ + entryPoints: [entry], bundle: true, platform: "browser", format: "iife", + outfile: join(temp, "app.js"), tsconfig: join(cwd, "tsconfig.json"), + nodePaths: [join(cwd, "node_modules")], define: { "process.env.NODE_ENV": '"production"' }, + }); + const styles = ["base", "header", "play", "mobileTabbar", "eighteen", "pwa"]; + const pwaStyles = await createStyleFixture(styles); + server = createServer(async (request, response) => { + try { + const url = new URL(request.url!, origin || "http://localhost"); + requests.push(url.pathname); + if (url.pathname === pwaStyles.href) { + response.setHeader("Content-Type", "text/css;charset=utf-8"); + response.end(pwaStyles.css); + return; + } + if (url.pathname === "/network-check") { + response.setHeader("Content-Type", "application/json"); + response.setHeader("Cache-Control", "no-store"); + response.end(JSON.stringify({ count: ++networkReads })); + return; + } + if (url.pathname === "/manifest.webmanifest") { + response.setHeader("Content-Type", "application/manifest+json"); + response.end(JSON.stringify(manifest())); + return; + } + let file: string | undefined; + if (url.pathname === "/sw.js") { + file = join(cwd, "public/sw.js"); + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + } else if (url.pathname.startsWith("/static/")) { + file = resolve(cwd, "public", "." + url.pathname); + assert(file.startsWith(resolve(cwd, "public") + "/")); + } else if (["/app.js", "/app.css", "/fixture.mp4"].includes(url.pathname)) { + file = join(temp, url.pathname.slice(1)); + } + if (file) { + const bytes = await readFile(file); + response.setHeader("Content-Type", ({ ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".mp4": "video/mp4" } as Record)[extname(file)] || "application/octet-stream"); + const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || ""); + if (range) { + const start = Number(range[1]); + const end = Math.min(range[2] ? Number(range[2]) : bytes.length - 1, bytes.length - 1); + response.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${bytes.length}`, "Accept-Ranges": "bytes" }); + response.end(bytes.subarray(start, end + 1)); + } else response.end(bytes); + return; + } + if (url.pathname !== "/") { + response.writeHead(404).end(); + return; + } + response.setHeader("Content-Type", "text/html;charset=utf-8"); + response.setHeader("Cache-Control", "no-store"); + response.end(`
`); + } catch (error) { + response.writeHead(500).end(String(error)); + } + }); + await new Promise(done => server!.listen(0, "127.0.0.1", done)); + const address = server.address(); + assert(address && typeof address !== "string"); + 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 }); + + for (const [name, viewport] of [ + ["portrait", { width: 390, height: 844 }], + ["landscape", { width: 844, height: 390 }], + ["desktop", { width: 1440, height: 900 }], + ] as const) { + const context = await browser.newContext({ viewport }); + await context.route("**/*", route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); + await context.addInitScript(() => { + // tsx preserves callback names with this helper; serialized Playwright callbacks need it too. + Object.defineProperty(window, "__name", { value: Function("value", "return value"), configurable: true }); + const fixture = window as unknown as FixtureWindow; + const originalMatchMedia = window.matchMedia.bind(window); + const states = new Map(); + window.matchMedia = (query) => { + if (!/^\(display-mode: (fullscreen|standalone)\)$/.test(query)) return originalMatchMedia(query); + let state = states.get(query); + if (!state) { + const target = new EventTarget(); + const list = Object.assign(target, { media: query, onchange: null, + addListener(listener: EventListener) { target.addEventListener("change", listener); }, + removeListener(listener: EventListener) { target.removeEventListener("change", listener); }, + }) as unknown as MediaQueryList; + state = { matches: false, target, list }; + const current = state; + Object.defineProperty(list, "matches", { get: () => current.matches }); + states.set(query, state); + } + return state.list; + }; + fixture.setPwaMode = (mode, active) => { + const query = `(display-mode: ${mode})`; + window.matchMedia(query); + const state = states.get(query)!; + state.matches = active; + state.target.dispatchEvent(Object.assign(new Event("change"), { matches: active, media: query })); + }; + let ios = false; + Object.defineProperty(navigator, "standalone", { configurable: true, get: () => ios }); + fixture.setIosStandalone = active => { + ios = active; + fixture.setPwaMode("fullscreen", states.get("(display-mode: fullscreen)")?.matches || false); + }; + let native = document.fullscreenEnabled; + Object.defineProperty(document, "fullscreenEnabled", { configurable: true, get: () => native }); + Object.defineProperty(document, "webkitFullscreenEnabled", { configurable: true, get: () => false }); + fixture.setNativeFullscreen = active => { native = active; }; + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto(origin, { waitUntil: "networkidle" }); + await page.waitForFunction(() => !!navigator.serviceWorker.controller && !!document.querySelector("video")).catch(async error => { + console.error({ errors, requests, state: await page.evaluate(async () => ({ secure: window.isSecureContext, video: !!document.querySelector("video"), controller: navigator.serviceWorker.controller?.scriptURL, registrations: (await navigator.serviceWorker.getRegistrations()).map(registration => ({ scope: registration.scope, active: registration.active?.state, installing: registration.installing?.state })) })) }); + throw error; + }); + await waitForInstalled(page, false); + const initial = await page.evaluate(() => { + const style = getComputedStyle(document.body); + const header = document.querySelector("#fixed-header")!.getBoundingClientRect(); + return { borders: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth], headerTop: header.top, headerHeight: header.height, flowTop: document.querySelector("#flow-probe")!.getBoundingClientRect().top }; + }); + assert.deepEqual(initial.borders, ["0px", "0px", "0px", "0px"], `${name}: normal browser keeps zero safe-area borders`); + assert.equal(initial.headerTop, 0); + const sw = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready; + const a = await (await fetch("/network-check")).json(); + const b = await (await fetch("/network-check")).json(); + return { scope: registration.scope, updateViaCache: registration.updateViaCache, caches: await caches.keys(), a: a.count, b: b.count, script: navigator.serviceWorker.controller!.scriptURL }; + }); + assert.equal(sw.scope, origin + "/"); + assert.equal(sw.script, origin + "/sw.js"); + assert.equal(sw.updateViaCache, "none"); + assert.deepEqual(sw.caches, []); + assert.equal(sw.b, sw.a + 1, "Controlled requests continue reaching the network"); + for (const mode of ["standalone", "fullscreen"] as const) { + await page.evaluate(mode => (window as unknown as FixtureWindow).setPwaMode(mode, true), mode); + await waitForInstalled(page, true); + await page.evaluate(mode => (window as unknown as FixtureWindow).setPwaMode(mode, false), mode); + await waitForInstalled(page, false); + } + await page.evaluate(() => (window as unknown as FixtureWindow).setIosStandalone(true)); + await waitForInstalled(page, true); + await page.evaluate(() => { + for (const [side, value] of Object.entries({ top: 44, right: 20, bottom: 34, left: 20 })) document.documentElement.style.setProperty("--pwa-safe-" + side, value + "px"); + }); + const safe = await page.evaluate(() => { + const rect = (selector: string) => { const r = document.querySelector(selector)!.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom }; }; + const tab = document.querySelector(".hk-mobile-tabbar")!; + const body = getComputedStyle(document.body); + return { header: rect("#fixed-header"), flow: rect("#flow-probe"), tab: rect(".hk-mobile-tabbar"), tabDisplay: getComputedStyle(tab).display, tabPadding: getComputedStyle(tab).paddingBottom, bodyBottom: body.paddingBottom, bodyBorderBottom: body.borderBottomWidth, bodyBorders: [body.borderTopWidth, body.borderRightWidth, body.borderLeftWidth], scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }; + }); + assert.equal(safe.header.y, 44); + assert.equal(safe.header.height, initial.headerHeight, `${name}: header measurement excludes the top inset`); + assert.equal(safe.flow.y, initial.flowTop + 44, `${name}: safe-area added once to normal flow`); + assert(safe.flow.y >= safe.header.bottom, `${name}: fixed header never covers the flow boundary`); + assert.deepEqual(safe.bodyBorders, ["44px", "20px", "20px"]); + assert(safe.scrollWidth <= safe.clientWidth, `${name}: no horizontal overflow`); + if (viewport.width <= 768) { + // The account tab was removed with the login system: only 首页/短剧/剧集 remain. + assert.deepEqual( + await page.locator(".hk-mobile-tabbar__item").allTextContents(), + ["首页", "短剧", "剧集"], + ); + assert.equal(safe.tab.height, 90); + assert.equal(safe.tabPadding, "34px"); + assert.equal(safe.bodyBottom, "90px"); + assert.equal(safe.bodyBorderBottom, "0px", "Visible TabBar owns bottom safety without another body border"); + assert.equal(safe.tab.bottom, viewport.height); + } else { + assert.equal(safe.tabDisplay, "none"); + assert.equal(safe.bodyBorderBottom, "34px", "Hidden TabBar leaves bottom safety to the body border"); + } + await page.locator("#open-modal").click(); + await page.waitForSelector("#modal-panel"); + const modal = await page.locator("#modal-panel").evaluate(element => { + const r = element.getBoundingClientRect(); + return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, scroll: element.scrollHeight, client: element.clientHeight, overflow: getComputedStyle(element).overflowY }; + }); + assert(modal.top >= 44 && modal.bottom <= viewport.height - 34, `${name}: modal stays within top/bottom safe areas`); + assert(modal.left >= 20 && modal.right <= viewport.width - 20, `${name}: modal stays within side safe areas`); + assert(modal.scroll > modal.client && modal.overflow === "auto", `${name}: tall modal remains scrollable`); + assert.equal(await page.evaluate(() => document.body.style.overflow), "hidden"); + await page.keyboard.press("Escape"); + await page.waitForSelector("#modal-panel", { state: "detached" }); + assert.notEqual(await page.evaluate(() => getComputedStyle(document.body).overflowY), "hidden"); + await page.evaluate(() => window.scrollTo(0, 0)); + if (name !== "desktop") await page.screenshot({ path: join(output, name + "-safe-area.png") }); + + await page.locator("video").evaluate(async element => { + const video = element as HTMLVideoElement; + video.muted = true; + await video.play(); + }); + await page.waitForFunction(() => document.querySelector("video")!.currentTime > 0.1); + // Installed iOS without the document fullscreen API must use the CSS fallback. + await page.evaluate(() => (window as unknown as FixtureWindow).setNativeFullscreen(false)); + const css = await fullscreen(page, true, viewport); + if (name === "portrait") await page.screenshot({ path: join(output, "portrait-css-fullscreen.png") }); + await exitFullscreen(page); + await page.evaluate(() => { window.scrollTo(0, 0); (window as unknown as FixtureWindow).setNativeFullscreen(true); }); + const native = await fullscreen(page, false, viewport); + if (name === "desktop") await page.screenshot({ path: join(output, "desktop-native-fullscreen.png") }); + await exitFullscreen(page); + await page.evaluate(() => { (window as unknown as FixtureWindow).setIosStandalone(false); for (const side of ["top", "right", "bottom", "left"]) document.documentElement.style.removeProperty("--pwa-safe-" + side); }); + await waitForInstalled(page, false); + assert.deepEqual(errors, [], `${name}: no browser runtime errors`); + results.push({ name, viewport, worker: true, modeChanges: true, safe, modal, cssFullscreen: css, nativeFullscreen: native, scrollRestored: true }); + await context.close(); + console.log(`${name}: provider modes, network-only SW, safe areas, modal, CSS/native fullscreen and scroll restoration passed`); + } + assert(!requests.some(path => path.startsWith("/api/")), "The PWA fixture never calls a production API"); + await writeFile(join(output, "results.json"), JSON.stringify(results, null, 2) + "\n"); + console.log(`PWA browser regression passed. Results: ${output}`); +} finally { + await browser?.close(); + server?.closeAllConnections(); + if (server?.listening) await new Promise(done => server!.close(() => done())); + await rm(temp, { recursive: true, force: true }); +} diff --git a/scripts/style-fixtures.ts b/scripts/style-fixtures.ts new file mode 100644 index 0000000..1a6d76c --- /dev/null +++ b/scripts/style-fixtures.ts @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Legacy fixture keys stay inside test infrastructure. Production imports semantic CSS paths. +const sourceStyles: Record = { + "about-mobile": "src/styles/pages/about-responsive.css", + "about": "src/styles/pages/about.css", + "ads": "src/styles/components/advertising-compat.css", + "base": "src/styles/foundations/base.css", + "dialogAd": "src/styles/components/advertisement-dialog.css", + "downloadPop": "src/styles/components/app-download-dialog.css", + "eighteen": "src/styles/components/age-gate.css", + "feedback": "src/styles/pages/feedback.css", + "footer": "src/styles/components/site-footer.css", + "header": "src/styles/components/site-header.css", + "help": "src/styles/pages/help.css", + "index": "src/styles/pages/home.css", + "installGuide": "src/styles/components/installation-guide.css", + "legal": "src/styles/pages/legal.css", + "list": "src/styles/pages/drama-list.css", + "mobile-search": "src/styles/pages/mobile-search.css", + "mobileTabbar": "src/styles/components/mobile-tabbar.css", + "moreList": "src/styles/pages/expanded-list.css", + "nav": "src/styles/pages/site-navigation.css", + "notFound": "src/styles/pages/not-found.css", + "pagination": "src/styles/components/pagination.css", + "play": "src/styles/pages/playback.css", + "playLinks": "src/styles/components/playback-links.css", + "popularRecommend": "src/styles/pages/popular-recommendations.css", + "promo-grid": "src/styles/components/promotional-grid.css", + "promo": "src/styles/components/promotional-banner.css", + "pwa": "src/styles/overrides/pwa-safe-area.css", + "rank": "src/styles/pages/rankings.css", + "recentlyAdded": "src/styles/pages/recently-added.css", + "search": "src/styles/pages/search-results.css", + "series": "src/styles/pages/series-list.css", + "tags": "src/styles/pages/tag-detail.css", + "toast": "src/styles/components/toast.css", + "topic": "src/styles/pages/topics.css", + "topicDetail": "src/styles/pages/topic-detail.css", + "player": "src/styles/components/video-player.css" +}; + +export async function createStyleFixture(keys: readonly string[]) { + const root = fileURLToPath(new URL("..", import.meta.url)); + const layout = await readFile(join(root, "src/app/layout.tsx"), "utf8"); + const order = [...layout.matchAll(/^import ["']@\/([^"']+\.css)["'];/gm)].map((match) => "src/" + match[1]); + const files = [...new Set(keys.map((key) => { + const file = sourceStyles[key]; + if (!file) throw new Error("Unknown fixture stylesheet: " + key); + return file; + }))].sort((first, second) => order.indexOf(first) - order.indexOf(second)); + for (const file of files) { + if (!order.includes(file)) throw new Error("Fixture stylesheet is not imported by the root layout: " + file); + } + const css = (await Promise.all(files.map((file) => readFile(join(root, file), "utf8")))).join("\n"); + return { + css, + href: "/fixture-" + createHash("sha256").update(css).digest("hex").slice(0, 12) + ".css", + tokens: files.map((file) => basename(file, ".css")).join(" "), + }; +} diff --git a/scripts/sync-figma-assets.ts b/scripts/sync-figma-assets.ts new file mode 100644 index 0000000..daf0c71 --- /dev/null +++ b/scripts/sync-figma-assets.ts @@ -0,0 +1,93 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { dirname, resolve } from 'node:path'; +import { brandAssets } from '../src/lib/brand-assets'; + +// User-provided design. Credentials are read only from the local environment. +const fileKey = '1vUq4wf5xOs8ZOlmt26Q3O'; +const token = process.env.FIGMA_TOKEN; +const assets = [ + { id: '1:414', name: 'header', file: 'public/static/brand/header.svg', width: 104, height: 36 }, + { id: '1:706', name: 'footer', file: 'public/static/brand/footer.svg', width: 124, height: 44 }, + { id: '3:20211', name: 'mobile', file: 'public/static/brand/mobile.svg', width: 88, height: 28 }, + { id: '3:15722', name: 'symbol', file: 'public/static/favicon.svg', width: 34, height: 34 }, +] as const; + +const manifestFile = resolve('public/static/brand/manifest.json'); +const pngAssets = [ + { id: '1:414', name: 'header-png', file: 'public/static/logo.png', width: 208, height: 72 }, + { id: '1:706', name: 'footer-png', file: 'public/static/logo2.png', width: 248, height: 88 }, + { id: '3:21847', name: 'login-symbol', file: 'public/static/logo_icon.png', width: 128, height: 128 }, + { id: '3:15722', name: 'favicon-png', file: 'public/static/favicon.png', width: 68, height: 68 }, + { id: '3:15722', name: 'favicon-ico', file: 'public/static/favicon.ico', width: 68, height: 68 }, +]; +const alias = (asset: typeof pngAssets[number], url: string) => ({ + ...asset, name: `${asset.name}-versioned`, source: asset.file, file: `public${url}`, +}); +const versionedAssets = [ + alias(pngAssets[0]!, brandAssets.headerPng), + alias(pngAssets[1]!, brandAssets.navigationLogo), + alias(pngAssets[2]!, brandAssets.authLogo), + alias(assets[3], brandAssets.iconSvg), + alias(pngAssets[3]!, brandAssets.iconPng), + alias(pngAssets[4]!, brandAssets.faviconIco), +]; +const sha256 = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex'); +if (process.argv.includes('--check')) { + const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) as { assets: { file: string; sha256: string; width: number; height: number }[] }; + for (const asset of manifest.assets) { + const bytes = await readFile(resolve(asset.file)); + if (sha256(bytes) !== asset.sha256) throw new Error(`Brand asset checksum mismatch: ${asset.file}`); + if (asset.file.endsWith('.png') && (bytes.readUInt32BE(16) !== asset.width || bytes.readUInt32BE(20) !== asset.height)) throw new Error(`PNG dimensions mismatch: ${asset.file}`); + } + for (const asset of versionedAssets) { + if (!manifest.assets.some(entry => entry.file === asset.file)) throw new Error(`Versioned brand asset missing from manifest: ${asset.file}`); + if (!(await readFile(resolve(asset.file))).equals(await readFile(resolve(asset.source)))) throw new Error(`Versioned brand asset differs from source: ${asset.file}`); + } + const ico = await readFile(resolve('public/static/favicon.ico')); + const png = await readFile(resolve('public/static/favicon.png')); + if (!ico.subarray(22).equals(png)) throw new Error('ICO payload differs from exported PNG'); + console.log(`Checked ${manifest.assets.length} Figma brand assets offline.`); + process.exit(0); +} +if (!token) throw new Error('FIGMA_TOKEN is required'); + +async function render(ids: readonly string[], format: 'svg' | 'png', scale = 1): Promise> { + const query = new URLSearchParams({ ids: ids.join(','), format, scale: String(scale), svg_outline_text: 'true' }); + const response = await fetch(`https://api.figma.com/v1/images/${fileKey}?${query}`, { headers: { 'X-Figma-Token': token! } }); + if (!response.ok) throw new Error(`Figma render returned HTTP ${response.status}`); + const result = await response.json() as { images: Record }; + for (const id of ids) if (!result.images[id]) throw new Error(`Figma returned no image for ${id}`); + return result.images as Record; +} +async function download(url: string, file: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`Asset download returned HTTP ${response.status}`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (!bytes.length) throw new Error('Empty Figma asset'); + await mkdir(dirname(resolve(file)), { recursive: true }); + await writeFile(resolve(file), bytes); + return bytes; +} + +const svg = await render(assets.map(asset => asset.id), 'svg'); +for (const asset of assets) await download(svg[asset.id], asset.file); +const png = await render(['1:414', '1:706', '3:21847', '3:15722'], 'png', 2); +await download(png['1:414'], 'public/static/logo.png'); +await download(png['1:706'], 'public/static/logo2.png'); +await download(png['3:21847'], 'public/static/logo_icon.png'); +const icon = await download(png['3:15722'], 'public/static/favicon.png'); +// ICO supports an unmodified PNG payload; no redraw, recoloring or raster resampling. +const icoHeader = Buffer.alloc(22); +icoHeader.writeUInt16LE(1, 2); icoHeader.writeUInt16LE(1, 4); +icoHeader[6] = 68; icoHeader[7] = 68; +icoHeader.writeUInt16LE(1, 10); icoHeader.writeUInt16LE(32, 12); +icoHeader.writeUInt32LE(icon.length, 14); icoHeader.writeUInt32LE(22, 18); +await writeFile(resolve('public/static/favicon.ico'), Buffer.concat([icoHeader, icon])); +for (const asset of versionedAssets) { + await mkdir(dirname(resolve(asset.file)), { recursive: true }); + await writeFile(resolve(asset.file), await readFile(resolve(asset.source))); +} +const manifestAssets = await Promise.all([...assets, ...pngAssets, ...versionedAssets].map(async asset => ({ ...asset, sha256: sha256(await readFile(resolve(asset.file))) }))); +await writeFile(manifestFile, JSON.stringify({ fileKey, source: `https://www.figma.com/design/${fileKey}/?node-id=0-1`, assets: manifestAssets }, null, 2) + '\n'); +console.log(`Downloaded ${assets.length} SVG brand assets and matching PNG/ICO fallbacks from Figma; generated ${versionedAssets.length} versioned aliases.`); diff --git a/scripts/sync-figma-icons.ts b/scripts/sync-figma-icons.ts new file mode 100644 index 0000000..d9adbe4 --- /dev/null +++ b/scripts/sync-figma-icons.ts @@ -0,0 +1,151 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +type Size = { width: number; height: number }; +type IconAsset = { + file: string; + nodeId: string; + role: string; + sourcePath: string; + sourceSize: Size; + size: Size; + palette: string[]; + sha256?: string; + usedBy?: string[]; +}; +type RetainedIcon = { file: string; size: Size; sha256: string; reason: string; usedBy?: string[] }; +type IconManifest = { fileKey: string; assets: IconAsset[]; retained: RetainedIcon[] }; + +const manifestPath = resolve('scripts/figma-icon-manifest.json'); +const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as IconManifest; +const verifyOnly = process.argv.includes('--check'); +const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex'); + +async function refreshReferences(): Promise { + async function files(directory: string): Promise { + const entries = await readdir(directory, {withFileTypes: true}); + return (await Promise.all(entries.map(entry => entry.isDirectory() ? files(directory + '/' + entry.name) : Promise.resolve([directory + '/' + entry.name])))).flat(); + } + const paths = [...await files('src')].filter(path => /\.(?:tsx?|css)$/.test(path)); + const sources = await Promise.all(paths.map(async path => ({path, text:await readFile(path,'utf8')}))); + for (const asset of [...manifest.assets,...manifest.retained]) { + const url = asset.file.replace(/^public/, ''); + const name = url.replace(/^\/static\/images\//, '').replace(/\.svg$/, ''); + const literal = new RegExp('["\x27`]' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '["\x27`]'); + asset.usedBy = sources.filter(source => source.text.includes(url) || literal.test(source.text)).map(source => source.path).sort(); + } + await writeFile(manifestPath, JSON.stringify(manifest,null,2)+'\n'); + console.log('Refreshed Figma icon references from native TS/TSX and CSS.'); +} + +function svgRoot(svg: string): string { + const root = svg.match(/]*>/i)?.[0]; + if (!root || !/<\/(?:[\w-]+:)?svg\s*>/i.test(svg)) throw new Error('Invalid or empty SVG document'); + return root; +} + +function attribute(root: string, name: string): string { + return root.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i'))?.[1] ?? ''; +} + +function validateSVG(asset: IconAsset | RetainedIcon, bytes: Buffer, checkHash: boolean): void { + const svg = bytes.toString('utf8'); + const root = svgRoot(svg); + if (!/<(?:path|rect|circle|ellipse|line|polyline|polygon|text|use)\b/i.test(svg)) throw new Error('SVG has no visible geometry'); + for (const [name, value] of Object.entries(asset.size)) { + if (Number(attribute(root, name).replace(/px$/, '')) !== value) throw new Error(`Incorrect root ${name}`); + if ('sourceSize' in asset && Math.abs(asset.sourceSize[name as keyof Size] - value) > 0.01) throw new Error(`Root ${name} differs from the Figma node`); + } + const viewBox = attribute(root, 'viewBox').trim().split(/[\s,]+/).map(Number); + if (viewBox.length !== 4 || !viewBox.every(Number.isFinite) || viewBox[2] <= 0 || viewBox[3] <= 0) throw new Error('Invalid SVG viewBox'); + if (Math.abs(viewBox[2] / viewBox[3] - asset.size.width / asset.size.height) > 0.00001) throw new Error('SVG aspect ratio would distort the icon'); + if (checkHash && asset.sha256 && hash(bytes) !== asset.sha256) throw new Error('SVG differs from its recorded Figma export or retained original'); + if ('palette' in asset) { + const colors = new Set((svg.match(/#[\da-f]{6}\b|#[\da-f]{3}\b/gi) ?? []).map(value => { + const lower = value.toLowerCase(); + return lower.length === 4 ? '#' + [...lower.slice(1)].map(char => char + char).join('') : lower; + })); + if (/\b(?:fill|stroke)=["']white["']/i.test(svg)) colors.add('#ffffff'); + for (const color of colors) if (!asset.palette.includes(color)) throw new Error(`Paint ${color} does not match the Figma node`); + } +} + +async function verifyLocal(): Promise { + for (const asset of [...manifest.assets, ...manifest.retained]) { + try { + validateSVG(asset, await readFile(resolve(asset.file)), true); + } catch (error) { + throw new Error(`${asset.file}: ${(error as Error).message}`); + } + } + console.log(`Verified ${manifest.assets.length} Figma SVG exports and ${manifest.retained.length} retained icons: geometry, palette, dimensions and hashes.`); +} + +async function request(url: string, headers?: Record): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + let response: Response; + try { + response = await fetch(url, { headers, signal: AbortSignal.timeout(60_000) }); + } catch { + if (attempt < 2) continue; + throw new Error('Figma asset request failed'); + } + if (response.ok) return response; + if ((response.status === 429 || response.status >= 500) && attempt < 2) { + const delay = Math.min(30, Math.max(1, Number(response.headers.get('retry-after')) || 3)); + await new Promise(resolveDelay => setTimeout(resolveDelay, delay * 1000)); + continue; + } + throw new Error(`Figma asset request returned HTTP ${response.status}`); + } + throw new Error('Figma asset request failed'); +} + +async function syncIcons(): Promise { + const token = process.env.FIGMA_TOKEN; + if (!token) throw new Error('FIGMA_TOKEN is required to synchronize icons; --check works offline'); + const ids = [...new Set(manifest.assets.map(asset => asset.nodeId))]; + const exported = new Map(); + for (let offset = 0; offset < ids.length; offset += 30) { + const batch = ids.slice(offset, offset + 30); + const query = new URLSearchParams({ + ids: batch.join(','), format: 'svg', svg_outline_text: 'true', use_absolute_bounds: 'true', + }); + const response = await request(`https://api.figma.com/v1/images/${manifest.fileKey}?${query}`, { 'X-Figma-Token': token }); + const result = await response.json() as { images?: Record }; + // Signed download URLs and credentials are deliberately never logged or saved. + await Promise.all(batch.map(async id => { + const url = result.images?.[id]; + if (!url) throw new Error(`Figma returned no SVG for node ${id}`); + exported.set(id, Buffer.from(await (await request(url)).arrayBuffer())); + })); + console.log(`Downloaded Figma SVG batch ${Math.floor(offset / 30) + 1}: ${batch.length} nodes.`); + } + // Save complete REST response bytes: no root, whitespace, geometry or paint edits. + // The exported intrinsic size is recorded here; display size belongs to CSS/HTML. + const pending = manifest.assets.map(previous => { + const bytes = exported.get(previous.nodeId)!; + const root = svgRoot(bytes.toString('utf8')); + const asset = { ...previous, size: { width: Number(attribute(root, 'width').replace(/px$/, '')), height: Number(attribute(root, 'height').replace(/px$/, '')) } }; + try { + validateSVG(asset, bytes, false); + } catch (error) { + throw new Error(`${asset.file} (node ${asset.nodeId}): ${(error as Error).message}`); + } + return { asset, bytes }; + }); + for (const { asset, bytes } of pending) { + await mkdir(dirname(resolve(asset.file)), { recursive: true }); + await writeFile(resolve(asset.file), bytes); + asset.sha256 = hash(bytes); + } + manifest.assets = pending.map(({ asset }) => asset); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + await refreshReferences(); + await verifyLocal(); +} + +if (process.argv.includes('--refresh-references')) await refreshReferences(); +else if (verifyOnly) await verifyLocal(); +else await syncIcons(); diff --git a/scripts/sync-sitemap.ts b/scripts/sync-sitemap.ts new file mode 100644 index 0000000..47c9d00 --- /dev/null +++ b/scripts/sync-sitemap.ts @@ -0,0 +1,223 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, readFile, rename, rm, stat, utimes } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, posix, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { parse } from "dotenv"; + +export interface SitemapSyncConfig { + host: string; + port: number; + sourceDirectory: string; + destinationDirectory: string; +} +export type SitemapTransfer = (name: string, destination: string) => Promise; +const shardName = /^sitemap-[a-z0-9-]+\.xml$/; +const execute = promisify(execFile); + +function validated(config: SitemapSyncConfig): SitemapSyncConfig { + if (!/^(?:[A-Za-z_][A-Za-z0-9_.-]*@)?(?:[A-Za-z0-9][A-Za-z0-9.-]*|\[[A-Fa-f0-9:]+\])$/.test(config.host)) + throw new Error("SITEMAP_SYNC_HOST must be an SSH host, optionally prefixed with a username"); + if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) + throw new Error("SITEMAP_SYNC_PORT must be an integer between 1 and 65535"); + if (!/^\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+\/?$/.test(config.sourceDirectory) || config.sourceDirectory.split("/").some((part) => part === "." || part === "..")) + throw new Error("SITEMAP_SYNC_DIR must be an absolute remote directory without shell syntax or traversal"); + if (!config.destinationDirectory || /[\0\r\n]/.test(config.destinationDirectory)) + throw new Error("SITEMAP_DIR must identify a local directory"); + const destinationDirectory = resolve(config.destinationDirectory); + if (dirname(destinationDirectory) === destinationDirectory) + throw new Error("SITEMAP_DIR cannot be a filesystem root"); + return { ...config, sourceDirectory: posix.normalize(config.sourceDirectory), destinationDirectory }; +} + +/** This explicit developer command always loads development configuration; it is never run by a request handler. */ +export async function readSitemapSyncConfig(root = process.cwd(), environment: Record = process.env): Promise { + if (environment.APP_ENV && environment.APP_ENV !== "development") + throw new Error("sitemap:sync only supports APP_ENV=development; do not run inside a test or production service environment"); + const settings = async (file: string) => { + try { return parse(await readFile(join(root, file))); } + catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw error; } + }; + const inherited = Object.fromEntries(Object.entries(environment).filter((entry) => entry[1] !== undefined)); + const values = { ...await settings("config/development.env"), ...await settings("config/development.local.env"), ...inherited }; + if (values.APP_ENV && values.APP_ENV !== "development") + throw new Error("sitemap:sync requires development configuration"); + const port = (values.SITEMAP_SYNC_PORT || "22").trim(); + if (!/^\d+$/.test(port)) throw new Error("SITEMAP_SYNC_PORT must be numeric"); + return validated({ + host: (values.SITEMAP_SYNC_HOST || "").trim(), + port: Number(port), + sourceDirectory: (values.SITEMAP_SYNC_DIR || "").trim(), + destinationDirectory: resolve(root, values.SITEMAP_DIR?.trim() || "./template/pages/sitemaps"), + }); +} + +interface XMLNode { name: string; children: XMLNode[]; text: string } +const xmlWhitespace = /^[ \t\r\n]*$/; +const xmlName = "[A-Za-z_][A-Za-z0-9_.-]*(?::[A-Za-z_][A-Za-z0-9_.-]*)?"; +function decodeXML(value: string): string { + return value.replace(/&([^;]*);|&/g, (_all, entity: string | undefined) => { + const named: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }; + if (entity && Object.hasOwn(named, entity)) return named[entity]!; + const numeric = entity && /^(?:#\d+|#x[0-9a-fA-F]+)$/.test(entity) + ? Number.parseInt(entity.slice(entity[1] === "x" ? 2 : 1), entity[1] === "x" ? 16 : 10) : NaN; + if (!Number.isInteger(numeric) || numeric > 0x10ffff || (numeric >= 0xd800 && numeric <= 0xdfff) || (numeric < 0x20 && ![9, 10, 13].includes(numeric)) || [0xfffe, 0xffff].includes(numeric)) + throw new Error("Invalid XML entity"); + return String.fromCodePoint(numeric); + }); +} + +/** Validate well-formed sitemap XML without a network-capable parser or external entities. Bytes are never serialized again. */ +function document(bytes: Uint8Array, file: string): XMLNode { + try { + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\ufeff/, ""); + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/u.test(source) || / { + if (!stack.length) { if (cdata || !xmlWhitespace.test(value)) throw new Error("XML text outside its root"); return; } + if (!cdata && value.includes("]]>")) throw new Error("Invalid XML text"); + stack[stack.length - 1]!.text += cdata ? value : decodeXML(value); + }; + while (position < source.length) { + const start = source.indexOf("<", position); + if (start < 0) { text(source.slice(position)); break; } + text(source.slice(position, start)); + if (source.startsWith("", start + 4); + const contents = source.slice(start + 4, end); + if (end < 0 || contents.includes("--") || contents.endsWith("-")) throw new Error("Invalid XML comment"); + position = end + 3; continue; + } + if (source.startsWith("", start + 9); + if (end < 0) throw new Error("Unclosed XML CDATA"); + text(source.slice(start + 9, end), true); position = end + 3; continue; + } + if (source.startsWith("", start + 2); + if (end < 0) throw new Error("Unclosed XML processing instruction"); + const instruction = source.slice(start + 2, end); + const target = new RegExp(`^(${xmlName})(?:[ \\t\\r\\n]+[\\s\\S]*)?$`).exec(instruction)?.[1]; + if (!target) throw new Error("Invalid XML processing instruction"); + if (/^xml$/i.test(target) && (start !== 0 || !/^xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?(?:\s+standalone\s*=\s*(["'])(?:yes|no)\3)?\s*$/i.test(instruction) || target !== "xml")) + throw new Error("Invalid XML declaration"); + position = end + 2; continue; + } + let end = start + 1, quote = ""; + for (; end < source.length; end++) { + const character = source[end]!; + if (quote) { if (character === quote) quote = ""; } + else if (character === '"' || character === "'") quote = character; + else if (character === ">") break; + } + if (end === source.length) throw new Error("Unclosed XML tag"); + const tag = source.slice(start + 1, end); + if (tag.startsWith("/")) { + const match = new RegExp(`^/(${xmlName})[ \\t\\r\\n]*$`).exec(tag); + if (!match || stack.pop()?.name !== match[1]) throw new Error("Mismatched XML closing tag"); + } else { + const selfClosing = tag.endsWith("/"), value = selfClosing ? tag.slice(0, -1) : tag; + const name = new RegExp(`^(${xmlName})`).exec(value); + if (!name) throw new Error("Invalid XML tag"); + let attributes = value.slice(name[0].length); + const seen = new Set(); + while (!xmlWhitespace.test(attributes)) { + const attribute = new RegExp(`^[ \\t\\r\\n]+(${xmlName})[ \\t\\r\\n]*=[ \\t\\r\\n]*(?:"([^"<]*)"|'([^'<]*)')`).exec(attributes); + if (!attribute || seen.has(attribute[1]!)) throw new Error("Invalid XML attribute"); + seen.add(attribute[1]!); decodeXML(attribute[2] ?? attribute[3] ?? ""); + attributes = attributes.slice(attribute[0].length); + } + const node: XMLNode = { name: name[1]!, children: [], text: "" }; + if (stack.length) stack[stack.length - 1]!.children.push(node); + else { if (root) throw new Error("Multiple XML roots"); root = node; } + if (!selfClosing) stack.push(node); + } + position = end + 1; + } + if (!root || stack.length) throw new Error("Incomplete XML document"); + return root; + } catch { throw new Error(`${file} is not valid UTF-8 sitemap XML`); } +} +const localName = (node: XMLNode) => node.name.split(":").at(-1); +function location(item: XMLNode, file: string): URL { + const locations = item.children.filter((child) => localName(child) === "loc"); + if (locations.length !== 1 || locations[0]!.children.length || !xmlWhitespace.test(item.text)) + throw new Error(`${file} contains an invalid location entry`); + let url: URL; + try { url = new URL(locations[0]!.text.trim()); } catch { throw new Error(`${file} contains an invalid location URL`); } + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) + throw new Error(`${file} contains an unsupported location URL`); + return url; +} +function shardNames(bytes: Uint8Array): string[] { + const root = document(bytes, "sitemap.xml"); + if (localName(root) !== "sitemapindex" || !xmlWhitespace.test(root.text)) throw new Error("sitemap.xml must contain a sitemapindex"); + const names = new Set(); + for (const item of root.children) { + if (localName(item) !== "sitemap") throw new Error("sitemap.xml contains an invalid sitemap entry"); + const url = location(item, "sitemap.xml"); + const name = /^\/sitemaps\/([^/]+)$/.exec(url.pathname)?.[1]; + if (!name || !shardName.test(name) || !["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) + throw new Error("sitemap.xml contains an unsupported shard path"); + names.add(name); + } + return [...names]; +} +function validateShard(bytes: Uint8Array, file: string): void { + const root = document(bytes, file); + if (localName(root) !== "urlset" || !xmlWhitespace.test(root.text)) throw new Error(`${file} must contain a urlset`); + for (const item of root.children) { + if (localName(item) !== "url") throw new Error(`${file} contains an invalid url entry`); + location(item, file); + } +} + +function sshTransfer(config: SitemapSyncConfig): SitemapTransfer { + return async (name, destination) => { + if (name !== "sitemap.xml" && !shardName.test(name)) throw new Error("Invalid sitemap transfer filename"); + try { + await execute("scp", ["-p", "-q", "-P", String(config.port), "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=15", "--", `${config.host}:${posix.join(config.sourceDirectory, name)}`, destination], { timeout: 120_000, maxBuffer: 1024 * 1024 }); + } catch { throw new Error(`Unable to read remote ${name} over SSH`); } + }; +} + +/** Download and validate a complete generation before replacing any local XML; install its original index last. */ +export async function syncSitemaps(settings: SitemapSyncConfig, transfer?: SitemapTransfer) { + const config = validated(settings), copy = transfer || sshTransfer(config); + const temporary = await mkdtemp(join(tmpdir(), "huakui-sitemap-sync-")); + let installation: string | undefined; + try { + await copy("sitemap.xml", join(temporary, "sitemap.xml")); + const index = await readFile(join(temporary, "sitemap.xml")), names = shardNames(index); + for (const name of names) { + await copy(name, join(temporary, name)); + validateShard(await readFile(join(temporary, name)), name); + } + await copy("sitemap.xml", join(temporary, "index-check.xml")); + if (!index.equals(await readFile(join(temporary, "index-check.xml")))) throw new Error("Remote sitemap index changed during synchronization; retry the command"); + await mkdir(config.destinationDirectory, { recursive: true }); + installation = await mkdtemp(join(config.destinationDirectory, ".sitemap-sync-")); + const files = [...names, "sitemap.xml"]; + for (const name of files) { + const source = join(temporary, name), destination = join(installation, name), info = await stat(source); + if (!info.isFile()) throw new Error(`${name} is not a regular file`); + await copyFile(source, destination); + await utimes(destination, info.atime, info.mtime); + } + for (const name of files) await rename(join(installation, name), join(config.destinationDirectory, name)); + return { directory: config.destinationDirectory, files }; + } finally { + if (installation) await rm(installation, { recursive: true, force: true }); + await rm(temporary, { recursive: true, force: true }); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + if (process.argv.length > 2) throw new Error("Usage: npm run sitemap:sync (configure development.env, development.local.env or process environment)"); + const result = await syncSitemaps(await readSitemapSyncConfig()); + console.log(`Synchronized ${result.files.length} backend XML files to ${result.directory}; original bytes and modification times preserved.`); + } catch (error) { console.error(error instanceof Error ? error.message : "Sitemap synchronization failed"); process.exitCode = 1; } +} diff --git a/scripts/tracker-smoke.ts b/scripts/tracker-smoke.ts new file mode 100644 index 0000000..f5fbeab --- /dev/null +++ b/scripts/tracker-smoke.ts @@ -0,0 +1,170 @@ +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> | undefined; +let server: ReturnType | undefined; + +try { + await build({ + stdin: { + contents: `import {createRoot} from 'react-dom/client'; + import {TrackerProvider} from './src/components/analytics/TrackerProvider'; + createRoot(document.getElementById('root')).render( + + 广告验证 + );`, + 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( + 'Tracker fixture
', + ); + } else response.writeHead(404).end(); + }); + await new Promise((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[] = []; + 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[] = []; + 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); + } + } + 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((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"); +} diff --git a/scripts/ui-check.ts b/scripts/ui-check.ts new file mode 100644 index 0000000..e10257d --- /dev/null +++ b/scripts/ui-check.ts @@ -0,0 +1,254 @@ +import { chromium } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const origin = process.env.SMOKE_URL || "http://localhost:25131"; +const output = process.env.UI_OUTPUT_DIR || "/tmp/hkdj-figma/implementation"; +const allowEmptyContent = process.env.UI_ALLOW_EMPTY_CONTENT === "1"; +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ + executablePath: + process.env.CHROME_PATH || + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: true, +}); +const results: Record[] = []; +const failures: string[] = []; +const paths = [ + "/", + "/dramas/", + "/series/", + "/collections/", + "/collection/dushi/", + "/rankings/", + "/tags/chitie/", + "/latest/", + "/featured/", + "/search/", + "/search/测试/", + "/discover/search/", + "/about/", + "/help/", + "/terms/", + "/privacy/", + "/feedback/", + "/2257/", + "/dmca/", + "/navigation/", + "/__ui_missing_page__/", +]; +// The account system was removed; every former account page permanently returns home. +const removedAccountPaths = [ + "/account/", + "/account/history/", + "/account/favorites/", + "/account/purchases/", + "/account/settings/", + "/account/recharge/", + "/account/login/", + "/account/register/", + "/login/", + "/profile/", +]; +try { + for (const [device, viewport] of [ + ["desktop", { width: 1440, height: 1000 }], + ["mobile", { width: 375, height: 812 }], + ] as const) { + const context = await browser.newContext({ + viewport, + isMobile: device === "mobile", + deviceScaleFactor: 1, + }); + await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1")); + await context.route("**/*", async (route) => { + const request = route.request(); + if ( + new URL(request.url()).origin !== origin && + (request.method() !== "GET" || request.resourceType() === "script") + ) + return route.abort(); + if ( + request.url().includes("/api/statistics/") || + request.url().includes("/api/report/") + ) + return route.fulfill({ json: { code: 200, data: {} } }); + return route.continue(); + }); + for (const path of removedAccountPaths) { + const response = await context.request.get(origin + path, { maxRedirects: 0 }); + if (response.status() !== 301 || response.headers().location !== "/") + failures.push( + `${device} ${path}: expected 301 to /, got ${response.status()} ${response.headers().location || ""}`, + ); + } + const page = await context.newPage(); + let play = ""; + for (const path of paths) { + const errors: string[] = []; + const missing: string[] = []; + const onError = (error: Error): void => { + errors.push(error.message); + }; + const onResponse = ( + response: import("@playwright/test").Response, + ): void => { + if ( + response.status() >= 400 && + response.url().startsWith(origin + "/static/") + ) + missing.push(new URL(response.url()).pathname); + }; + page.on("pageerror", onError); + page.on("response", onResponse); + try { + const response = await page.goto(origin + path, { + waitUntil: "domcontentloaded", + timeout: 40000, + }); + await page.waitForTimeout( + path === "/" || path === "/dramas/" ? 1800 : 250, + ); + 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]", + ); + if (!(await close.count()) || !(await close.first().isVisible())) + break; + await close.first().click({ force: true }); + } + if (path === "/") { + const playLink = page.locator('a[href^="/drama/"]').first(); + play = (await playLink.count()) + ? (await playLink.getAttribute("href")) || "" + : ""; + if (!play && !allowEmptyContent) + failures.push( + `${device} ${path}: no playable content; populate the upstream or use UI_ALLOW_EMPTY_CONTENT=1 for empty-state checks`, + ); + } + const layout = await page.evaluate(() => { + const boxes = Object.fromEntries( + Object.entries({ + header: ".site-header", + logo: ".site-header .brand__logo", + footer: ".site-footer", + hero: ".hero", + listHead: ".list-hero", + listFilters: ".list-filters", + firstCard: ".grid--list > :first-child", + tabbar: ".mobile-tabbar", + }).map(([key, selector]) => { + const element = document.querySelector(selector); + const rect = element?.getBoundingClientRect(); + return [ + key, + rect + ? { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + } + : null, + ]; + }), + ); + return { + width: innerWidth, + scrollWidth: document.documentElement.scrollWidth, + title: document.title, + noindex: /(?:^|[,\s])noindex(?:$|[,\s])/.test( + document.querySelector('meta[name="robots"]') + ?.content || "", + ), + background: getComputedStyle(document.body).backgroundColor, + accent: getComputedStyle(document.documentElement) + .getPropertyValue("--accent") + .trim(), + oldBrand: document.body.innerText.includes("黄果短剧"), + accountEntries: document.querySelectorAll( + "[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']", + ).length, + ...boxes, + }; + }); + const unavailableEntity = + allowEmptyContent && + response?.status() === 404 && + (path.startsWith("/collection/") || path.startsWith("/tags/")); + const expected = + path === "/__ui_missing_page__/" || unavailableEntity ? 404 : 200; + if (response?.status() !== expected) + failures.push( + `${device} ${path}: status ${response?.status()}, expected ${expected}`, + ); + if (unavailableEntity && !layout.noindex) + failures.push( + `${device} ${path}: missing entity must remain noindex`, + ); + if (layout.scrollWidth > layout.width + 1) + failures.push( + `${device} ${path}: horizontal overflow ${layout.scrollWidth}`, + ); + if (layout.accountEntries) + failures.push(`${device} ${path}: removed account entries remain`); + if (layout.oldBrand) + failures.push(`${device} ${path}: former brand remains visible`); + if (errors.length || missing.length) + failures.push( + `${device} ${path}: ${JSON.stringify({ errors, missing })}`, + ); + results.push({ + device, + path, + status: response?.status(), + unavailableEntity, + ...layout, + errors, + missing, + }); + await page.screenshot({ + path: resolve( + output, + `${device}-${path === "/" ? "home" : path.split("/").filter(Boolean).join("-")}.png`, + ), + fullPage: true, + animations: "disabled", + }); + } catch (error) { + failures.push(`${device} ${path}: ${String(error)}`); + } finally { + page.off("pageerror", onError); + page.off("response", onResponse); + } + } + if (play) { + await page.goto(origin + play, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(300); + results.push({ + device, + path: play, + player: await page.locator("[data-play-player]").count(), + xgplayer: await page.locator(".xgplayer").count(), + video: await page.locator("video").count(), + }); + await page.screenshot({ + path: resolve(output, device + "-play.png"), + fullPage: true, + animations: "disabled", + }); + } + await context.close(); + } + await writeFile( + resolve(output, "results.json"), + JSON.stringify({ results, failures }, null, 2), + ); + console.log( + JSON.stringify({ pages: results.length, failures, output }, null, 2), + ); + if (failures.length) process.exitCode = 1; +} finally { + await browser.close(); +} diff --git a/src/app/2257/[[...segments]]/page.tsx b/src/app/2257/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/2257/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/404/page.tsx b/src/app/404/page.tsx new file mode 100644 index 0000000..c88ea0a --- /dev/null +++ b/src/app/404/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from '@/server/react/page'; diff --git a/src/app/[...path]/page.tsx b/src/app/[...path]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/[...path]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/about/[[...segments]]/page.tsx b/src/app/about/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/about/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/api/[...path]/route.ts b/src/app/api/[...path]/route.ts new file mode 100644 index 0000000..65d13a3 --- /dev/null +++ b/src/app/api/[...path]/route.ts @@ -0,0 +1,10 @@ +import { handleRequest } from "@/server/handler"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const GET = handleRequest; +export const HEAD = handleRequest; +export const POST = handleRequest; +export const PUT = handleRequest; +export const PATCH = handleRequest; +export const DELETE = handleRequest; +export const OPTIONS = handleRequest; diff --git a/src/app/app/download/[[...segments]]/page.tsx b/src/app/app/download/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/app/download/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/collection/[[...segments]]/page.tsx b/src/app/collection/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/collection/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/collections/[[...segments]]/page.tsx b/src/app/collections/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/collections/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/curated/dramas/[[...segments]]/page.tsx b/src/app/curated/dramas/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/curated/dramas/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/curated/series/[[...segments]]/page.tsx b/src/app/curated/series/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/curated/series/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/discover/search/[[...segments]]/page.tsx b/src/app/discover/search/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/discover/search/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/dmca/[[...segments]]/page.tsx b/src/app/dmca/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/dmca/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/drama/[[...segments]]/page.tsx b/src/app/drama/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/drama/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/dramas/[[...segments]]/page.tsx b/src/app/dramas/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/dramas/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/enkey/route.ts b/src/app/enkey/route.ts new file mode 100644 index 0000000..65d13a3 --- /dev/null +++ b/src/app/enkey/route.ts @@ -0,0 +1,10 @@ +import { handleRequest } from "@/server/handler"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const GET = handleRequest; +export const HEAD = handleRequest; +export const POST = handleRequest; +export const PUT = handleRequest; +export const PATCH = handleRequest; +export const DELETE = handleRequest; +export const OPTIONS = handleRequest; diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..2bccb10 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,20 @@ +"use client"; +export default function ErrorPage({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+

暂时无法加载页面

+

请稍后重试。

+ + + 返回首页 + +
+ ); +} diff --git a/src/app/featured/[[...segments]]/page.tsx b/src/app/featured/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/featured/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/feedback/[[...segments]]/page.tsx b/src/app/feedback/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/feedback/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/h5/[[...path]]/route.ts b/src/app/h5/[[...path]]/route.ts new file mode 100644 index 0000000..65d13a3 --- /dev/null +++ b/src/app/h5/[[...path]]/route.ts @@ -0,0 +1,10 @@ +import { handleRequest } from "@/server/handler"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const GET = handleRequest; +export const HEAD = handleRequest; +export const POST = handleRequest; +export const PUT = handleRequest; +export const PATCH = handleRequest; +export const DELETE = handleRequest; +export const OPTIONS = handleRequest; diff --git a/src/app/healthz/route.ts b/src/app/healthz/route.ts new file mode 100644 index 0000000..65d13a3 --- /dev/null +++ b/src/app/healthz/route.ts @@ -0,0 +1,10 @@ +import { handleRequest } from "@/server/handler"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const GET = handleRequest; +export const HEAD = handleRequest; +export const POST = handleRequest; +export const PUT = handleRequest; +export const PATCH = handleRequest; +export const DELETE = handleRequest; +export const OPTIONS = handleRequest; diff --git a/src/app/help/[[...segments]]/page.tsx b/src/app/help/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/help/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/latest/[[...segments]]/page.tsx b/src/app/latest/[[...segments]]/page.tsx new file mode 100644 index 0000000..077a1cc --- /dev/null +++ b/src/app/latest/[[...segments]]/page.tsx @@ -0,0 +1,4 @@ +export { + DocumentPage as default, + generatePageMetadata as generateMetadata, +} from "@/server/react/page"; diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..1140b8d --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,87 @@ +import "xgplayer/dist/index.min.css"; +import "@/styles/components/video-player.css"; +import "@/styles/foundations/base.css"; +import "@/styles/components/site-header.css"; +import "@/styles/components/site-footer.css"; +import "@/styles/components/age-gate.css"; +import "@/styles/components/advertisement-dialog.css"; +import "@/styles/components/app-download-dialog.css"; +import "@/styles/components/installation-guide.css"; +import "@/styles/components/toast.css"; +import "@/styles/components/mobile-tabbar.css"; +import "@/styles/components/pagination.css"; +import "@/styles/components/promotional-banner.css"; +import "@/styles/components/promotional-grid.css"; +import "@/styles/components/advertising-compat.css"; +import "@/styles/components/playback-links.css"; +import "@/styles/pages/home.css"; +import "@/styles/pages/drama-list.css"; +import "@/styles/pages/recently-added.css"; +import "@/styles/pages/popular-recommendations.css"; +import "@/styles/pages/tag-detail.css"; +import "@/styles/pages/expanded-list.css"; +import "@/styles/pages/series-list.css"; +import "@/styles/pages/rankings.css"; +import "@/styles/pages/topics.css"; +import "@/styles/pages/topic-detail.css"; +import "@/styles/pages/search-results.css"; +import "@/styles/pages/about.css"; +import "@/styles/pages/about-responsive.css"; +import "@/styles/pages/help.css"; +import "@/styles/pages/feedback.css"; +import "@/styles/pages/legal.css"; +import "@/styles/pages/not-found.css"; +import "@/styles/pages/mobile-search.css"; +import "@/styles/pages/site-navigation.css"; +import "@/styles/pages/home-road.css"; +import "@/styles/pages/playback.css"; +import "@/styles/overrides/pwa-safe-area.css"; +import type { ReactNode } from "react"; +import { PwaProvider } from "@/components/pwa/PwaProvider"; +import { pwaViewport } from "@/lib/pwa/config"; +import { getRequestPage } from "@/server/react/request-page"; +import { toNextMetadata } from "@/server/seo"; +import { presentations } from "@/server/react/presentation"; +export async function generateMetadata() { + return toNextMetadata((await getRequestPage()).data); +} +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const viewport = pwaViewport; +export default async function RootLayout({ + children, +}: { + children: ReactNode; +}) { + const { view, data } = await getRequestPage(), + presentation = presentations[view] || presentations["404"]; + const styleTokens = presentation.styleTokens.join(" "); + return ( + + + {["play", "seriesPlay"].includes(view) && ( + <> + + + + + + + + + )} + {data.jsonLD && ( + ", "pageKey": "tags", "pageName": "标签", "siteName": "花魁", "siteURL": "https://drama.example", "canonicalURL": "https://drama.example/tags/", "ogType": "website", "robots": "index, follow", "appConfig": {"reportSdkConfig": "sdk-config", "appAddr": "https://app.example/download", "newUrlAddr": "https://drama.example", "detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}]}, "listCount": "共14部", "list": [{"id": 1, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 2, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 3, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 4, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 5, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 6, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 7, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 8, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 9, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 10, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 11, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 12, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 13, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 14, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}], "tagSlug": "", "tagId": 0, "sortType": 1, "config": {"appAddr": "https://app.example/download", "customerServiceHost": "https://service.example", "twitterAddr": "https://x.example/huakui"}, "advitise": {"detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}], "nineGridList": []}, "requestPath": "/list/", "themeSlug": "", "themeId": 0, "updateStatus": 0, "categoryList": [{"id": 1, "name": "首页", "urlValue": "/home"}, {"id": 2, "name": "短剧", "urlValue": "/playlet"}, {"id": 3, "name": "下载APP", "urlValue": "/download-app"}], "themeList": [{"id": 1, "name": "都市", "slug": "都市"}], "homeConfigPlaylets": []} \ No newline at end of file diff --git a/tests/fixtures/templates/list-go.html b/tests/fixtures/templates/list-go.html new file mode 100644 index 0000000..1a992bf --- /dev/null +++ b/tests/fixtures/templates/list-go.html @@ -0,0 +1,1116 @@ + + + + + + 花魁 & "标题" <test> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+

+ + 短剧 +

+

全部短剧

+

海量竖屏原创短剧,每日 18:00 上新

+
+
+ +
+
+ 题材 +
+ 全部 + + 都市 + +
+
+
+ 状态 + +
+
+ 排序 + +
+
+ +
+

短剧列表

+

共14部

+ + + + + + + + + + + +
+ +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+
+
+ + + + + + + + + + + diff --git a/tests/fixtures/templates/locked-data.json b/tests/fixtures/templates/locked-data.json new file mode 100644 index 0000000..a3698d2 --- /dev/null +++ b/tests/fixtures/templates/locked-data.json @@ -0,0 +1 @@ +{"metaTitle": "花魁 & \"标题\" ", "metaDescription": "短剧'说明 & 安全", "trackerAppId": "app'\"", "pageKey": "tags", "pageName": "标签", "siteName": "花魁", "siteURL": "https://drama.example", "canonicalURL": "https://drama.example/tags/", "ogType": "website", "robots": "index, follow", "appConfig": {"reportSdkConfig": "sdk-config", "appAddr": "https://app.example/download", "newUrlAddr": "https://drama.example", "detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}]}, "listCount": "共14部", "list": [{"id": 1, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 2, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 3, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 4, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 5, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 6, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 7, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 8, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 9, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 10, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 11, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 12, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 13, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 14, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}], "tagSlug": "", "tagId": 0, "sortType": 1, "config": {"appAddr": "https://app.example/download", "customerServiceHost": "https://service.example", "twitterAddr": "https://x.example/huakui"}, "advitise": {"detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}], "nineGridList": []}, "requestPath": "/list/", "themeSlug": "", "themeId": 0, "updateStatus": 0, "categoryList": [{"id": 1, "name": "首页", "urlValue": "/home"}, {"id": 2, "name": "短剧", "urlValue": "/playlet"}, {"id": 3, "name": "下载APP", "urlValue": "/download-app"}], "themeList": [{"id": 1, "name": "都市", "slug": "都市"}], "homeConfigPlaylets": [], "playlet": {"id": 1, "slug": "都市", "playName": "花魁", "title": "花魁", "cover": "https://img.example/封面.webp", "horizontalCover": "https://img.example/横封.webp", "totalEpisode": 3, "updateStatus": 1, "countCollect": 0, "countLike": 0, "heatValue": 0, "isLike": 0, "isCollect": 0, "onlineAt": "2026-10-01", "playletChapterInfo": [{"id": 10, "currentEpisode": 1, "payType": 0, "canWatch": true}, {"id": 11, "currentEpisode": 2, "payType": 1, "needUnlock": true}]}, "chapter": {"id": 10, "mediaTime": 123, "payType": 0, "needUnlock": true, "needVip": false, "countComment": 0}, "chapterID": 10, "episode": 1, "canWatch": false, "mainURL": "/h5/videos/video.m3u8", "previewURL": "", "hasPreview": false, "unlockCost": 38, "balance": 12, "balanceText": "12", "hotList": [], "guessLike": [], "related": [], "commentSize": 4, "commentTotal": 0, "commentPage": 1, "commentObjectID": 10, "commentObjectType": 2, "rechargePackages": [], "rechargePayMethods": [], "trackerDrama": {"MediaID": "10", "DramaID": "1", "DramaTitle": "花魁", "Episode": 1, "TotalEpisode": 3, "VideoID": "10", "DramaTypeID": 0, "DramaTypeName": "短剧", "RecommendTraceID": "", "DramaTagKey": "", "DramaTagName": "", "DramaDuration": 123}, "comments": [], "hotMoreURL": "/list/all/0/2/"} \ No newline at end of file diff --git a/tests/fixtures/templates/locked-go.html b/tests/fixtures/templates/locked-go.html new file mode 100644 index 0000000..9cec4ae --- /dev/null +++ b/tests/fixtures/templates/locked-go.html @@ -0,0 +1,1343 @@ + + + + + + 花魁 & "标题" <test> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 返回 + +
花魁
+ +
+ +
+
+ + +
+ +
+ +
+ + + 花魁 + + + + + + + +
+ +

第 1 集 共 3 集

+ +

金币不足

+

本集需 38 金币,当前余额 12 金币

+
+ + + + 下载 + 下载 App 免费看 + +
+ +
+ + +
+ + 正片 第 1 集 共 3 集 + 第 1 集 共 3 集 + +
+ + +
+ +
+
+ + + + + 00:00 / 02:03 +
+
+ + + + +
+
+
+ +
+ + + + + +
+ + +
+
+ +
+
+
+

花魁

+ +
+
+ 全 3 集 + 连载中 + 2026 + 1080P +
+

+ + + + 热度 0 + 收藏 0 +

+

+ + + + 热度 0 + 收藏 0 +

+
+ +
+ + 播放 + 解锁播放 + + + + + + 评论 + 反馈 + +
+ +
+
+

简介

+ +
+

暂无简介

+
+
+ +
+
+
+

选集

+ + 共 3 集 + 连载中 + + +
+ +
+ +
+ + 1 + + 2 + +
+ +
+ + + 免费集 无需登录直接播放 + 免费集直接播放 + + + + + 38 金币 / 集 解锁后永久观看 + 38 金币 / 集 永久观看 + + +
+
+ + + + + + + + +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+ + + +
+ + + + + + + +
+
+

评论

+ 暂无评论 +
+ +
+ +
+ + 0 / 200 +
+ + + +
+ +
    + +
  • +

    暂无评论

    +
  • + +
+ + +
+ + + + +
+ + + +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/templates/play-data.json b/tests/fixtures/templates/play-data.json new file mode 100644 index 0000000..134d5bd --- /dev/null +++ b/tests/fixtures/templates/play-data.json @@ -0,0 +1 @@ +{"metaTitle": "花魁 & \"标题\" ", "metaDescription": "短剧'说明 & 安全", "trackerAppId": "app'\"", "pageKey": "tags", "pageName": "标签", "siteName": "花魁", "siteURL": "https://drama.example", "canonicalURL": "https://drama.example/tags/", "ogType": "website", "robots": "index, follow", "appConfig": {"reportSdkConfig": "sdk-config", "appAddr": "https://app.example/download", "newUrlAddr": "https://drama.example", "detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}]}, "listCount": "共14部", "list": [{"id": 1, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 2, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 3, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 4, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 5, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 6, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 7, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 8, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 9, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 10, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 11, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 12, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 13, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 14, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}], "tagSlug": "", "tagId": 0, "sortType": 1, "config": {"appAddr": "https://app.example/download", "customerServiceHost": "https://service.example", "twitterAddr": "https://x.example/huakui"}, "advitise": {"detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}], "nineGridList": []}, "requestPath": "/list/", "themeSlug": "", "themeId": 0, "updateStatus": 0, "categoryList": [{"id": 1, "name": "首页", "urlValue": "/home"}, {"id": 2, "name": "短剧", "urlValue": "/playlet"}, {"id": 3, "name": "下载APP", "urlValue": "/download-app"}], "themeList": [{"id": 1, "name": "都市", "slug": "都市"}], "homeConfigPlaylets": [], "playlet": {"id": 1, "slug": "都市", "playName": "花魁", "title": "花魁", "cover": "https://img.example/封面.webp", "horizontalCover": "https://img.example/横封.webp", "totalEpisode": 3, "updateStatus": 1, "countCollect": 0, "countLike": 0, "heatValue": 0, "isLike": 0, "isCollect": 0, "onlineAt": "2026-10-01", "playletChapterInfo": [{"id": 10, "currentEpisode": 1, "payType": 0, "canWatch": true}, {"id": 11, "currentEpisode": 2, "payType": 1, "needUnlock": true}]}, "chapter": {"id": 10, "mediaTime": 123, "payType": 0, "needUnlock": false, "needVip": false, "countComment": 0}, "chapterID": 10, "episode": 1, "canWatch": true, "mainURL": "/h5/videos/video.m3u8", "previewURL": "", "hasPreview": false, "unlockCost": 0, "balance": 0, "balanceText": "0", "hotList": [], "guessLike": [], "related": [], "commentSize": 4, "commentTotal": 0, "commentPage": 1, "commentObjectID": 10, "commentObjectType": 2, "rechargePackages": [], "rechargePayMethods": [], "trackerDrama": {"MediaID": "10", "DramaID": "1", "DramaTitle": "花魁", "Episode": 1, "TotalEpisode": 3, "VideoID": "10", "DramaTypeID": 0, "DramaTypeName": "短剧", "RecommendTraceID": "", "DramaTagKey": "", "DramaTagName": "", "DramaDuration": 123}, "comments": [], "hotMoreURL": "/list/all/0/2/"} \ No newline at end of file diff --git a/tests/fixtures/templates/play-go.html b/tests/fixtures/templates/play-go.html new file mode 100644 index 0000000..1d97318 --- /dev/null +++ b/tests/fixtures/templates/play-go.html @@ -0,0 +1,1290 @@ + + + + + + 花魁 & "标题" <test> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 返回 + +
花魁
+ +
+ +
+
+ + +
+ +
+ +
+ + + 花魁 + + + + + + + +
+ + 正片 第 1 集 共 3 集 + 第 1 集 共 3 集 + +
+ + +
+ + + + + +
+ + +
+
+ +
+
+
+

花魁

+ +
+
+ 全 3 集 + 连载中 + 2026 + 1080P +
+

+ + + + 热度 0 + 收藏 0 +

+

+ + + + 热度 0 + 收藏 0 +

+
+ +
+ + 播放 + 立即播放 + + + + + + 评论 + 反馈 + +
+ +
+
+

简介

+ +
+

暂无简介

+
+
+ +
+
+
+

选集

+ + 共 3 集 + 连载中 + + +
+ +
+ +
+ + 1 + + 2 + +
+ +
+ + + 免费集 无需登录直接播放 + 免费集直接播放 + + + + + 金币解锁后永久观看 + 金币解锁 永久观看 + + +
+
+ + + + + + + + +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+ + + +
+ + + + + + + +
+
+

评论

+ 暂无评论 +
+ +
+ +
+ + 0 / 200 +
+ + + +
+ +
    + +
  • +

    暂无评论

    +
  • + +
+ + +
+ + + + +
+ + + +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/templates/preview-data.json b/tests/fixtures/templates/preview-data.json new file mode 100644 index 0000000..0405f48 --- /dev/null +++ b/tests/fixtures/templates/preview-data.json @@ -0,0 +1 @@ +{"metaTitle": "花魁 & \"标题\" ", "metaDescription": "短剧'说明 & 安全", "trackerAppId": "app'\"", "pageKey": "tags", "pageName": "标签", "siteName": "花魁", "siteURL": "https://drama.example", "canonicalURL": "https://drama.example/tags/", "ogType": "website", "robots": "index, follow", "appConfig": {"reportSdkConfig": "sdk-config", "appAddr": "https://app.example/download", "newUrlAddr": "https://drama.example", "detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}]}, "listCount": "共14部", "list": [{"id": 1, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 2, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 3, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 4, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 5, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 6, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 7, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 8, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 9, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 10, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 11, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 12, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 13, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 14, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}], "tagSlug": "", "tagId": 0, "sortType": 1, "config": {"appAddr": "https://app.example/download", "customerServiceHost": "https://service.example", "twitterAddr": "https://x.example/huakui"}, "advitise": {"detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}], "nineGridList": []}, "requestPath": "/list/", "themeSlug": "", "themeId": 0, "updateStatus": 0, "categoryList": [{"id": 1, "name": "首页", "urlValue": "/home"}, {"id": 2, "name": "短剧", "urlValue": "/playlet"}, {"id": 3, "name": "下载APP", "urlValue": "/download-app"}], "themeList": [{"id": 1, "name": "都市", "slug": "都市"}], "homeConfigPlaylets": [], "playlet": {"id": 1, "slug": "都市", "playName": "花魁", "title": "花魁", "cover": "https://img.example/封面.webp", "horizontalCover": "https://img.example/横封.webp", "totalEpisode": 3, "updateStatus": 1, "countCollect": 0, "countLike": 0, "heatValue": 0, "isLike": 0, "isCollect": 0, "onlineAt": "2026-10-01", "playletChapterInfo": [{"id": 10, "currentEpisode": 1, "payType": 0, "canWatch": true}, {"id": 11, "currentEpisode": 2, "payType": 1, "needUnlock": true}]}, "chapter": {"id": 10, "mediaTime": 123, "payType": 0, "needUnlock": true, "needVip": false, "countComment": 0}, "chapterID": 10, "episode": 1, "canWatch": false, "mainURL": "/h5/videos/video.m3u8", "previewURL": "/h5/preview.m3u8", "hasPreview": true, "unlockCost": 38, "balance": 12, "balanceText": "12", "hotList": [], "guessLike": [], "related": [], "commentSize": 4, "commentTotal": 0, "commentPage": 1, "commentObjectID": 10, "commentObjectType": 2, "rechargePackages": [], "rechargePayMethods": [], "trackerDrama": {"MediaID": "10", "DramaID": "1", "DramaTitle": "花魁", "Episode": 1, "TotalEpisode": 3, "VideoID": "10", "DramaTypeID": 0, "DramaTypeName": "短剧", "RecommendTraceID": "", "DramaTagKey": "", "DramaTagName": "", "DramaDuration": 123}, "comments": [], "hotMoreURL": "/list/all/0/2/"} \ No newline at end of file diff --git a/tests/fixtures/templates/preview-go.html b/tests/fixtures/templates/preview-go.html new file mode 100644 index 0000000..bc5a640 --- /dev/null +++ b/tests/fixtures/templates/preview-go.html @@ -0,0 +1,1349 @@ + + + + + + 花魁 & "标题" <test> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 返回 + +
花魁
+ +
+ +
+
+ + +
+ +
+ +
+ + + 花魁 + + + + + + + +
+ +

第 1 集 共 3 集

+ +

金币不足

+

本集需 38 金币,当前余额 12 金币

+
+ + + + 下载 + 下载 App 免费看 + +
+ +
+ + +
+ + 预告片 免费 + 预告片 + +
+ + +
+ +
+
+ + + + + 00:00 / 02:03 +
+
+ + + + +
+
+
+ +
+ + + + + +
+ + + +

预告片免费播放,无需登录与解锁

+ +
+
+ +
+
+
+

花魁

+ +
+
+ 全 3 集 + 连载中 + 2026 + 1080P +
+

+ + + + 热度 0 + 收藏 0 +

+

+ + + + 热度 0 + 收藏 0 +

+
+ +
+ + 播放 + 播放预告片 + + + + + + 评论 + 反馈 + +
+ +
+
+

简介

+ +
+

暂无简介

+
+
+ +
+
+
+

选集

+ + 共 3 集 + 连载中 + + +
+ +
+ +
+ + 1 + + 2 + +
+ +
+ + + 免费集 无需登录直接播放 + 免费集直接播放 + + + + + 38 金币 / 集 解锁后永久观看 + 38 金币 / 集 永久观看 + + +
+
+ + + + + + + + +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+ + + +
+ + + + + + + +
+
+

评论

+ 暂无评论 +
+ +
+ +
+ + 0 / 200 +
+ + + +
+ +
    + +
  • +

    暂无评论

    +
  • + +
+ + +
+ + + + +
+ + + +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/templates/seriesPlay-data.json b/tests/fixtures/templates/seriesPlay-data.json new file mode 100644 index 0000000..d803791 --- /dev/null +++ b/tests/fixtures/templates/seriesPlay-data.json @@ -0,0 +1 @@ +{"metaTitle": "花魁 & \"标题\" ", "metaDescription": "短剧'说明 & 安全", "trackerAppId": "app'\"", "pageKey": "tags", "pageName": "标签", "siteName": "花魁", "siteURL": "https://drama.example", "canonicalURL": "https://drama.example/tags/", "ogType": "website", "robots": "index, follow", "appConfig": {"reportSdkConfig": "sdk-config", "appAddr": "https://app.example/download", "newUrlAddr": "https://drama.example", "detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}]}, "listCount": "共14部", "list": [{"id": 1, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 2, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 3, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 4, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 5, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 6, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 7, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 8, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 9, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 10, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 11, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 12, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 13, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}, {"id": 14, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2], "heatValue": 12000, "countCollect": 1, "countLike": 2}], "tagSlug": "", "tagId": 0, "sortType": 1, "config": {"appAddr": "https://app.example/download", "customerServiceHost": "https://service.example", "twitterAddr": "https://x.example/huakui"}, "advitise": {"detailbannerList": [{"id": "a", "images": "https://img.example/banner-a.webp", "link": "https://ad.example", "title": "广告 A", "location": 1}, {"id": "empty", "images": ""}, {"id": "b", "images": "https://img.example/banner-b.webp", "link": "https://ad.example", "title": "广告 B", "location": 2}], "nineGridList": []}, "requestPath": "/list/", "themeSlug": "", "themeId": 0, "updateStatus": 0, "categoryList": [{"id": 1, "name": "首页", "urlValue": "/home"}, {"id": 2, "name": "短剧", "urlValue": "/playlet"}, {"id": 3, "name": "下载APP", "urlValue": "/download-app"}], "themeList": [{"id": 1, "name": "都市", "slug": "都市"}], "homeConfigPlaylets": [], "playlet": {"id": 1, "slug": "都市", "playName": "花魁", "title": "花魁", "cover": "https://img.example/封面.webp", "horizontalCover": "https://img.example/横封.webp", "totalEpisode": 3, "updateStatus": 1, "countCollect": 0, "countLike": 0, "heatValue": 0, "isLike": 0, "isCollect": 0, "onlineAt": "2026-10-01", "playletChapterInfo": [{"id": 10, "currentEpisode": 1, "payType": 0, "canWatch": true}, {"id": 11, "currentEpisode": 2, "payType": 1, "needUnlock": true}]}, "chapter": {"id": 10, "mediaTime": 123, "payType": 0, "needUnlock": false, "needVip": false, "countComment": 0}, "chapterID": 10, "episode": 1, "canWatch": true, "mainURL": "/h5/videos/video.m3u8", "previewURL": "", "hasPreview": false, "unlockCost": 0, "balance": 0, "balanceText": "0", "hotList": [], "guessLike": [], "related": [], "commentSize": 4, "commentTotal": 0, "commentPage": 1, "commentObjectID": 10, "commentObjectType": 2, "rechargePackages": [], "rechargePayMethods": [], "trackerDrama": {"MediaID": "10", "DramaID": "1", "DramaTitle": "花魁", "Episode": 1, "TotalEpisode": 3, "VideoID": "10", "DramaTypeID": 0, "DramaTypeName": "短剧", "RecommendTraceID": "", "DramaTagKey": "", "DramaTagName": "", "DramaDuration": 123}, "comments": [], "hotMoreURL": "/list/all/0/2/", "videoID": 2, "videoURL": "/h5/video.m3u8", "video": {"id": 2, "title": "花魁影集", "watchTimes": 0, "countLike": 0, "countCollect": 0, "countComment": 0, "isLike": 0, "isCollect": 0, "video": {"cover": "https://img.example/影集.webp", "videoTime": 120, "width": 1280, "height": 720}}} \ No newline at end of file diff --git a/tests/fixtures/templates/seriesPlay-go.html b/tests/fixtures/templates/seriesPlay-go.html new file mode 100644 index 0000000..285ffe5 --- /dev/null +++ b/tests/fixtures/templates/seriesPlay-go.html @@ -0,0 +1,1190 @@ + + + + + + 花魁 & "标题" <test> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 返回 + +
花魁影集
+ +
+ +
+
+ + +
+
+ +
+ + + 花魁影集 + + + + + + + +
+ 正片 02:00 + 02:00 +
+ + +
+ + + + + + +
+ 正片 + +
+
+ +
+
+
+

花魁影集

+ 影集 +
+
+ 120 分钟 + 1280×720 + 18+ +
+

+ + +

+

+ + +

+
+ +
+ + + + + +
+ +
+
+

简介

+ +
+

花魁影集

+
+
+ + + + + + + + +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+ +
+ + + + + + + +
+
+

评论

+ 暂无评论 +
+ +
+ +
+ + 0 / 200 +
+ + + +
+ +
    + +
  • +

    暂无评论

    +
  • + +
+ + +
+ + + + +
+ + + +
+ + + + +
+ + + 广告 A + 广告 + + + + 广告 B + 广告 + + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/templates/tags-data.json b/tests/fixtures/templates/tags-data.json new file mode 100644 index 0000000..2728450 --- /dev/null +++ b/tests/fixtures/templates/tags-data.json @@ -0,0 +1 @@ +{"metaTitle": "花魁 & \"标题\" ", "metaDescription": "短剧'说明 & 安全", "trackerAppId": "app'\"", "pageKey": "tags", "pageName": "标签", "siteName": "花魁", "siteURL": "https://drama.example", "canonicalURL": "https://drama.example/tags/", "ogType": "website", "robots": "index, follow", "appConfig": {"ReportSDKConfig": ""}, "listCount": "共 1 部", "list": [{"id": 1, "slug": "热门 剧", "playName": "花魁 & 第一部", "title": "花魁", "cover": "https://example.com/图片?a=1&b=2", "totalEpisode": 4, "updateStatus": 2, "tagNames": ["都市"], "tagSlugs": ["都市"], "tagIds": [2]}], "tagSlug": "", "tagId": 0, "sortType": 1} diff --git a/tests/fixtures/templates/tags-go.html b/tests/fixtures/templates/tags-go.html new file mode 100644 index 0000000..30dbd2b --- /dev/null +++ b/tests/fixtures/templates/tags-go.html @@ -0,0 +1,717 @@ + + + + + + 花魁 & "标题" <test> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 返回 + +
标签
+ + 搜索 + +
+ +
+
+
+ + +
+

+ + 标签 +

+

标签短剧

+

+
+
+ +
+ +
+ 排序 + +
+
+ +
+

标签短剧作品列表

+

共 1 部

+ + + + + + + +
+
+
+ + + + + + + + + + + diff --git a/tests/images.test.ts b/tests/images.test.ts new file mode 100644 index 0000000..92e2325 --- /dev/null +++ b/tests/images.test.ts @@ -0,0 +1,275 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + imageCDNBases, + imageSourceURLs, + validProfileImage, +} from "../src/lib/media/image-source"; +import { + decodeImageBytes, + decryptImageBytes, + imageMime, + resolveImage, +} from "../src/lib/media/images"; + +const primary = "https://primary.images.test/assets/"; +const backup = "https://backup.images.test/media/"; +const signedPath = "v3/image/cover.jpg?ticket=a%2Bb%3D&expires=99&path=%2Ffoo"; +const bytesOf = (path: string): Uint8Array => + new Uint8Array(readFileSync(new URL(path, import.meta.url))); +const png = bytesOf("../public/static/favicon.png"); +const fixtures = [ + ["PNG", "image/png", png], + [ + "JPEG", + "image/jpeg", + bytesOf("../public/static/images/profile/water-margin.jpg"), + ], + [ + "WebP", + "image/webp", + new Uint8Array( + Buffer.from("UklGRhwAAABXRUJQVlA4TBAAAAAvAAAAAAcQ/S9g/wMR0f8A", "base64"), + ), + ], + [ + "GIF", + "image/gif", + new Uint8Array( + Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"), + ), + ], +] as const; +const bufferOf = (bytes: Uint8Array): ArrayBuffer => + Uint8Array.from(bytes).buffer; + +test("CDN bases retain deployment paths, deduplicate, and reject unsafe configuration", () => { + assert.deepEqual( + imageCDNBases( + " https://primary.images.test/assets ", + primary, + "http://backup.images.test/media/", + "https://user:secret@private.images.test/", + "https://query.images.test/?token=secret", + "https://hash.images.test/#fragment", + "javascript:alert(1)", + "data:image/png;base64,abc", + "//relative.images.test/", + "https://", + "", + null, + 42, + {}, + ), + [primary, "http://backup.images.test/media/"], + ); +}); + +test("relative API images use the CDN base or the site root on every page", () => { + for (const source of [signedPath, "/" + signedPath]) { + assert.deepEqual(imageSourceURLs(source), ["/" + signedPath]); + assert.deepEqual(imageSourceURLs(source, [primary, backup]), [ + primary + signedPath, + backup + signedPath, + ]); + } +}); + +test("absolute signed image URLs remain byte-for-byte intact and only configured CDN paths gain fallbacks", () => { + const source = primary + signedPath; + assert.deepEqual(imageSourceURLs(source, [primary, backup]), [ + source, + backup + signedPath, + ]); + assert.deepEqual(imageSourceURLs(backup + signedPath, [primary, backup]), [ + backup + signedPath, + source, + ]); + for (const outside of [ + "https://external.images.test/" + signedPath, + "https://primary.images.test/assets-other/" + signedPath, + ]) + assert.deepEqual(imageSourceURLs(outside, [primary, backup]), [outside]); +}); + +test("static, inline and blob sources bypass rewriting while executable sources are rejected", () => { + for (const source of [ + "/static/brand/placeholder.svg?v=1", + "data:image/png;base64,iVBORw0KGgo=", + "blob:https://site.test/image-id", + ]) + assert.deepEqual(imageSourceURLs(source, [primary, backup]), [source]); + for (const source of [ + "javascript:alert(1)", + "data:text/html,", + "file:///private/image.png", + "vbscript:msgbox(1)", + "https://", + "", + ]) + assert.deepEqual(imageSourceURLs(source, [primary, backup]), []); +}); + +test("profile avatars accept real API image paths without accepting executable or malformed HTTP URLs", () => { + for (const source of [ + "https://avatars.images.test/face.jpg?signature=a%2Bb%3D", + "http://avatars.images.test/face.png", + "/static/images/profile/avatar.png?v=1", + signedPath, + "/" + signedPath, + ]) + assert.equal(validProfileImage(source), source); + for (const source of [ + null, + 42, + {}, + "", + "javascript:alert(1)", + "data:text/html,unsafe", + "https://", + "http://[malformed", + "https://bad host/image.png", + "ordinary-relative-avatar.png", + ]) + assert.equal(validProfileImage(source), ""); +}); + +test("image decoding accepts complete raw and legacy XOR images without mutating their input", () => { + for (const [name, mime, original] of fixtures) { + const plainBuffer = bufferOf(original); + assert.deepEqual(decodeImageBytes(plainBuffer), original, name + " raw"); + assert.equal(imageMime(decodeImageBytes(plainBuffer)), mime, name + " type"); + assert.deepEqual(new Uint8Array(plainBuffer), original, name + " raw input"); + const encrypted = decryptImageBytes(bufferOf(original)); + const encryptedBuffer = bufferOf(encrypted); + assert.deepEqual( + decodeImageBytes(encryptedBuffer), + original, + name + " encrypted", + ); + assert.deepEqual( + new Uint8Array(encryptedBuffer), + encrypted, + name + " encrypted input", + ); + } +}); + +test("image decoding rejects empty bodies and non-image API error responses", () => { + for (const value of [ + "", + '{"code":404,"data":null,"message":"image missing"}', + "CDN unavailable", + "upstream unavailable", + ]) { + const bytes = new TextEncoder().encode(value); + assert.throws(() => decodeImageBytes(bufferOf(bytes)), value || "empty"); + if (bytes.length) + assert.throws(() => + decodeImageBytes(bufferOf(decryptImageBytes(bufferOf(bytes)))), + ); + } +}); + +test("image fetching validates bytes, retries alternate CDNs, and only caches successful images", async (t) => { + const originalFetch = globalThis.fetch; + const created: string[] = []; + const responseFor = (bytes: Uint8Array, contentType = "image/png") => + new Response(bufferOf(bytes), { headers: { "Content-Type": contentType } }); + const captureBlob = async (url: string, expected: Uint8Array, mime: string) => { + assert.match(url, /^blob:/); + created.push(url); + const response = await originalFetch(url); + assert.equal(response.headers.get("content-type"), mime); + assert.deepEqual(new Uint8Array(await response.arrayBuffer()), expected); + }; + try { + await t.test("HTTP failure uses the next CDN and retains its successful cache", async () => { + const path = "v3/image/http-fallback-20261012.png?signature=x%2By%3D"; + const calls: string[] = []; + globalThis.fetch = async (input) => { + const url = String(input); + calls.push(url); + return url === primary + path + ? new Response("CDN unavailable", { status: 503 }) + : responseFor(decryptImageBytes(bufferOf(png))); + }; + const first = await resolveImage(path, true, [primary, backup]); + await captureBlob(first, png, "image/png"); + assert.deepEqual(calls, [primary + path, backup + path]); + assert.equal(await resolveImage(path, false, [primary, backup]), first); + assert.equal(calls.length, 2); + }); + + await t.test("a JSON 200 response is rejected before trying the alternate CDN", async () => { + const path = "v3/image/json-fallback-20261012.png"; + const calls: string[] = []; + globalThis.fetch = async (input) => { + const url = String(input); + calls.push(url); + return url === primary + path + ? new Response('{"code":404,"data":null}', { + headers: { "Content-Type": "application/json" }, + }) + : responseFor(png); + }; + await captureBlob( + await resolveImage(primary + path, false, [primary, backup]), + png, + "image/png", + ); + assert.deepEqual(calls, [primary + path, backup + path]); + }); + + await t.test("a missing CDN roots the API path before requesting valid bytes", async () => { + const path = "v3/image/no-cdn-20261012.png?signature=a%2Bb%3D"; + const calls: string[] = []; + globalThis.fetch = async (input) => { + calls.push(String(input)); + return responseFor(png); + }; + await captureBlob(await resolveImage(path), png, "image/png"); + assert.deepEqual(calls, ["/" + path]); + }); + + await t.test("invalid image responses are not cached and the same URL can recover", async () => { + const source = "https://unconfigured.images.test/no-cache-20261012.png"; + let count = 0; + let recovered = false; + globalThis.fetch = async () => { + count++; + return recovered + ? responseFor(png) + : new Response('{"code":500,"tip":"failed"}', { + headers: { "Content-Type": "image/png" }, + }); + }; + await assert.rejects(resolveImage(source)); + await assert.rejects(resolveImage(source)); + assert.equal(count, 2, "invalid responses must trigger a fresh request"); + recovered = true; + const resolved = await resolveImage(source); + await captureBlob(resolved, png, "image/png"); + assert.equal(count, 3); + assert.equal(await resolveImage(source), resolved); + assert.equal(count, 3, "successful bytes may be cached"); + }); + + await t.test("non-image sources and illegal protocols never enter the image fetcher", async () => { + let count = 0; + globalThis.fetch = async () => { + count++; + throw new Error("Unexpected image request"); + }; + const source = "/static/brand/placeholder.svg?v=image-test"; + assert.equal(await resolveImage(source), source); + assert.equal(count, 0); + await assert.rejects(resolveImage("javascript:alert(1)")); + assert.equal(count, 0); + }); + } finally { + globalThis.fetch = originalFetch; + for (const url of created) URL.revokeObjectURL(url); + } +}); diff --git a/tests/install-guide.test.ts b/tests/install-guide.test.ts new file mode 100644 index 0000000..f8dc822 --- /dev/null +++ b/tests/install-guide.test.ts @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { installationURL } from '../src/server/config'; +import { installGuideData } from '../src/server/services/install-guide'; + +test('installation URLs require a valid HTTP(S) platform destination', () => { + assert.equal(installationURL(' https://apps.apple.com/app/id123?campaign=site '), 'https://apps.apple.com/app/id123?campaign=site'); + assert.equal(installationURL('http://downloads.example/app.apk'), 'http://downloads.example/app.apk'); + for (const value of ['', '/download-app/', '//downloads.example/app', 'javascript:alert(1)', 'data:text/html,test', 'file:///tmp/app.apk', 'vbscript:foo', 'huakui://open', 'https://user:password@example.com/', 'https://example.com/\napp.apk']) assert.equal(installationURL(value), '', value); +}); + +test('deep links allow universal links and app schemes while rejecting executable protocols', () => { + assert.equal(installationURL('huakui://open/home', true), 'huakui://open/home'); + assert.equal(installationURL('https://app.example/open', true), 'https://app.example/open'); + for (const value of ['javascript://alert(1)', 'JaVaScRiPt:alert(1)', 'data://text/html,test', 'file://localhost/etc/passwd', 'vbscript://execute', 'blob://test', 'about://blank', 'chrome://settings', 'huakui:open', 'not a URL']) assert.equal(installationURL(value, true), '', value); +}); + +test('unconfigured installation capabilities retain backend AppAddr and truthful fallback state', () => { + const empty = { iosStoreURL: '', iosProfileURL: '', androidAPKURL: '', deepLinkURL: '' }; + const data = installGuideData('https://download.example/real-backend-page', empty); + assert.equal(data.iosStoreURL, data.fallbackURL); assert.equal(data.iosProfileURL, data.fallbackURL); assert.equal(data.androidAPKURL, data.fallbackURL); + assert.equal(data.fallbackURL, 'https://download.example/real-backend-page'); + assert.equal(data.hasIOSStore, false); assert.equal(data.hasIOSProfile, false); assert.equal(data.hasAndroidAPK, false); assert.equal(data.deepLinkURL, ''); + assert.equal(installGuideData(undefined, empty).fallbackURL, '/app/download/'); +}); + +test('each configured installation capability replaces only its own fallback', () => { + const data = installGuideData('https://download.example/', { iosStoreURL: 'https://apps.apple.com/app/id123', iosProfileURL: '', androidAPKURL: 'https://download.example/app.apk', deepLinkURL: 'huakui://open' }); + assert.equal(data.iosStoreURL, 'https://apps.apple.com/app/id123'); assert.equal(data.hasIOSStore, true); + assert.equal(data.iosProfileURL, 'https://download.example/'); assert.equal(data.hasIOSProfile, false); + assert.equal(data.androidAPKURL, 'https://download.example/app.apk'); assert.equal(data.hasAndroidAPK, true); assert.equal(data.deepLinkURL, 'huakui://open'); +}); diff --git a/tests/mobile-tabbar.test.ts b/tests/mobile-tabbar.test.ts new file mode 100644 index 0000000..a47c27d --- /dev/null +++ b/tests/mobile-tabbar.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { MobileTabbar } from '../src/components/layout/MobileTabbar'; + +const render = (requestPath?: string, favorites?: unknown[]) => renderToStaticMarkup(createElement(MobileTabbar, { data: { requestPath, favorites } })); + +test('mobile navigation follows the Figma page whitelist and current route', () => { + for (const [path, current] of [['/', '/'], ["/dramas/", "/dramas/"], ["/dramas/all/page/2/", "/dramas/"], ['/series/', '/series/'], ['/series/anime/12/2/', '/series/']] as const) { + const html = render(path, []); + assert.equal((html.match(/data-navigation-key=/g) || []).length, 3, path); + assert.doesNotMatch(html, /mobile_mine|我的|\/account\//, path); + assert.equal((html.match(/aria-current="page"/g) || []).length, 1, path); + assert.ok(html.includes(`href="${current}" aria-current="page"`), path); + assert.match(html, /mobile-tabbar\/\w+-active\.svg/); + } + for (const path of ["/account/", "/account/favorites/", "/account/login/", "/account/register/", "/discover/search/", "/drama/1/", "/series/watch/1/", "/account/recharge/", "/account/history/", "/account/purchases/", "/account/settings/", '/about/', '/help/', '/terms/', '/privacy/', '/feedback/', "/collections/", "/rankings/", '/404/']) { + assert.doesNotMatch(render(path), /