整体UI修改

This commit is contained in:
谢宇宁
2026-10-27 17:39:27 +07:00
parent 8cdbfee08f
commit e289658a28
99 changed files with 1227 additions and 4118 deletions
+2 -7
View File
@@ -1,14 +1,9 @@
# Legacy development-only .env.local example. Prefer config/development.local.env.
# Development uses this configured backend; browsers call the current site's /api/*.
# Test uses this same backend; production uses http://172.104.56.28:26125.
UPSTREAM_API_URL=http://172.104.181.99:21022
# Test and production use this same backend.
UPSTREAM_API_URL=https://webhgcgapi.mfcg.me
# API_BASE_URL remains supported for existing local overrides.
# Optional channel code passed when creating a guest account.
API_CHANNEL=
# Public configuration/navigation: 60s; homepage: 30s. Optional cap in milliseconds; 0 disables.
# PUBLIC_DATA_CACHE_TTL_MS=60000
# Analytics application ID. Must match this site's reportSdkConfig and upstream app ID.
NEXT_PUBLIC_TRACKER_APP_ID=1047
# Optional same-project encrypted config fallback when config/info omits reportSdkConfig.
NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG=
+36 -61
View File
@@ -1,4 +1,4 @@
# 91短剧 · Next.js
# 黄果吃瓜 · Next.js
Next.js App Router、React、TypeScript。保留 HTML 原稿布局和移动端导航,业务数据通过 Swagger API 动态获取。
@@ -35,7 +35,7 @@ npm start
测试环境按生产模式构建、启动,使用 `APP_ENV=test` 和 `NODE_ENV=production`;`npm run dev:test` 则使用测试配置启动开发服务器。业务环境通过 `APP_ENV` 区分,不把测试部署误设为单元测试使用的 `NODE_ENV=test`。
浏览器始终访问当前站点的相对地址 `/api/*`,Next.js 服务端通过 `UPSTREAM_API_URL` 请求 Go 的 `/api/app/*`。开发、测试环境使用 `http://172.104.181.99:21022`,生产环境使用 `http://172.104.56.28:26125`。上游地址仅由服务端读取。默认应用端口均为 `21021`。公开接口缓存和 SEO 快照按解析后的上游域名隔离。修改服务器配置时,在对应目录新增 `config/test.local.env` 或 `config/production.local.env`;这些本地文件已忽略提交。优先级为:进程环境变量 > 对应 `*.local.env` > 对应基础配置。开发环境兼容旧 `.env.local`,其优先级低于 `config/development.local.env`;测试和生产不从旧 `.env.local` 加载本站业务配置。
浏览器始终访问当前站点的相对地址 `/api/*`,Next.js 服务端通过 `UPSTREAM_API_URL` 请求 Go 的 `/api/app/*`。开发、测试、生产环境均使用 `https://webhgcgapi.mfcg.me`。上游地址仅由服务端读取。默认应用端口均为 `21021`。公开接口缓存和 SEO 快照按解析后的上游域名隔离。修改服务器配置时,在对应目录新增 `config/test.local.env` 或 `config/production.local.env`;这些本地文件已忽略提交。优先级为:进程环境变量 > 对应 `*.local.env` > 对应基础配置。开发环境兼容旧 `.env.local`,其优先级低于 `config/development.local.env`;测试和生产不从旧 `.env.local` 加载本站业务配置。
原有 `API_BASE_URL` 仍作为兼容别名。在同一配置层内 `UPSTREAM_API_URL` 优先;较高配置层的旧变量仍可覆盖较低层的新变量,启动后统一为同一个上游地址。上游地址仅在服务端读取,不写入浏览器代码。
@@ -103,15 +103,14 @@ DEPLOY_JUMP_HOST=user@jump-host DEPLOY_JUMP_DIR=/srv/91dj/h5 bash deploy_prod.sh
- 官方入口 `/navigation/`:独立展示线路、官方渠道与 App 下载,不加载首页导航、播放器或账户交互;首页和业务页面不再弹出入口导航页。
- 专题、题材、标签、栏目:`/topics/:id/`、`/theme/:id/`、`/tag/:id/`、`/config/:id/`。
- 详情 `/detail/:id/`;章节播放 `/play/:id/:chapterId/`。
- 内容路由优先使用接口返回的英文或拼音 `slug`(字母、数字、连字符、下划线),中文或无效 slug 回退到 ID,例如 `/detail/city-story/`、`/play/city-story/90001/`、`/topics/urban/`、`/tag/urban/`、`/config/editors-choice/`。章节段仍是章节 ID。卡片、轮播、选集、评论/列表分页、解锁后跳转和 SEO 地址使用同一规则;原 ID 地址继续直接访问。有配置的 `moreUrl` 优先级不变。
- 内容路由优先使用接口返回的英文或拼音 `slug`(字母、数字、连字符、下划线),中文或无效 slug 回退到 ID,例如 `/detail/city-story/`、`/play/city-story/90001/`、`/topics/urban/`、`/tag/urban/`、`/config/editors-choice/`。章节段仍是章节 ID。卡片、轮播、选集、评论/列表分页和 SEO 地址使用同一规则;原 ID 地址继续直接访问。有配置的 `moreUrl` 优先级不变。
- 搜索 `/search/关键词/`;分页 `/search/关键词/page/2/`。关键词通过 `encodeURIComponent` 作为单个路径段编码,支持中文和特殊字符。
- 独立导航页 `/navigation/` 从 `config/info` 读取站名、介绍、`navPageList`、永久入口、TG/X 和 App 下载地址。线路按 `line` 排序;旧配置兼容 `dhNewUrlAddr1/2/3` 与最新地址字段。未配置或无效的链接不展示,不使用演示账号或首页链接兜底。
- 个人中心 `/member/center/`,收藏、历史、点赞、已购和设置分别为 `/member/favorites/`、`/member/history/`、`/member/likes/`、`/member/purchased/`、`/member/settings/`。
- 帮助与平台信息 `/info/:slug/`;充值 `/info/deposit/`;VIP `/info/deposit/vip/`。
- 所有分页使用独立路径,例如 `/shorts/page/2/`、`/rank/new/page/2/`、`/topics/31/page/2/`、`/member/history/page/2/`、`/info/deposit/vip/page/2/`;详情和播放页的 `/page/2/` 对应评论第 2 页。页码、上一页和下一页均为真实链接,可直接访问、刷新和前进后退。
- 排序通过 `?sort=2` 保留;评论排序使用 `?commentSort=1`。切换排序时链接指向第一页,翻页时保留当前条件;第一页链接使用原页面路径,不执行自动跳转或地址替换。
- 独立导航页 `/navigation/` 站名固定为“黄果吃瓜”,入口线路和永久入口固定为 `https://huangguocg.com/`(`src/lib/site.ts`),联系方式固定为官方 TG `@jbd125`;不使用后台 `navPageList`、`newUrlAddr*`、`foreverUrlAddr` 和联系方式字段。介绍和 App 下载地址仍读取 `config/info`,未配置或无效的下载链接不展示。
- 帮助与平台信息 `/info/:slug/`。个人中心(`/member/*`)、充值/VIP(`/info/deposit/*`)、`/info/user-center/`、`/info/settings/` 已删除,返回 404。
- 所有分页使用独立路径,例如 `/shorts/page/2/`、`/rank/new/page/2/`、`/topics/31/page/2/`。详情和播放页不分页(评论已删除),`/page/N/` 返回 404。页码、上一页和下一页均为真实链接,可直接访问、刷新和前进后退。
- 排序通过 `?sort=2` 保留。切换排序时链接指向第一页,翻页时保留当前条件;第一页链接使用原页面路径,不执行自动跳转或地址替换。
页面直接根据 URL 路径加载,不解析或转换旧 hash 路由。站内导航、动态卡片、播放选集、搜索和会员入口均使用路径地址;无效路径返回 404。
页面直接根据 URL 路径加载,不解析或转换旧 hash 路由。站内导航、动态卡片、播放选集和搜索入口均使用路径地址;无效路径返回 404。
页面地址统一使用尾斜杠。`src/proxy.ts` 对有效页面的无尾斜杠地址返回 `308` 永久重定向,例如 `/shorts` → `/shorts/`,分页、搜索关键词和查询参数完整保留;首页 `/` 直接响应。Next.js 保留 `trailingSlash: true` 和 `skipTrailingSlashRedirect: true`,由 Proxy 仅处理页面,API、播放列表、密钥、静态资源地址不受影响;无效路径直接返回 404。
@@ -123,7 +122,7 @@ DEPLOY_JUMP_HOST=user@jump-host DEPLOY_JUMP_DIR=/srv/91dj/h5 bash deploy_prod.sh
页面 head 由本项目(`JHA-1047`)的 `src/lib/seo-server.ts` 和 `src/lib/seo.ts` 生成:全站名称与首页 TDK 取 `config/info.seoInfo`,栏目 TDK 优先使用 `translation/category/list` 的导航配置,具体专题、剧集使用对应接口信息。兼容字符串形式的 SEO 配置及 `keyworld` 等旧字段。`/navigation/` 使用 `seoNavInfo`。
canonical 的域名优先取 `config.foreverUrlAddr`,其次取 `newAddressInfo.foreverUrlAddr`,只使用配置中的永久域名,不回退到请求 Host、API 或 CDN 域名。配置沿用 60 秒公共缓存,成功取得有效永久域名后,将公开 SEO 信息保存到 `.next/cache/seo/`,按 API 地址隔离;接口超时、报错、配置或永久域名暂缺时沿用最近一次有效值,服务重启也可读取已有快照。快照不包含 SDK 配置、账号或凭证;部署时需保留该缓存目录,完全没有有效配置/快照时不输出猜测的 canonical 和站点 JSON-LD。后台更新为有效永久域名后,缓存过期后的新页面请求采用新配置;同一浏览器文档沿用服务端给出的 canonical、site_name 和发布者身份。
canonical、sitemap 和结构化数据的域名固定为 `https://huangguocg.com`(`src/lib/site.ts` 的 `SITE_ORIGIN`),不使用后台的 `foreverUrlAddr`,也不回退到请求 Host、API 或 CDN 域名。页面上展示的站点地址(导航页线路与永久入口、回家的路、详情页“官方地址”)同样固定为该域名。配置沿用 60 秒公共缓存,公开 SEO 信息保存到 `.next/cache/seo/`,按 API 地址隔离;接口超时或报错时沿用最近一次有效的站名与 TDK,服务重启也可读取已有快照。快照不包含账号或凭证;部署时需保留该缓存目录。同一浏览器文档沿用服务端给出的 canonical、site_name 和发布者身份。
canonical 不带查询参数,分页各自生成规范地址和有边界的 `prev/next`,第 1 页使用栏目地址。搜索、账户、充值和工具页按参考项目输出 `noindex`。服务端将 TDK、robots、Open Graph、Twitter 和图标信息直接写入 `<head>`,每次页面请求都返回对应页面的完整 head。本次未新增统计脚本。
@@ -131,19 +130,19 @@ canonical 不带查询参数,分页各自生成规范地址和有边界的 `pr
公开正文与 SEO 使用同一次服务端取数:短剧、三个榜单、专题列表/详情、题材/标签、首页更多栏目、短剧详情、播放页及各自分页直接输出当前页的标题、简介、内容卡片、集数和已有评论,分页与免费集数保留原生链接。搜索结果也直接输出首屏正文,继续保持 `noindex`。详情/播放和栏目模板在服务端与站内导航间共用,沿用现有 HTML 样式。
请求与渲染方式:服务端先完成当前页面取数,再返回页头和完整正文;福利应用、回家的路、反馈、个人中心、收藏/历史/点赞/已购、设置及充值/VIP 页面也直接输出内容。链接和搜索使用浏览器原生页面请求,旧页面保留到新响应到达,不清空正文或插入页面加载占位。浏览器复用首屏快照绑定轮播、播放、表单和支付等交互,不再重复获取整页数据;提交成功后重新请求当前页面。
请求与渲染方式:服务端先完成当前页面取数,再返回页头和完整正文;福利应用和回家的路页面也直接输出内容。链接和搜索使用浏览器原生页面请求,旧页面保留到新响应到达,不清空正文或插入页面加载占位。浏览器复用首屏快照绑定轮播、播放、表单和支付等交互,不再重复获取整页数据;提交成功后重新请求当前页面。
公共配置、导航、分类、页脚和标签选项使用 60 秒进程内缓存,首页使用 30 秒;同一上游地址及查询参数的并发请求合并,返回独立数据副本,缓存有数量上限。配置及首页刷新失败可沿用已成功取得的数据;错误和无效响应不写入缓存。账户、余额、套餐及付费观看权限不使用公共缓存,HTML 继续保持私有响应。可用 `PUBLIC_DATA_CACHE_TTL_MS` 缩短公共缓存时间,设为 `0` 可关闭;多实例各自维护缓存。
详情、章节、选集、评论和推荐按 URL 中的 ID 并行请求。浏览器复用首屏中的公开配置和页脚数据,下载与广告交互不再等待重复取数;SDK 配置仍单独读取,不写入 HTML。支持浏览器前进后退缓存时,公开页保留 DOM、滚动位置和交互,并在后台恢复当前会话和页面埋点;账户、播放及打开弹窗的页面仍重新请求,防止恢复已处置的支付/权限状态。
详情、章节、选集和推荐按 URL 中的 ID 并行请求。浏览器复用首屏中的公开配置和页脚数据,下载与广告交互不再等待重复取数。支持浏览器前进后退缓存时,公开页保留 DOM、滚动位置和交互,并在后台恢复当前会话;账户、播放及打开弹窗的页面仍重新请求,防止恢复已处置的支付/权限状态。
有效业务页面在 Proxy 中恢复账号或建立游客会话,将同一身份的 Cookie 传给本次服务端渲染并写回浏览器。客户端复用已准备的用户信息,避免首屏再次初始化游客。正式账号过期显示登录入口,接口异常直接返回重试状态;账户页面保持 `private, no-store` 和 `noindex`。首屏账号、账户列表、套餐、流水和播放器数据均按字段白名单传递,未授权付费视频源、服务端账号凭证及 SDK 私密配置不写入 HTML;点击金币集数仍读取实时权限、价格并由用户确认解锁。当前 Next.js 动态参数在路由入口只解码一次,中文和含空格、斜杠、百分号的搜索词与浏览器保持一致。
有效业务页面在 Proxy 中恢复或建立游客会话,将同一身份的 Cookie 传给本次服务端渲染并写回浏览器。客户端复用已准备的用户信息,避免首屏再次初始化游客。正式账号过期显示登录入口,接口异常直接返回重试状态;账户页面保持 `private, no-store` 和 `noindex`。首屏账号、账户列表、套餐、流水和播放器数据均按字段白名单传递,未授权付费视频源、服务端账号凭证及 SDK 私密配置不写入 HTML;点击金币集数仍读取实时权限、价格并由用户确认解锁。当前 Next.js 动态参数在路由入口只解码一次,中文和含空格、斜杠、百分号的搜索词与浏览器保持一致。
JSON-LD 参考 `huangguo.com` 的品牌节点及参考项目 `seoser/jsonld.go`:收录页输出 `Organization`、`WebSite` 和对应 `WebPage` / `CollectionPage`,列表输出实际内容的 `ItemList`,详情使用 `TVSeries`,播放页关联 `TVEpisode` 与具备真实封面、发布时间的 `VideoObject`。关于页使用 `AboutPage`,常见问题直接从页面可见问答生成 `FAQPage`。URL、站名与分页序号沿用现有动态 SEO 配置;不使用参考站的品牌、域名、伪造日期或评分。每份页面响应包含本页的原生 JSON-LD script,不收录页不输出旧页面的数据。测试覆盖脚本安全转义和重复节点清理。
- 文档:https://91av-wz.80sygtm.com/swagger/index.html
- `UPSTREAM_API_URL`:开发、测试环境为 `http://172.104.181.99:21022`,生产环境为 `http://172.104.56.28:26125`;兼容旧 `API_BASE_URL`。
- `API_CHANNEL`:可选游客、登录和绑定的默认渠道码;已有首次渠道时优先保留。
- `UPSTREAM_API_URL`:三套环境均为 `https://webhgcgapi.mfcg.me`;兼容旧 `API_BASE_URL`。
- `API_CHANNEL`:可选游客会话的默认渠道码;已有首次渠道时优先保留。
- 项目编号:`JHA-1047`。接口读取与业务处理位于 `src/lib/api/server.ts`、`src/lib/client/config.ts`、`src/lib/client/channel.ts`、`src/lib/client/auth.ts` 和 `src/lib/client/account.ts`。
浏览器访问同源 `/api/*`,由 Next.js 按白名单代理到配置上游的 `/api/app/*`。Nginx 已有的 `/api/app/* → Go` 规则可以保留,其他 `/api/*` 交给 Next.js;不能将整个 `/api/` 都交给 Go。旧 Next.js `/api/app/*` 处理器仍保留供本地兼容访问。首页由服务端渲染,公开首页数据缓存 30 秒。公共接口不携带用户 token;用户接口使用 HttpOnly、SameSite Cookie。token 和密码不会写入浏览器本地存储或返回给前端脚本。
@@ -152,18 +151,13 @@ JSON-LD 参考 `huangguo.com` 的品牌节点及参考项目 `seoser/jsonld.go`
请求处理参考 `jha-1045-hgdjnet-web`:Next.js 接口代理不根据 `Origin`、`Host` 或 `Sec-Fetch-Site` 拦截 POST,避免 CloudFront / Nginx 转发后域名或协议变化导致正常浏览器请求返回 403。浏览器继续请求当前站点的 `/api/*`,代理负责接口白名单、参数处理和会话传递,业务权限由上游校验。
## 登录与游客绑定
## 游客会话
登录流程由本项目(`JHA-1047`)的 `src/lib/client/auth.ts` 和 `src/app/api/app/[...path]/route.ts` 实现,保留本站登录弹窗。
已删除登录、注册/绑定、找回密码和退出,全站只使用游客身份(点赞、收藏仍需要它)。实现位于 `src/lib/client/api.ts`、`src/lib/api/page-session.ts` 和 `src/app/api/app/[...path]/route.ts`。
- 首次访问自动建立游客会话;已有 Cookie 时先读取 `auth/getInfo` 恢复身份、昵称和余额,接口未返回新 token 时继续使用原 token。
- 游客、登录和绑定使用同一持久设备标识,发送 `devID` 和渠道;首次渠道取 `dc/ch/pc`,后续访问保持不变。绑定支持 `inviteCode/invite/code` 邀请参数。服务端按参考项目发送设备头,并透传有效的客户端 IP。
- 绑定携带当前游客凭证以保留其数据;前端填写账号、密码和确认密码并同意协议,无需邮箱。上游要求的缺省邮箱由服务端生成为 `账号@91dj.local`;实际已绑定邮箱仍用于找回密码。
- 绑定账号为 6–20 位字母或数字,密码为 8–64 位且包含字母和数字;登录兼容旧账号的 4 位密码及绑定允许的较长账号、密码,支持邮箱登录。
- 登录失败保留原身份;成功后更新账户并重新读取当前页面。自动游客、登录和退出响应串行处理,旧请求的失效结果不会清除客户端刚登录的身份。
- 游客请求明确返回会话失效时重建游客并重试一次;正式账号失效会清理会话并提示登录。登录、注册、找回密码自身的错误不触发游客恢复。
- 退出清理会话,并跳过紧接着一次页面初始化的自动游客登录。当前页面保持未登录,主动登录或绑定可恢复使用。
- 表单提交期间禁用重复提交;支持显示/隐藏密码,关闭和成功后均清除密码值。仅设备与渠道写本地存储,凭证保留在 HttpOnly Cookie。
- 首次访问自动建立游客会话;已有 Cookie 时先读取 `auth/getInfo` 恢复身份,接口未返回新 token 时继续使用原 token。
- 游客使用持久设备标识,发送 `devID` 和渠道;首次渠道取 `dc/ch/pc`,后续访问保持不变。服务端按参考项目发送设备头,并透传有效的客户端 IP。
- 会话失效时(包括旧版本遗留的已登录 Cookie)清理会话并重建游客后重试一次,不再提示登录。仅设备与渠道写本地存储,凭证保留在 HttpOnly Cookie。
## 已接入
@@ -171,44 +165,38 @@ JSON-LD 参考 `huangguo.com` 的品牌节点及参考项目 `seoser/jsonld.go`
- 短剧运营分类、剧集视频列表、榜单、专题、题材/标签、分页和更多列表。
- 搜索、搜索联想、热搜、猜你喜欢;搜索记录单独保存在当前浏览器。
- 短剧详情、章节列表、章节权限和实际播放地址;HLS 播放、媒体代理和播放上报。
- 游客会话、登录、注册/游客绑定、找回密码、资料与头像更新。
- 个人中心统计、收藏、历史、点赞、已购列表;点赞、收藏、评论、回复、评论点赞。
- 金币/VIP 套餐、支付方式、订单创建、余额查询和逐集解锁。
- 应用列表、下载配置、固定平台说明页、反馈提交。
- 游客会话;点赞、收藏、分享。评论已删除,详情页和播放页的评论区换成“推荐视频”(`playlet/guess-like/list`,12 部)。
- 全部短剧和视频免费观看:前端不再识别 `needUnlock`/`needVip`/`payType`/`canWatch`,只要接口返回了视频地址就直接播放,选集里没有付费角标。接口没返回地址的章节显示“本集视频暂未开放”,有免费预告时可看预告。**付费章节目前后端不返回 `videoUrl`,需要在后台把这些章节设为免费后才能播放。**
- 应用列表、下载配置、固定平台说明页。
首页“推荐 / 最新 / 热门 / 随机”筛选项保持移除。栏目排序、列表数据、总数与观看权限由接口决定,失败时显示错误或空状态。
首页动态栏目沿用 HTML 的模块间距(桌面 48px、移动端 32px)与卡片网格,首个栏目不再复用原稿图文混排区域的选择器。首页封面统一为 3:4,图片居中裁切,避免接口方图、竖图混排时卡片高度不齐,并在图片解密加载前预留封面高度。
全站以 `91短剧-2组 (3).html` 的页面结构和样式为基准:短剧、剧集、专题、题材/标签、更多列表和搜索页沿用原稿网格、标题、筛选栏和封面框;排行榜使用原稿数字角标、五列布局及窄屏断点,排名按接口分页顺序连续计算,榜单 tab 保持独立路由。搜索关键词只在已转义的标题中高亮。
全站以原稿 HTML 的页面结构和样式为基准:短剧、剧集、专题、题材/标签、更多列表和搜索页沿用原稿网格、标题、筛选栏和封面框;排行榜使用原稿数字角标、五列布局及窄屏断点,排名按接口分页顺序连续计算,榜单 tab 保持独立路由。搜索关键词只在已转义的标题中高亮。
专题列表与专题详情按 HTML 直接展示卡片和分页,不增加“最新专题 / 热门专题”或“编辑推荐 / 最新 / 最热”等分类选项。
搜索结果由本项目(`JHA-1047`)的 `src/lib/client/search.ts` 和 `src/lib/seo-server.ts` 实现:服务端准备会话后调用 `playlet/search`,传入 `keyword/pageNum/pageSize`,默认每页 25 条;展示接口总数、关键词和实际 `pageSize`,剧名优先使用 `playName`,不再本地筛选接口结果。无结果优先展示 `everyoneWatching`,缺少推荐时请求 `playlet/hot/list`;推荐不计入搜索数量。`/search/` 可直接进入,空关键词跳过搜索接口,翻页使用 `/search/{关键词}/page/{页码}/`,搜索框、热搜、搜索历史和联想词入口均直接生成关键词路径,不通过重定向转换。标题高亮沿用 HTML 的 NFKC、大小写和空白处理;无结果/失败状态使用原稿插图,接口错误保留重试入口,不伪装为零结果。
个人中心、收藏、历史、点赞与已购页共用原稿的头像/金币余额卡片、资料入口、书架导航与卡片列表;个人中心默认展示真实观看历史,分页链接进入历史页。设置页恢复头像编辑、昵称保存和账号安全卡片,资料、头像、密码仍通过原有接口保存;登录/绑定弹窗恢复原稿输入框、眼睛图标与按钮层级。页脚与桌面导航采用新版 HTML 的排版。
“关于我们、用户协议、隐私政策、常见问题、2257、DMCA”六页固定使用 `src/lib/info-pages.ts` 中的原 HTML 文案与结构,不再请求富文本接口,底部入口也保持固定。每次访问均由服务端输出正文、标题和对应样式标记,无须等待首页或富文本接口。保留分节间距、折叠问答、编号段落和 `mailto:` 邮箱链接。
“关于我们、用户协议、隐私政策、常见问题、2257、DMCA”六页固定使用 `src/lib/info-pages.ts` 中的原 HTML 文案与结构,不再请求富文本接口,底部入口也保持固定。每次访问均由服务端输出正文、标题和对应样式标记,无须等待首页或富文本接口。保留分节间距、折叠问答、编号段落和 `mailto:` 邮箱链接,反馈链接直接使用 `/info/feedback/`。
联系方式与其他黄果站点一致,全站只展示官方 TG `@jbd125`(`https://t.me/jbd125`,`src/lib/site.ts` 的 `OFFICIAL_TG_HANDLE`/`OFFICIAL_TG_URL`):关于我们的“联系我们”、DMCA 联系方式、页脚和侧栏图标、导航页“官方TG”、回家的路“保持联系”、详情页官方链接以及结构化数据 `sameAs`。不再读取后台的 `telegram`、`tgGroup`、`twitterAddr`、邮箱或页脚 `contactList`,也不展示 X、邮箱和 TG 群。
“关于我们”的“联系我们”沿用 HTML 分节和段落样式:商务合作读取 `config.telegram`(兼容 `telegramAdd`),用户反馈读取 `config.tgGroup`,空值再取 `newAddressInfo`;不使用邮箱或在线客服地址代替 TG。服务时间等附加说明读取 `config/footer/info` 的 `footerInfo.contactList`。这些内容随正文一起由服务端输出,未配置的项目不展示。
反馈页恢复原稿的问题选项、环境/设备面板、描述计数、上传区域和提交区。实际提交通过 `feedback/add`,环境信息随描述保存;图片通过现有 `upload/image` 上传,支持最多 3 张、每张 5MB 以内的 JPG/PNG/WebP,不展示接口尚未支持的视频上传。上传失败不清空表单,提交时阻止重复写入,离开页面后隔离迟到的结果。
本站 UI 为了收录和与其他黄果站点区分,使用独立的 `hg-*` 组件(`src/lib/client/render.ts`、`detail.ts`、`player-markup.ts`,样式在 `globals.css` 末尾):
详情页结构按原 HTML 调整:封面与资料卡、热度图标和标签、播放/下载与互动操作栏、三行简介展开/收起、选集横向分段和金币角标、官方链接、评论及嵌套回复。移动端复用原稿的封面、四列操作栏与五列选集布局;无下载配置时只显示播放入口,无推荐数据时不留空侧栏。内容和章节 ID 继续由接口提供,评论分页保持独立路由。
支付和解锁均由用户在页面确认后发起;创建订单不会直接把本地余额改为“支付成功”,余额通过用户信息接口刷新。
充值页按 `91短剧-2组 (3).html#info/deposit` 展示余额与金币用途卡片、套餐面板、推荐角标、选中标记、独立支付按钮和交易明细。先选择接口套餐,再点击“立即支付”打开确认弹窗;价格、赠送金币与流水收支读取真实字段,不使用原稿的模拟金额和交易记录。桌面端四列、移动端三列套餐,交易明细继续使用独立分页路由。
充值确认弹窗按原 HTML 展示订单摘要、赠送/合计金币、金额徽标、支付渠道图标与选中圆点、“去支付”按钮和确认说明。金币数读取 `baseCoin/giftCoin/totalCoin`,价格按接口分单位换算;仅展示套餐配置的支付方式。切换样式按钮会同步实际下单参数,提交期间禁止重复下单。金币和 VIP 下单成功后自动在新标签页打开支付地址;原页保留“打开支付页面”供再次打开同一订单,以及“我已完成支付,刷新余额”。点击去支付时先预留标签页以避免异步弹窗拦截,下单失败或离开页面时关闭等待页,浏览器禁止新窗口时自动在当前页继续支付。创建订单后仍需用户完成真实支付。
详情和播放页点击金币集数,直接打开原 HTML 样式的解锁弹窗,读取所选章节的最新权限、余额和价格;价格优先使用 `costCoins`,其次 `singleCoins`。确认后提交该集的 `chapterId/playletId`,成功后刷新余额并进入对应播放页;余额不足进入充值,会员章节进入会员套餐。读取期间禁止支付,支付期间禁止重复提交或切换目标;关闭弹窗、切换集数或离开页面会隔离迟到的读取结果。已允许观看的章节仍使用真实播放链接。
- 短剧卡片 `card()`:`<article class="hg-card">`,整卡一个链接,`title` 为“《剧名》全N集 在线观看”,海报 alt 为“《剧名》海报”,可见标题、题材标签、简介、热度和集数角标;榜单显示排名数字。
- 推荐视频条目 `videoItem()`:横向缩略图加 `<h3>` 标题链接、题材/集数/热度和三行简介,用于详情页和播放页的“推荐视频”区。
- 首页 banner `hero()`:主图区输出当前推荐的 `<h2>` 标题链接、题材/集数/热度、简介和“立即观看”;右侧 `<ol>` 列出全部 banner 的真实详情链接(首次点击切换主图,再次点击进入详情),只有 1 个 banner 时隐藏列表。
- 详情页:海报 + `<h1>`、“《剧名》全N集在线观看”导语、`<dl>` 信息表(类型链接、集数、热度、收藏、点赞)、操作区;随后是“《剧名》剧情简介”“《剧名》全集列表”“推荐视频”和官方链接。
- 播放页:播放器(桌面端限高居中)、“剧名 第N集” `<h1>`、同一信息表与操作、剧情简介、全集列表(链接带“《剧名》第N集” title)、推荐视频。
App 下载入口(顶部、悬浮图标、首页底部、详情页和 `/navigation/`)统一校验配置。配置返回前隐藏入口;没有有效的 HTTP(S) 下载链接时不展示。电脑端直接点击链接下载,已移除悬浮二维码面板和触发脚本;移动端保留点击下载弹窗,二维码只在实际点击时生成。
图片解密由本项目(`JHA-1047`)的 `src/lib/client/images.ts` 实现:使用相同密钥对前 100 字节做 XOR,识别 JPEG、PNG、GIF、WebP,并兼容未加密图片。封面、轮播、题材、广告、应用图标、用户/评论头像和视频 poster 统一处理;接口原图放在 `data-src` / `data-poster`,完成解密后展示 Blob URL。本地素材和下载二维码保持直接加载。移动端并发 2、桌面端并发 4,首屏主图和弹窗优先;共享请求与缓存、视口懒加载、12 秒超时及占位图回退,页面离开时释放 Blob URL。
播放逻辑由本项目(`JHA-1047`)的 `src/lib/client/player.ts`、`src/lib/client/playback.ts` 和 `src/lib/api/media.ts` 实现:绝对地址直接播放,相对地址走 `/h5/m3u8/`;Safari 优先原生 HLS,其余浏览器优先 hls.js,无 MSE 时再尝试原生 HLS。保留移动端内联属性,HLS 致命错误最多恢复 3 次,重试会重建播放器;切换预告、正片或离开路由时销毁旧实例。锁定章节可看配置的免费预告,正片按 `canWatch` 和参考项目的旧字段兼容规则判定;金币解锁需用户确认,成功后重新获取账户与章节,预告不提交正片观看上报。上一集/下一集使用接口的真实章节 ID。
播放逻辑由本项目(`JHA-1047`)的 `src/lib/client/player.ts`、`src/lib/client/playback.ts` 和 `src/lib/api/media.ts` 实现:绝对地址直接播放,相对地址走 `/h5/m3u8/`;Safari 优先原生 HLS,其余浏览器优先 hls.js,无 MSE 时再尝试原生 HLS。保留移动端内联属性,HLS 致命错误最多恢复 3 次,重试会重建播放器;切换预告、正片或离开路由时销毁旧实例。正片只要接口返回有效 `videoUrl` 即可播放(不判断付费字段),没有地址时可看配置的免费预告;预告不提交正片观看上报。上一集/下一集使用接口的真实章节 ID。
H.264/AVC 与 H.265/HEVC 使用 `hls.js` 1.7.3 完整版解析,保留 HEVC MPEG-2 TS 支持(参考项目为 1.6.13 完整版)。多编码清单按设备能力保留 H.264、`hvc1`、`hev1`,不强制改写编码或限制为 H.264。编码不兼容时在 hls.js 与原生 HLS 之间各尝试一次,保留签名与播放位置;普通网络/密钥错误仍走原有恢复逻辑。无可用解码方式时显示编码不支持提示。H.265 的实际解码依赖浏览器、系统及设备能力,前端不提供转码或软件解码器,参见 [hls.js 官方说明](https://github.com/video-dev/hls.js/blob/v1.7.3/README.md)。
@@ -255,27 +243,14 @@ SMOKE_PUBLIC_ONLY=1 PREVIEW_URL=http://127.0.0.1:21021 npm run smoke:api
- `src/lib/api/page-session.ts`:页面会话准备及同一次请求的 Cookie 传递。
- `src/lib/api/public-cache.ts`:有上限的公共数据缓存、过期刷新及并发合并。
- `src/lib/site-shared-data.ts`:首屏公开配置和页脚数据白名单。
- `src/lib/client/`:共享页面模板及轮播、播放、登录、支付、下载等浏览器交互。
- `src/lib/client/`:共享页面模板及轮播、播放、下载等浏览器交互。
- `public/scripts/site-runtime.js`:原稿布局、移动端菜单和播放器控件。
- `public/assets/`:本地品牌、图标和空状态素材。
- `public/assets/`:本地品牌、图标和空状态素材。黄果吃瓜 logo 为 `hgcg-logo.png`(500×176 比例,左侧图标、右侧字标),悬浮 App 图标为 `hgcg-app-icon.png`;`favicon.png`、`apple-touch-icon.png`、`src/app/icon.png` 和分享图 `og-default.png` 均由同一黄果图标生成。
- `tests/api-integration.test.mjs`:SSR、鉴权隔离和代理边界测试。
- `tests/routing.test.mjs`:路径解析、搜索关键词编码、hash 地址不转换与原生页面导航测试。
- `src/lib/client/startup.ts` 与 `tests/startup.test.mjs`:年龄确认、广告队列和会话进度,以及控制器与浏览器事件适配测试。
## 埋点
## 已删除的功能
按照 [Web SDK v1.2.0 文档](https://doc2.adcenteryyds.cc/web/getting-started.html) 、[飞书埋点规范](https://djprsdgo4i4n.jp.larksuite.com/wiki/X8sjwbzs2ib01Ak2TWzj9Y94pFd)(已通过 Google Chrome 读取,包含 2026-10-17 更新)为本项目(`JHA-1047`)接入,官方 SDK 固定保存在 `public/scripts/web-sdk-v1.2.0.js`。应用 ID 默认 `1047`,可通过 `NEXT_PUBLIC_TRACKER_APP_ID` 配置,必须与当前站点密文和上游应用一致;初始化优先读取 `config/info.reportSdkConfig`,可选 `NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG` 作为同项目的密文备用配置。缺少配置或 SDK 加载失败时,业务页面继续工作。
已移除 Web SDK 埋点(含 `X-SDK-Common`、`X-Tracker-*` 请求头透传)、意见反馈/在线客服、充值与 VIP、个人中心(收藏/历史/点赞/已购/设置)、登录注册以及付费章节解锁。对应页面返回 404,代理白名单不再放行 `auth/login`、`auth/register`、`auth/resetPassword`、`auth/profile`、`coin/*`、`vip/*`、`recharge/*`、`playlet/chapter/unlock`、个人列表和 `upload/image`。
- SDK 自动采集生命周期、网络和会话;关闭自动 PV/点击,由接入层按真实路径上报,避免重复。支持首页、榜单、分页、专题、搜索、账户、信息页和独立导航页;登录、游客恢复和退出后同步 UID,渠道沿用首次归因。
- 内容曝光以可见元素为准,关闭的弹窗、被模态弹窗遮挡的页面、后台标签页和轮播副本不计入;关闭弹窗或恢复标签页后重新观察尚未曝光的元素。短剧、专题和普通福利应用均有曝光、点击;内容无父级时 `parent_item_id` 使用 `0`,满足 SDK 的必填非空字符串校验。
- 广告保留接口广告代码和广告位字段,上报 `adImpression/adClick`;页面广告、大图弹窗、宫格弹窗按实际展示位置分别去重,避免同一广告出现在宫格时被首页曝光记录吞掉。宫格仅统计滚动区域内可见的项目。两种弹窗另按参考项目协议上报 `advertising` 的 `show/click/close` 行为;只关闭实际曝光过的项目,同一展示只记一次关闭,关闭不算广告点击。福利应用有 `advitiseId` 时使用该广告 ID 上报,没有广告关联时按普通内容统计。
- 搜索成功后上报关键词与实际总数,结果点击使用含页码的全局位置;未接推荐引擎的追踪字段保留空串。顶部、侧栏、独立入口页渠道和 App 下载入口记录导航点击。
- 短剧使用 `dramaEvent/dramaStatusChange`,记录展示、播放、暂停、快进/快退、完成、分享、切集和成功解锁;独立视频使用 `videoEvent`。终止播放失败分别上报 `ep_failed/video_failed`(播放失败),自动恢复中的中间错误和预告失败不作为正片失败上报。短剧完播同时补报 `ep_play`,进度固定 100%,保留最后一段播放时长。播放时长按实际播放区间累计,排除缓冲和免费预告,进度为百分比,倍速乘以 100。
- 所有同源业务 API 请求携带 SDK 原始公共字段 `X-SDK-Common`,保留 SDK 返回的完整公共字段,包括应用、渠道、UID、数值时间戳、会话、设备、指纹和版本等;注册、登录、充值及 VIP 下单都使用同一份字段格式。字段来自 `WebSDK.getCommonFields()`,不重新生成指纹或会话 ID,等待最多 1.5 秒。
- 点赞、收藏、评论、购买由上游服务端上报,前端传入 `X-Tracker-Request-Id/X-Tracker-Event-Name/X-DC-Request-Context`,包含完整公共字段和目标剧集上下文。Next.js 代理校验应用 ID、事件对应接口和字段结构后透传;游客重试复用原始快照和请求 ID,不额外从客户端发送业务成功事件。
SDK 未初始化时最多缓存 1000 条事件,配置到达后按顺序补发;配置接口的首次请求不等待 SDK 加载,避免互相等待,首次 SDK 脚本失败后在取得配置时再尝试加载。SDK 返回字段校验失败、事件不在允许列表、地址不可用或异常时,仅触发脱敏的 `tracker:diagnostic` 事件,不向浏览器控制台输出。自带 SDK 仅移除了三处控制台警告,保留字段校验、HTTPS 地址限制和配置解密失败兜底;网络重试和持久队列由官方 SDK 负责。
埋点回归使用 SDK 替身及项目自带官方 SDK 的真实字段校验器;浏览器验证使用同一 SDK 的事件地址参数,将签名后的测试记录发送到本机接收端,不向真实统计后台发送测试事件。生产环境还需在广告中心为当前应用的 `reportSdkConfig` 配置业务域名反向代理地址(SDK 的 `batchUrls`),由部署层转发到文档指定的生产批量上报接口,不能让客户端直连生产上报域名。代理需保留请求体、真实来源 IP 和 SDK 签名;目前未变更线上域名或 Nginx 配置。
“我的点赞”入口位于头像菜单和个人中心书架导航,路由为 `/member/likes/`,分页使用 `/member/likes/page/N/`。服务端通过当前账号请求 `GET /api/app/playlet/like/list?pageNum=N&pageSize=24`,列表不进入公开缓存或搜索索引;空列表显示原 HTML 的空状态和发现入口,接口失败显示重试状态。
+1 -3
View File
@@ -2,8 +2,6 @@
APP_ENV=development
PORT=21021
# Server-only Go API origin. Browser requests use the current site's /api/*.
UPSTREAM_API_URL=http://172.104.181.99:21022
UPSTREAM_API_URL=https://webhgcgapi.mfcg.me
API_CHANNEL=
PUBLIC_DATA_CACHE_TTL_MS=0
NEXT_PUBLIC_TRACKER_APP_ID=1047
NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG=
+1 -3
View File
@@ -2,8 +2,6 @@
APP_ENV=production
PORT=21021
# Server-only Go API origin. Browser requests use the current site's /api/*.
UPSTREAM_API_URL=http://172.104.56.28:26125
UPSTREAM_API_URL=https://webhgcgapi.mfcg.me
API_CHANNEL=
PUBLIC_DATA_CACHE_TTL_MS=60000
NEXT_PUBLIC_TRACKER_APP_ID=1047
NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG=
+1 -3
View File
@@ -2,8 +2,6 @@
APP_ENV=test
PORT=21021
# Server-only Go API origin. Browser requests use the current site's /api/*.
UPSTREAM_API_URL=http://172.104.181.99:21022
UPSTREAM_API_URL=https://webhgcgapi.mfcg.me
API_CHANNEL=
PUBLIC_DATA_CACHE_TTL_MS=60000
NEXT_PUBLIC_TRACKER_APP_ID=1047
NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG=
+4 -11
View File
@@ -1,6 +1,6 @@
# 91av 页面接口对照
核对日期:2026-10-21。来源:[当前 Swagger](https://91av-wz.80sygtm.com/swagger/index.html)。上游地址通过 `API_BASE_URL` 配置,默认 `https://91av-wz.80sygtm.com`;下表省略 `/api/app/` 前缀。
核对日期:2026-10-21。来源:[当前 Swagger](https://91av-wz.80sygtm.com/swagger/index.html)。上游地址通过 `UPSTREAM_API_URL` 配置,默认 `https://webhgcgapi.mfcg.me`;下表省略 `/api/app/` 前缀。
| 页面 | 接口及关键参数 |
| --- | --- |
@@ -18,23 +18,16 @@
| 短剧详情 | `playlet/detail` 传 `slug` 或 `id`;`playlet/chapter/list` 传 `playletSlug` 或 `playletId` |
| 详情推荐 | `playlet/guess-like/list`;必须传当前 `playletId`,不依赖详情中未定义的推荐字段 |
| 章节播放 | `playlet/chapter/detail`;`chapterId` 加 `playletSlug` 或 `playletId`,使用实时观看权限、费用、余额及视频地址。章节 SEO 优先于整剧 SEO |
| 评论 | `comment/list`;`objectId,objectType,pageNum,pageSize,sortOrder`;整剧类型 10、章节类型 11 |
| 收藏、历史、已购 | `playlet/collect/list`、`playlet/history/list`、`playlet/bought/list`;`pageNum,pageSize`;服务端使用 `auth/getInfo` 恢复当前账号,直接输出列表 |
| 账号设置 | `auth/profile`;头像上传 `upload/image`,使用返回的 `sourceNameFs` |
| 充值、VIP | `recharge/package/list`;读取真实套餐金额(分)、金币及支付渠道;余额明细 `coin/trans/list` 使用 `page,pageSize,type` |
| 福利应用 | `applist/list?categoryId=…`;保留分类锚点及空分类隐藏逻辑 |
| 回家的路、独立导航 | `config/info` 的永久地址、备用线路、TG、App 链接;没有 App 地址时隐藏下载入口 |
| 关于等六个信息页 | 按既有要求保留 HTML 静态正文;联系我们使用配置中的商务 TG 与 TG 群,服务时间可取 `config/footer/info` |
| 回家的路、独立导航 | 站点地址固定 `https://huangguocg.com/`,联系方式固定官方 TG `@jbd125`;`config/info` 只提供导航介绍和 App 链接,没有 App 地址时隐藏下载入口 |
| 关于等六个信息页 | 按既有要求保留 HTML 静态正文;联系我们与 DMCA 只展示官方 TG `@jbd125` |
## 写接口
2026-10-22 本项目(`JHA-1047`)调整内容地址:链接优先使用非空 slug,缺少时使用 ID,保留尾斜杠和独立分页。专题详情接口仍必填 ID,slug 路由先分页查询专题目录解析 ID,再读取详情;目录故障返回服务错误,不伪装成专题不存在。详情评论和推荐等只支持数字 ID 的接口在详情返回后使用真实 ID;支持 slug 的章节请求继续并行。canonical、面包屑、选集和 ItemList 使用对应主地址,旧 ID 地址不新增转向 slug 的重定向。
登录、绑定和找回密码使用 `auth/login`、`auth/register`、`auth/resetPassword`;凭证保存在 HttpOnly Cookie。整剧点赞/收藏传 `id` 和布尔状态,章节点赞/收藏只传 `chapterId` 和布尔状态。解锁传 `playletId,chapterId`,仍在用户确认后提交。评论发送、点赞、观看报告的字段按各自请求模型发送。
只使用游客会话 `auth/guest`,凭证保存在 HttpOnly Cookie;登录、注册、找回密码、资料修改、充值/VIP 下单和章节解锁已删除,代理不再放行。整剧点赞/收藏传 `id` 和布尔状态,章节点赞/收藏只传 `chapterId` 和布尔状态。评论发送、点赞、观看报告的字段按各自请求模型发送。
金币下单 `coin/buy`、VIP 下单 `vip/pay` 均使用 `packageId,payMode`,支付渠道只取套餐配置。没有在真实服务上创建订单、付费解锁、注册账号、发送评论或提交反馈。
反馈按 `proto.FeedbackReq` 模型:1 建议、2 Bug、3 体验、4 账号、5 其他;操作描述仍写旧的四类,与模型不一致,本次以请求模型为准。问题正文与附加环境信息合计最多 500 字,优先完整保留用户正文。
## 已移除的旧接口依赖
+5 -6
View File
@@ -15,22 +15,21 @@
| 范围 | 设置 |
| --- | --- |
| 内部搜索、导航入口、回家的路、福利应用 | `noindex,follow` |
| 个人中心、收藏、历史、已购、设置、充值/VIP、反馈 | `noindex,nofollow` |
| 非默认有效排序变体、非默认评论排序 | `noindex,follow`,沿用原有例外规则 |
| 详情和播放页的评论分页,页码大于 1 | `noindex,follow` |
| 个人中心、充值/VIP 等已删除页面 | 返回 404 |
| 非默认有效排序变体 | `noindex,follow`,沿用原有例外规则 |
| 接口成功确认 `totalCount=0` 的公开列表第一页 | `noindex,follow`,继续返回 200 |
| 缺少独立栏目信息的更多列表、纯解锁或无有效正片的播放页 | `noindex,follow`,继续提供原页面 |
noindex 页面不输出 canonical、head 中的 prev/next 或内容 JSON-LD;正文的选集、评论、分页链接继续提供。作品详情没有评论时仍可索引,评论数为 0 不等于作品为空。
noindex 页面不输出 canonical、head 中的 prev/next 或内容 JSON-LD;正文的选集、推荐、分页链接继续提供。作品详情没有评论时仍可索引,评论数为 0 不等于作品为空。
## 去重和结构化数据
- 同 ID 的 `/theme/{id}/` 与 `/topics/{id}/` 当前读取相同资源,以 `/topics/{id}/` 为 canonical。两条路由都可直接访问,不新增重定向。
- 自动生成的专题卡片与 ItemList 链接使用 `/topics/{id}/`;后台明确配置的 `moreUrl` 保留原值。
- 数字资源 ID 的前导零在 canonical 与播放实体 URL 中归一;普通分页仍保留自己的页码。搜索关键词不进行数字归一。
- canonical 仍仅取配置的永久域名,去除跟踪参数;`/rank/`、`/page/1/` 继续使用既有主地址。
- canonical 固定使用 `https://huangguocg.com`,去除跟踪参数;`/rank/`、`/page/1/` 继续使用既有主地址。
- 2026-10-22 起内容主地址优先使用接口的非空 slug,缺少时使用 ID。ID 地址仍直接提供内容,其 canonical 与结构化数据指向该内容的 slug 主地址;评论与操作请求继续使用真实数字 ID。
- 可索引的付费播放页标记 `isAccessibleForFree:false` 与 `hasPart.cssSelector:.play-paywalled-content`,选择器对应真实播放器容器,不改变样式。
- 全部内容免费:有正片地址的播放页即可索引,TVEpisode/VideoObject 标记 `isAccessibleForFree:true`,不再输出付费区域标记。
- VideoObject 只在真实媒体源、有效日期与封面均存在时输出。无权观看的媒体地址不进入 SEO 数据;不编造视频日期或媒体源。
## 状态码与验证
+5 -14
View File
@@ -9,7 +9,7 @@
## 1. 域名与通用约定
- `D` 表示正式站点的 origin,即协议、域名和可选端口,不带末尾 `/`。
- 域名优先取 `config.config.foreverUrlAddr`;无有效值时取 `config.newAddressInfo.foreverUrlAddr`。移除配置中的路径、查询参数和锚点,与前端 canonical 保持一致。
- 域名固定为 `https://huangguocg.com`,与前端 canonical(`src/lib/site.ts` 的 `SITE_ORIGIN`)保持一致,不读取 `foreverUrlAddr`。
- 不使用 API 域名、CDN 域名、请求 Host、临时访问域名或本地地址作为页面域名。
- 生成任务没有有效正式域名时,不猜测域名;暂停本次发布并保留上一份成功结果。
- 页面 URL 以 `/` 结尾;sitemap 中使用绝对地址 `D + 规范路径`。
@@ -93,8 +93,8 @@ sitemap 提交页面 URL,不写入用户凭证或受限媒体源。
- 更多栏目当前使用有限首页栏目快照:类型 `1` 取实际作品列表,类型 `3` 取实际专题列表,以实际条目数为 `totalCount`。SEO 层取 `pageSize = max(24, totalCount)`,因此当前只发布栏目基础地址,不生成 `/config/{key}/page/2/` 等额外分页。
- 第一页只提交基础地址,不额外提交 `/page/1/`。
- 第 2 页及以后保留自己的 canonical,例如 `/shorts/page/2/`,不统一归并到第一页。
- 作品详情和播放页的 `/page/{n}/` 是评论分页;`n > 1` 时不进入 sitemap。
- 搜索、收藏、历史、已购和充值即使有分页,也不进入 sitemap。
- 作品详情和播放页没有分页(评论已删除),`/page/{n}/` 返回 404,不进入 sitemap。
- 搜索即使有分页,也不进入 sitemap;个人中心和充值页已删除。
## 4. Canonical 规则
@@ -152,13 +152,6 @@ sitemap 提交页面 URL,不写入用户凭证或受限媒体源。
| 独立入口 | `/navigation/` | `noindex,follow` |
| 回家的路 | `/home-road/` | `noindex,follow` |
| 福利应用 | `/apps/` | `noindex,follow` |
| 个人中心 | `/member/center/`、`/info/user-center/` | `noindex,nofollow` |
| 收藏 | `/member/favorites/`,包括分页 | `noindex,nofollow` |
| 历史 | `/member/history/`,包括分页 | `noindex,nofollow` |
| 已购 | `/member/purchased/`,包括分页 | `noindex,nofollow` |
| 账号设置 | `/member/settings/`、`/info/settings/` | `noindex,nofollow` |
| 充值及 VIP | `/info/deposit/`、`/info/deposit/vip/`,包括支持的分页 | `noindex,nofollow` |
| 意见反馈 | `/info/feedback/` | `noindex,nofollow` |
以上页面及其参数变体均不进入 sitemap。
@@ -176,13 +169,11 @@ sitemap 提交页面 URL,不写入用户凭证或受限媒体源。
| 情况 | 当前前端行为 | 后端生成处理 |
| --- | --- | --- |
| 空公开列表 | 接口成功确认 `totalCount=0` 时,第一页 `noindex,follow`,仍返回 200 | 不提交 |
| 详情或播放页评论分页 | `/detail/{key}/page/{n}/`、`/play/{key}/{chapterId}/page/{n}/`,`n>1` 时 `noindex,follow` | 不提交 |
| 非默认列表排序 | 除榜单和更多栏目外,公开列表 `sort != 1` 时 noindex | 只生成无参数的默认地址 |
| 非法列表排序 | 公开列表的 `sort` 不属于 `1、2、3` 时 noindex;未传时默认 `1` | 不生成非法变体 |
| 非默认评论排序 | 存在 `commentSort` 且值不等于字符串 `2` 时 noindex | 不生成参数变体 |
| 更多栏目内容不足或无效 | 已存在栏目缺少自己的标题、正文简介或有效卡片时 `noindex,follow`;找不到启用的类型 `1/3` 栏目时按资源缺失处理 | 不提交 |
| 播放页条件不足 | 预告、纯解锁提示、无有效正片或无观看权限时 `noindex,follow` | 不提交 |
| 明确禁止索引或需要登录 | `indexable=false` 或 `loginRequired=true` 时 noindex | 不提交 |
| 明确禁止索引 | `indexable=false` 时 noindex | 不提交 |
| 无效路由或不存在的资源 | 返回 404,且无 canonical | 不提交 |
| 已确认越界的分页 | 返回 404,且无 canonical | 不提交 |
| 临时取数故障 | `failed` 或 `unavailable` 时返回 500,且无 canonical | 不将故障误判为删除;保留上次成功结果,稍后重试 |
@@ -190,7 +181,7 @@ sitemap 提交页面 URL,不写入用户凭证或受限媒体源。
补充约定:
- 榜单和更多栏目可以接受 `sort=2/3`,但 canonical 仍不含参数;sitemap 不重复提交这些参数地址。
- 作品详情的评论数为 0,不等于作品内容为空,不能按空列表规则排除作品详情。
- 推荐视频为空不等于作品内容为空,不能按空列表规则排除作品详情。
- 不存在的内容与临时故障必须区分;无法可靠判断一批内容时,不发布因取数失败而缺项的新结果。
## 7. 建议的生成与发布约定
+18
View File
@@ -171,3 +171,21 @@ H.264/H.265 兼容测试另覆盖 MSE/原生 HLS 编码错误回退、签名与
浏览器使用真实页面及未修改的官方 SDK,测试专用包装通过 SDK 支持的单次事件地址参数把请求送入本机接收端,避开官方域名解析器对配置地址的 HTTPS 要求。已验证大图广告展示/点击、宫格展示/点击、页面访问、内容曝光,以及原生页面跳转前的内容点击均实际发出带 `event_sig` 的网络记录。UI 点击和弹窗滚动经浏览器执行,未向正式统计后台发测试事件。未验证正式部署的反向代理和统计后台入库结果。
最终验证:测试环境构建(含 TypeScript)及全部 323 项回归测试通过。最后一轮本机浏览器验证收到 78 条带签名的 SDK 事件,其中 `advertising` 为 show 21、click 2、close 21;大图广告的三种行为均到达,宫格关闭只覆盖实际曝光的广告,关闭未增加 `ad_click`。另外一轮已确认原生页面跳转前的 `app_pageitem_click` 实际到达,内容父级字段为字符串 `0`。所有网络验证仅使用本地模拟业务数据和本地测试签名配置。
## 2026-10-27 移除埋点与客服、切换接口域名
删除 Web SDK 及埋点适配层、`X-SDK-Common`/`X-Tracker-*` 请求头透传、内容与广告的追踪属性和相关环境变量;删除意见反馈页(`/info/feedback/` 返回 404)、`feedback/add` 代理白名单、页脚/侧栏/播放页/充值页的反馈与客服入口及样式。上游接口统一为 `https://webhgcgapi.mfcg.me`;配置和快照都没有永久域名时,canonical 使用 `https://huangguocg.com`。类型检查、生产构建通过;除部署脚本测试因中文目录名被 URL 编码导致找不到 `node_modules` 外,其余测试全部通过。
## 2026-10-27 删除充值、用户中心、登录与付费解锁;品牌与联系方式
删除充值/VIP(`/info/deposit/*`)、个人中心(`/member/*`、`/info/user-center/`、`/info/settings/`)、登录注册/找回密码/退出、头像与资料修改以及付费章节解锁;对应页面返回 404,代理白名单只保留游客会话与浏览、点赞、收藏、评论所需接口。旧版本遗留的已登录 Cookie 失效后自动换成游客,不再提示登录。付费集保留锁定标记和免费预告,播放区提示“本集为付费内容,暂不可观看”。
站点地址(canonical、sitemap、导航页线路与永久入口、回家的路、详情页官方地址)固定为 `https://huangguocg.com`;导航站名固定为“黄果吃瓜”,页面上写死的“91短剧”改为“黄果吃瓜”;logo、favicon、touch icon、App 图标与分享图换成黄果图标。联系方式与其他黄果站点一致,只保留官方 TG `@jbd125`。类型检查、生产构建通过,307 项测试中除部署脚本测试(中文目录名被 URL 编码,环境问题,改动前同样失败)外全部通过。
## 2026-10-27 详情页、播放页、卡片与首页 banner 改版;评论改为推荐视频
为收录及与其他黄果站点区分,短剧卡片、推荐视频条目、首页 banner、详情页和播放页改用独立的 `hg-*` 结构和样式:全部内容由服务端输出,使用 `article`、`h1/h2/h3`、`dl` 信息表、描述性链接 title 与“《剧名》海报”类 alt;首页 banner 以 `<ol>` 输出全部推荐的真实详情链接;详情/播放页增加“剧情简介”“全集列表”“推荐视频”小标题。评论读取、发表、点赞及评论分页全部删除,`/detail|play/.../page/N/` 返回 404,代理不再放行 `comment/*`;评论区换成 `playlet/guess-like/list` 的 12 部推荐。桌面与 390px 手机宽度截图核对;类型检查、生产构建通过,除部署脚本环境问题外测试全部通过。
## 2026-10-27 全部短剧和视频免费观看
前端去掉付费判断:`chapterAccess` 只看接口是否返回有效视频地址,`needUnlock`/`needVip`/`payType`/`canWatch` 不再影响播放、选集链接、SSR 正片地址和索引;选集付费角标、`play-paywalled-content` 及 `isAccessibleForFree:false` 结构化数据删除,帮助中心常见问题改为“全部免费观看”。真实接口核对:付费章节(如 14193 第 2 集起)`chapter/detail` 返回 `videoUrl: ''`,地址由后端签名下发,需后台将章节设为免费后前端即可播放。
+19 -23
View File
@@ -1,5 +1,5 @@
---
项目名称: 91短剧web
项目名称: 黄果吃瓜web
一级部门: 北斗
二级部门: 瑞升
@@ -48,7 +48,7 @@ DMP编码: JHA-1047
# 1. 项目说明
91短剧 Web 项目,DMP 编码 **JHA-1047**,应用及埋点 App ID 为 **1047**。采用 Next.js App Router、React、TypeScript,实现 PC 和移动端响应式页面,布局依据提供的 HTML 原稿还原。
黄果吃瓜 Web 项目,DMP 编码 **JHA-1047**,站点域名为 **huangguocg.com**。采用 Next.js App Router、React、TypeScript,实现 PC 和移动端响应式页面,布局依据提供的 HTML 原稿还原。
本文按当前仓库代码整理,更新日期为 **2026-10-26**;该日期不代表安全审计或线上验收时间。人员信息沿用原登记信息,部署目标以当前脚本默认值为准。
@@ -56,10 +56,10 @@ DMP编码: JHA-1047
- 首页动态栏目、轮播、顶部及侧栏导航、广告与宫格广告弹窗。
- 短剧列表、排行榜、专题、题材/标签、栏目更多页、搜索及独立分页。
- 短剧详情、章节播放、HLS 播放、图片解密、点赞、收藏、评论与分享。
- 游客/账号登录、个人中心、观看历史、我的收藏、我的点赞、已购剧集、账号设置。
- 金币充值、VIP 套餐、章节解锁、反馈、App 下载、福利应用、回家的路和独立导航页。
- 服务端 SEO 元数据、JSON-LD、robots.txt 与广告/页面/播放等埋点。
- 短剧详情、章节播放、HLS 播放、图片解密、点赞、收藏与分享;详情/播放页以“推荐视频”代替评论。
- 游客会话(已删除登录注册、个人中心、充值/VIP 和付费解锁)。
- App 下载、福利应用、回家的路和独立导航页。
- 服务端 SEO 元数据、JSON-LD、robots.txt。
本仓库负责网站页面、浏览器交互和服务端 API 代理,不包含 Go 业务服务、管理后台或数据库部署代码。独立“剧集”入口及 `/series/`、`/video/*` 页面已移除。
@@ -70,7 +70,7 @@ DMP编码: JHA-1047
| `src/app/` | App Router 页面、API/媒体代理、robots 和健康检查 |
| `src/components/site/` | 首页、详情、播放、导航等页面组件 |
| `src/lib/api/` | 上游配置、接口白名单、会话、公共缓存和媒体请求 |
| `src/lib/client/` | 登录、账户、播放、解锁、广告、图片及埋点交互 |
| `src/lib/client/` | 游客会话、播放、广告及图片交互 |
| `src/lib/routing.ts` | 路由解析、slug/ID、分页地址与导航匹配 |
| `src/lib/seo*.ts`、`src/lib/structured-data.ts` | TDK、canonical、索引策略、SEO 快照和 JSON-LD |
| `src/proxy.ts` | 页面尾斜杠规范化和首屏会话准备 |
@@ -84,8 +84,6 @@ DMP编码: JHA-1047
| 专题 / 标签 / 更多栏目 | `/topics/`、`/topics/{key}/`、`/tag/{key}/`、`/config/{key}/` |
| 详情 / 播放 | `/detail/{key}/`、`/play/{key}/{chapterId}/` |
| 搜索 | `/search/{keyword}/` |
| 个人中心及书架 | `/member/center/`、`/member/history/`、`/member/favorites/`、`/member/likes/`、`/member/purchased/` |
| 充值 / VIP | `/info/deposit/`、`/info/deposit/vip/` |
| 官方入口 / 回家的路 / 福利应用 | `/navigation/`、`/home-road/`、`/apps/` |
内容地址优先使用有效英文/拼音 slug,无效或缺失时使用 ID;播放页章节段使用真实章节 ID。搜索词作为单个路径段编码。支持分页的页面使用 `/page/{N}/`,第一页使用基础路径;详情和播放页的分页对应评论。有效页面无尾斜杠访问返回 308,API、媒体和静态资源保留原路径。确认不存在的资源或越界分页返回 404,上游业务服务异常与资源不存在分别处理。
@@ -101,7 +99,7 @@ DMP编码: JHA-1047
- 浏览器请求本站 `/api/*`,Next.js 按白名单转发到 Go 后端的 `/api/app/*`。`UPSTREAM_API_URL` 仅服务端使用,`API_BASE_URL` 是兼容别名。
- `/h5/m3u8/*`、`/enkey`、`/newenkey` 由媒体代理处理,保留签名查询、客户端相关请求信息;媒体和密钥不缓存,不转发应用登录 Cookie 或 Authorization。
登录会话通过 HttpOnly Cookie 传递;SSR 启动数据按字段白名单输出,不向浏览器暴露后端登录凭证。公共接口和账户、余额、订单、付费权限数据采用不同缓存策略。
游客会话通过 HttpOnly Cookie 传递;SSR 启动数据按字段白名单输出,不向浏览器暴露后端登录凭证。公共接口和账户、余额、订单、付费权限数据采用不同缓存策略。
本仓库不直接连接 MongoDB、Redis 或 Elasticsearch。Go 后端是否使用这些中间件及其拓扑,由后端/运维文档确认;本仓库没有 Telegram Bot 部署通知实现。
@@ -109,9 +107,9 @@ DMP编码: JHA-1047
| 环境 | 配置文件 | Go API 上游 | 网站端口 | 构建目录 |
| --- | --- | --- | --- | --- |
| 开发 | `config/development.env` | `http://172.104.181.99:21022` | `21021` | `.next-dev/` |
| 测试 | `config/test.env` | `http://172.104.181.99:21022` | `21021` | `.next-test/` |
| 生产 | `config/production.env` | `http://172.104.56.28:26125` | `21021` | `.next/` |
| 开发 | `config/development.env` | `https://webhgcgapi.mfcg.me` | `21021` | `.next-dev/` |
| 测试 | `config/test.env` | `https://webhgcgapi.mfcg.me` | `21021` | `.next-test/` |
| 生产 | `config/production.env` | `https://webhgcgapi.mfcg.me` | `21021` | `.next/` |
配置由 [scripts/next-env.mjs](scripts/next-env.mjs) 加载,优先级为:进程环境变量 > `config/{环境}.local.env` > `config/{环境}.env`。开发环境兼容旧 `.env.local`,优先级低于 `config/development.local.env`;测试和生产不从旧 `.env.local` 加载本站业务配置。
@@ -121,11 +119,10 @@ DMP编码: JHA-1047
- 公共配置、页脚、导航、分类和标签选项默认缓存 60 秒,首页缓存 30 秒,进程内最多 128 条并合并并发请求;开发配置关闭缓存。`PUBLIC_DATA_CACHE_TTL_MS=0` 可关闭,正数仅缩短既有缓存上限。
- 公共缓存按上游地址隔离,各实例独立;配置和首页刷新失败可沿用已成功的数据。账户及付费权限不进入公共缓存。
- canonical 和站点身份取后台配置的永久域名,优先 `config.foreverUrlAddr`,其次 `newAddressInfo.foreverUrlAddr`。有效 SEO 配置保存在对应构建目录的 `cache/seo/`,异常时复用最近一次有效快照;不以 API IP 或访问 Host 作为正式域名。
- 生产 `/robots.txt` 允许抓取公开资源,限制 `/api/` 和 `/healthz`;开发、测试禁止全站抓取。账户、充值、搜索、工具等页面另有页面级 `noindex` 规则。
- 生产 robots 在取得有效永久域名后声明 `https://{永久域名}/sitemap.xml` 形式的绝对 URL。**本仓库没有 sitemap XML 生成或读取路由**;现有 XSL 和响应头配置不等于已提供 XML,需由后端/反向代理另行接入。规则见 [后端 Sitemap 对接约定](docs/sitemap-backend-contract.md)。
- 导航、官方入口、商务 TG、用户反馈 TG 群、广告和下载地址由接口动态配置;未配置有效 App 下载链接时隐藏入口。
- 埋点使用 App ID `1047`,优先读取接口 `reportSdkConfig`,构建配置 `NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG` 为兜底;配置及上报链路需与后端保持一致。
- canonical、sitemap 和页面展示的站点地址固定为 `https://huangguocg.com`(`src/lib/site.ts`),不读取后台 `foreverUrlAddr`。有效 SEO 配置保存在对应构建目录的 `cache/seo/`,异常时复用最近一次有效快照;不以 API IP 或访问 Host 作为正式域名。
- 生产 `/robots.txt` 允许抓取公开资源,限制 `/api/` 和 `/healthz`;开发、测试禁止全站抓取。搜索、工具等页面另有页面级 `noindex` 规则。
- 生产 robots 声明 `https://huangguocg.com/sitemap.xml`。**本仓库没有 sitemap XML 生成或读取路由**;现有 XSL 和响应头配置不等于已提供 XML,需由后端/反向代理另行接入。规则见 [后端 Sitemap 对接约定](docs/sitemap-backend-contract.md)。
- 站名(导航页固定“黄果吃瓜”)、官方入口和联系方式(官方 TG `@jbd125`)固定;广告和下载地址由接口动态配置,未配置有效 App 下载链接时隐藏入口。
# 3. 运维部署
@@ -193,11 +190,11 @@ PREVIEW_URL=http://127.0.0.1:21021 npm run smoke:api
| 风险点 | 影响及处理要求 |
| --- | --- |
| 上游及反向代理配置错误 | SSR、登录或媒体可能失败;区分网站 `21021`、API `21022/26125` 和 SSH `22889`。本站 `/api/*` 应进入 Next.js,避免上游回环;如启用可选 `current` 模式,必须先确认 `/api/app/*` 转发到 Go。 |
| 上游及反向代理配置错误 | SSR、游客会话或媒体可能失败;区分网站 `21021`、API `21022/26125` 和 SSH `22889`。本站 `/api/*` 应进入 Next.js,避免上游回环;如启用可选 `current` 模式,必须先确认 `/api/app/*` 转发到 Go。 |
| 播放签名、IP、UA 或密钥异常 | 可能出现播放 502;保留签名查询和客户端信息,检查真实上游响应,AES 密钥必须为 16 字节。H.265 解码受设备及浏览器限制,前端不提供转码。 |
| 个人数据误入共享缓存 | 可能造成身份或权限混用;新增接口需延续公开数据与账户、订单、付费权限隔离,首屏输出遵守字段白名单。 |
| 发布包平台或环境不匹配 | 生产需匹配 Linux 架构/libc,测试包需安装运行依赖;同名产物发布前核对配置和校验值。 |
| SEO 配置或 sitemap 未接入 | 测试环境禁止抓取;生产需核对 `APP_ENV`、永久域名、robots、页面索引标记及实际 sitemap 响应,不能只确认 Sitemap 声明。 |
| SEO 配置或 sitemap 未接入 | 测试环境禁止抓取;生产需核对 `APP_ENV`、robots、页面索引标记及实际 sitemap 响应,不能只确认 Sitemap 声明。 |
| 服务器更新脚本未交接 | 本地脚本成功不等于业务验收通过;运维应提供远端脚本、服务管理、日志、监控告警及回退说明。 |
# 5. 历史事故(可选)
@@ -206,10 +203,9 @@ PREVIEW_URL=http://127.0.0.1:21021 npm run smoke:api
# 6. 交接说明(可选)
- **应用标识**:DMP 为 `JHA-1047`;三套环境的 `NEXT_PUBLIC_TRACKER_APP_ID` 均为 `1047`,对接后台与 SDK 时保持一致。
- **应用标识**:DMP 为 `JHA-1047`。
- **新增页面**:复用集中路由、slug/ID、分页和 SEO 规则,补充有效/缺失/异常状态处理。导航与下载入口优先消费动态配置,不写死演示链接。
- **个人列表**:我的点赞入口为 `/member/likes/`,上游接口为 `GET /api/app/playlet/like/list`,与收藏、历史、已购共同保持私有响应及禁止索引。
- **配置交接**:确认实际运行目录的私有覆盖、上游连通性、正式永久域名、TG 联系方式、广告、下载及埋点配置。新版本切换需保留服务器私有配置和对应 SEO 快照。
- **配置交接**:确认实际运行目录的私有覆盖、上游连通性、广告及下载配置。新版本切换需保留服务器私有配置和对应 SEO 快照。
- **发布交接**:确认 SSH 权限、远端 `run.sh`/`rsupdate.sh`、服务管理名称、日志与回退方法;本文记录的是仓库默认值,不替代服务器实际配置验收。
- **待补登记**:最后审计人、最后审计时间、告警群,以及运维确认后的监控和事故台账;不将本次文档整理视为已完成审计。
- **参考资料**:[运行与部署说明](README.md)、[页面接口对照](docs/api-contract.md)、[索引策略](docs/indexation-policy.md)、[Sitemap 对接约定](docs/sitemap-backend-contract.md)。历史核对记录以其日期为界,当前配置和行为以代码为准。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 25 KiB

-28
View File
@@ -1,28 +0,0 @@
<svg width="500" height="176" viewBox="0 0 500 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_45235_124956)">
<path d="M463.454 54.534C463.262 53.8027 463.416 53.1163 463.653 52.4203C464.577 49.7324 467.858 45.8321 469.446 43.4745C472.333 39.1893 477.91 30.1569 481.682 27.1515C488.12 22.0227 496.075 28.8579 490.991 35.4975C483.145 41.0849 475.668 47.9202 467.678 53.2414C466.36 54.1203 465.08 55.4931 463.454 54.5373V54.534Z" fill="#FCD02B"/>
<path d="M477.637 63.0442C477.557 63.0634 477.541 63.458 477.287 63.5414C476.857 63.6793 472.784 63.891 472.3 63.814C468.188 63.1629 475.44 58.6595 476.331 58.0373C479.699 55.6926 490.817 48.2319 494.364 48.1389C499.839 47.9945 502.149 55.1248 497.52 58.0565C496.635 58.6178 495.708 58.8199 494.727 59.1118C489.158 60.7604 483.276 61.6521 477.64 63.0442H477.637Z" fill="#FCD02B"/>
<path d="M25.7009 139.13C20.6202 135.371 15.6325 130.791 10.6576 126.765C7.80935 124.462 2.48809 121.742 2.46884 117.611C2.44318 112.409 7.91199 109.673 12.3416 111.931C15.0423 113.307 19.876 120.831 21.8101 123.632C24.0362 126.849 30.6533 135.99 31.6989 139.092C32.1191 140.346 31.7502 141.818 30.2363 141.805C29.8931 141.805 26.2301 139.518 25.7041 139.13H25.7009Z" fill="#FCD02B"/>
<path d="M3.05646e-05 141.885C0.0128606 141.85 0.429837 141.828 0.535685 141.581C1.84756 138.582 3.2332 137.071 6.83203 137.324C10.3988 137.574 22.3243 146.126 25.5543 148.749C26.6609 149.651 28.6592 150.815 28.1043 152.515C27.4852 154.407 22.5104 152.416 21.2177 152.121C16.0184 150.937 8.95862 149.785 4.09923 148.069C2.29982 147.434 1.1804 146.398 0.5421 144.57C0.4138 144.201 -0.0512896 142.007 3.05646e-05 141.882V141.885Z" fill="#FCD02B"/>
<path d="M62.6914 41.4902C91.9149 22.1875 138.542 29.013 150.805 64.8789C151.202 66.0399 151.398 68.4937 151.748 69.251H151.745C156.55 79.6689 152.717 100.139 148.759 111.202C136.041 146.745 104.726 173.682 66.1719 175.52C60.6742 175.783 55.2529 175.279 49.7969 174.868C47.7924 174.718 46.2465 175.311 44.4023 174.169C42.9301 173.258 42.2085 171.606 42.2373 169.894C42.3175 165.416 50.1729 151.525 53.6562 148.57C57.8612 145.004 60.337 146.356 65.209 146.01C74.0264 145.381 82.6614 142.241 89.7051 137.147C91.6456 135.746 93.4454 134.011 95.1807 132.362C83.8422 135.785 72.8274 136.369 61.582 132.292C49.0791 127.76 40.9418 118.557 37.0479 105.826C35.0657 99.3536 35.4402 95.803 35.2061 89.0674C34.6063 71.7757 48.1678 51.0839 62.6914 41.4902ZM83.6387 61.96H83.6377L83.6367 61.9609C81.8856 62.0572 79.509 63.9522 78.6172 65.4404C77.4304 67.4291 77.5809 69.3763 77.2217 71.583H77.2246C75.5439 81.8373 74.5848 92.2168 73.0869 102.5C73.6188 107.482 76.7179 109.783 80.9043 109.213C81.0412 109.194 81.1792 109.173 81.3184 109.148C81.4461 109.126 81.5746 109.1 81.7041 109.072C81.9433 109.021 82.1856 108.961 82.4307 108.892C82.7598 108.798 83.094 108.69 83.4326 108.564C83.468 108.551 83.5046 108.54 83.54 108.526C83.7078 108.463 83.8764 108.396 84.0469 108.33C85.092 107.923 86.1883 107.465 87.3223 106.966C95.4105 103.406 105.421 97.7665 112.517 93.8809C116.58 91.6581 120.673 90.1277 120.218 84.5596C120 81.8944 118.492 80.8585 116.757 79.2773C107.343 74.5816 98.2334 67.1369 88.7842 62.749C87.251 62.037 85.2713 61.8701 83.6387 61.96ZM65.6748 42.0742C65.1103 42.0486 64.5483 42.0327 63.9902 42.1514C61.9786 42.5723 54.6342 49.179 53.0049 51.0488C52.7096 51.389 51.1995 53.2195 51.2471 53.4541C51.587 55.1087 53.8085 51.8105 54.1143 51.5234C57.6906 48.1684 61.1839 45.7723 65.0713 42.8984C65.4305 42.6322 65.87 42.9015 65.6777 42.0742H65.6748ZM66.2266 41.3975L66.5186 42.0098L68.7822 40.9355L68.4902 40.3223L66.2266 41.3975ZM68.8916 40.3135C70.0687 39.9478 71.1336 39.3478 72.2979 38.8955C72.7565 38.7191 73.2311 39.2029 73.0547 38.2246H73.0518L68.8916 40.3135ZM74.0137 37.9072C73.5648 37.6767 73.1579 38.1094 73.376 38.2217C73.825 38.4524 74.2318 38.0195 74.0137 37.9072Z" fill="#FCD02B"/>
<path d="M83.6384 61.9609C83.138 64.0939 86.7176 61.9032 86.5251 63.5615C82.9712 63.6705 80.3058 65.3032 79.4879 68.8763L78.1824 78.316C77.6211 78.316 77.8296 76.9112 77.852 76.5391C77.9899 74.3804 78.7084 72.1833 78.5064 69.9797L77.2266 71.5835C77.589 69.3799 77.4351 67.4297 78.6218 65.4411C79.5103 63.9528 81.8903 62.054 83.6416 61.9609H83.6384Z" fill="#FDFDFD"/>
<path d="M65.6739 42.0729C65.8664 42.9037 65.4301 42.631 65.0677 42.8972C61.177 45.7712 57.6872 48.1672 54.1108 51.5222C53.8061 51.8077 51.5833 55.1082 51.2433 53.4532C51.1952 53.219 52.7059 51.3875 53.001 51.0475C54.6337 49.1743 61.9757 42.5701 63.9868 42.1499C64.5481 42.0312 65.1062 42.0472 65.6707 42.0729H65.6739Z" fill="#FCD02B"/>
<path d="M68.4922 40.3143L66.2266 41.3906L66.5183 42.0048L68.784 40.9286L68.4922 40.3143Z" fill="#FCD02B"/>
<path d="M73.0501 38.2246C73.2297 39.2029 72.7518 38.7154 72.2931 38.895C71.132 39.3472 70.0671 39.947 68.8867 40.3127L73.0469 38.2246H73.0501Z" fill="#FCD02B"/>
<path d="M74.0153 37.9066C74.2334 38.0189 73.8261 38.4519 73.377 38.221C73.1589 38.1087 73.5662 37.6757 74.0153 37.9066Z" fill="#FCD02B"/>
<path d="M470.626 65.4876H486.343C487.536 65.4876 486.282 68.6374 486.208 69.2019C485.342 75.726 483.924 82.0544 482.754 88.5207C479.184 108.276 476.396 128.73 472.288 148.299C470.972 154.563 469.045 159.859 462.534 162.281C458.479 163.788 447.702 164.186 443.346 163.65C442.506 163.548 442.204 163.496 442.224 162.528C442.564 158.496 442.105 153.604 442.554 149.675C442.865 146.952 444.241 148.174 445.931 148.235C449.729 148.376 455.92 148.819 456.847 144.081C460.243 123.662 464.009 103.224 467.492 82.8787C468.358 77.8173 468.843 72.31 469.924 67.3479C470.065 66.6968 470.071 65.9463 470.626 65.4844V65.4876Z" fill="#FDFDFD"/>
<path d="M464.532 74.791C464.638 74.8199 465.1 75.2914 465.171 75.4389C465.562 76.2601 464.212 83.2492 463.958 84.8017C460.911 103.389 457.203 121.903 454.025 140.455C453.948 140.551 453.377 140.866 453.309 140.866H440.479C440.367 140.866 440.097 140.507 440.043 140.333C443.408 118.641 447.443 96.9678 451.199 75.317C451.292 75.1855 452.241 74.791 452.35 74.791H464.539H464.532Z" fill="#FDFDFD"/>
<path d="M446.562 66.7787L389.462 66.7562C386.697 66.6247 387.044 69.5564 386.71 71.3815C383.647 88.1664 381.184 105.195 377.549 121.881C374.829 134.371 370.381 146.364 365.242 158.02L365.993 158.898C368.607 159.902 371.227 160.916 373.816 161.981C374.701 162.347 377.309 164.069 378.05 163.601C378.489 163.322 382.107 155.405 382.637 154.235C387.284 143.961 390.424 133.7 392.763 122.663C392.987 121.595 392.375 120.312 393.645 120.017C393.911 119.956 394.87 119.812 394.764 120.335L393.481 120.659H407.594L403.428 120.02L408.236 119.696L406.911 127.029C406.244 127.458 395.948 126.785 393.949 127.057C393.353 127.138 393.019 127.15 392.695 127.728L386.168 162.273C386.053 162.529 386.47 162.994 386.588 162.994H429.89C431.198 162.994 431.695 160.804 432.003 160.037C432.116 159.758 432.574 159.781 432.619 159.566C432.764 158.886 432.318 158.19 432.382 157.471C433.193 148.5 436.42 138.55 437.402 129.447C437.543 128.899 436.731 127.074 436.305 127.074H421.71L422.673 120.655C427.696 120.315 433.293 121.111 438.245 120.671C438.813 120.62 439.58 120.614 439.997 120.177C441.199 118.926 441.575 109.381 442.239 107.184H425.239C425.386 106.356 426.81 100.769 425.72 100.769H411.126L410.163 107.184H395.73C395.794 106.007 396.339 100.086 396.993 99.6268L424.777 99.4632C425.8 99.2868 425.608 100.409 426.349 100.474L441.26 100.272C442.559 99.6076 443.095 98.7448 443.502 97.3816C445.061 92.1854 446.065 84.2372 446.95 78.6978C447.338 76.2761 448.426 71.6573 448.362 69.4954C448.326 68.2926 447.791 67.1508 446.559 66.7787H446.562ZM421.072 139.904C420.2 142.441 420.427 147.39 419.472 149.616C419.388 149.808 418.468 150.168 418.346 150.168H402.95C402.854 150.168 402.376 149.69 402.517 149.43L404.393 139.904H421.072ZM429.251 86.9796H399.903C400.499 84.9011 400.454 82.4121 401.346 80.5646H430.695C430.457 81.4114 429.919 86.9796 429.251 86.9796Z" fill="#FDFDFD"/>
<path d="M286.354 74.1494H309.127L306.263 89.0868L295.046 89.2568L292.128 105.904H303.354L300.743 120.613L288.612 120.671C288.817 121.99 287.374 123.702 287.791 124.806C287.977 125.293 288.75 126.102 289.084 126.746C292.262 132.869 295.05 139.214 298.527 145.189L289.392 162.035C283.817 157.24 281.049 149.565 278.65 142.79C278.053 142.652 278.027 142.992 277.742 143.323C274.663 146.886 272.032 151.874 268.7 155.771C267.763 156.868 261.464 163.745 260.787 163.925C260.11 164.104 251.261 156.207 249.814 155.409C249.442 154.174 254.115 150.321 255.081 149.208C256.325 147.771 257.903 145.748 259.077 144.224C264.601 137.055 269.316 129.126 272.558 120.658H258.445L261.027 105.92C261.541 105.423 273.258 106.173 275.301 105.92C275.901 105.846 276.388 105.766 276.587 105.118L279.618 89.228L278.64 89.4397L272.555 99.6716L259.728 98.2058L273.877 65.3641L274.643 64.8477L289.257 65.6335C289.69 65.9286 289.578 66.4161 289.501 66.8556C289.119 69.0784 286.659 71.747 286.354 74.1527V74.1494Z" fill="white"/>
<path d="M309.77 147.28C309.251 143.034 308.353 138.829 307.493 134.643C307.317 133.777 306.268 132.308 307.025 131.224C307.451 130.611 320.028 129.588 321.802 128.969C322.074 128.988 322.402 128.978 322.623 129.139C323.329 129.649 325.317 144.256 325.446 146.28L323.242 147.28H331.582L341.352 128.975L356.6 130.925L347.94 147.284H359.647C360.141 147.284 360.106 148.249 360.064 148.637C359.737 151.607 358.326 155.671 357.659 158.773C357.524 159.398 357.54 161.717 356.764 161.717H295.34L298.387 147.284H309.774L309.77 147.28Z" fill="white"/>
<path d="M369.384 81.8012L311.877 82.1733C311.027 82.0418 313.782 68.7819 314.144 67.7042C315.383 67.0531 316.656 67.7299 317.933 67.7459C335.577 67.9769 353.241 67.5535 370.889 67.717C371.225 67.7235 371.649 67.7395 371.86 68.0346C372.3 68.644 369.458 80.0114 369.384 81.8012Z" fill="white"/>
<path d="M365.421 87.3001H313.138L305.289 126.634C305.148 126.974 305.514 127.715 305.761 127.715H357.081C357.309 127.715 358.512 126.74 358.669 126.416C361.231 113.785 363.47 101.057 365.882 88.3714C366.036 88.0314 365.664 87.2969 365.417 87.2969L365.421 87.3001ZM345.168 113.307C338.048 113.89 330.715 113.663 323.566 113.278L325.548 101.952L325.968 101.41H347.298L345.168 113.307Z" fill="white"/>
<path d="M203.82 48.3031C204.529 48.4666 205.908 48.8932 206.944 49.1563C209.866 49.9004 218.613 51.8057 220.714 53.0277C222.786 54.2337 223.421 56.6747 223.177 58.9263L202.479 162.677C201.433 166.23 198.322 168.418 194.746 169.056L164.781 168.941C162.494 168.081 161.359 165.903 161.285 163.527L176.665 86.1934C176.671 85.9464 175.594 85.8181 175.337 85.8694C172.264 86.5045 167.722 89.8083 164.598 90.1643C161.542 90.5107 159.101 88.7017 159.332 85.5487C160.765 79.6116 160.801 72.2856 162.436 66.5089C162.847 65.0591 163.861 63.8274 165.214 63.1923C174.821 58.8044 184.341 52.7423 194.079 48.7553C198.277 47.0361 199.506 47.3119 203.817 48.3031H203.82Z" fill="#FCD02B"/>
<path d="M217.721 2.13574C221.749 -3.29773 229.63 2.70001 225.274 8.0918C224.986 8.45104 224.287 8.63105 224.203 8.77539L224.197 8.77832C223.819 9.41982 224.566 11.8519 224.79 12.7051C225.518 15.4828 226.673 20.5563 227.661 22.9844C227.786 23.2923 227.767 23.6454 228.178 23.7705C228.992 24.0163 239.786 19.9539 241.585 19.5947C242.364 19.0142 241.854 17.6667 242.887 16.4062C246.733 11.6976 254.268 16.8877 250.435 22.0293C249.059 23.8734 248.009 23.2992 246.479 23.8477C245.158 24.3225 235.42 45.9666 233.492 48.709C233.146 49.3023 232.703 49.976 231.969 50.0947C230.564 50.3225 224.925 47.9902 223.09 47.4385C212.508 44.2599 202.321 41.5209 191.483 39.2725C189.594 38.8812 186.451 39.4552 186.624 36.5078L190.643 9.81738C188.327 8.07873 188.052 4.18555 190.287 2.29004C193.973 -0.837286 199.107 2.57577 197.687 7.12402C197.487 7.75886 196.507 8.23362 197.017 9.02246L205.369 18.3242C207.739 16.0022 210.135 13.6735 212.582 11.4316C213.432 10.6522 217.002 8.06931 217.079 7.25781C217.258 5.30796 215.928 4.55403 217.721 2.13574ZM219.479 11.9248C217.034 13.2302 206.575 24.6463 205.022 24.7461C204.48 24.7814 204.031 24.5848 203.617 24.248C202.257 23.1348 199.858 19.4505 198.46 17.8467C198.216 17.5677 197.793 17.487 197.604 17.2754C197.453 17.1054 195.249 13.4103 194.62 14.1641L192.051 34.3682V34.373C205.054 36.3168 217.502 40.5537 230.024 44.3418L240.161 25.0762L240.163 25.0771V25.0742L240.161 25.0762L240.16 25.0752L240.162 25.0723C239.572 24.5196 226.682 30.0984 225.399 29.749C224.575 29.5244 224.389 28.8194 224.116 28.1299C222.269 23.4502 221.711 17.4201 219.953 12.7275C219.834 12.41 219.831 12.1301 219.479 11.9248Z" fill="#FCD02B"/>
</g>
<defs>
<clipPath id="clip0_45235_124956">
<rect width="500" height="175.589" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

-28
View File
@@ -1,28 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128"><defs><clipPath id="app91tile"><rect width="128" height="128" rx="29"/></clipPath></defs><g clip-path="url(#app91tile)"><rect width="128" height="128" fill="#202020"/><svg x="12" y="9" width="104" height="78" viewBox="0 0 250 176" fill="none">
<g clip-path="url(#clip0_45235_124956)">
<path d="M463.454 54.534C463.262 53.8027 463.416 53.1163 463.653 52.4203C464.577 49.7324 467.858 45.8321 469.446 43.4745C472.333 39.1893 477.91 30.1569 481.682 27.1515C488.12 22.0227 496.075 28.8579 490.991 35.4975C483.145 41.0849 475.668 47.9202 467.678 53.2414C466.36 54.1203 465.08 55.4931 463.454 54.5373V54.534Z" fill="#FCD02B"/>
<path d="M477.637 63.0442C477.557 63.0634 477.541 63.458 477.287 63.5414C476.857 63.6793 472.784 63.891 472.3 63.814C468.188 63.1629 475.44 58.6595 476.331 58.0373C479.699 55.6926 490.817 48.2319 494.364 48.1389C499.839 47.9945 502.149 55.1248 497.52 58.0565C496.635 58.6178 495.708 58.8199 494.727 59.1118C489.158 60.7604 483.276 61.6521 477.64 63.0442H477.637Z" fill="#FCD02B"/>
<path d="M25.7009 139.13C20.6202 135.371 15.6325 130.791 10.6576 126.765C7.80935 124.462 2.48809 121.742 2.46884 117.611C2.44318 112.409 7.91199 109.673 12.3416 111.931C15.0423 113.307 19.876 120.831 21.8101 123.632C24.0362 126.849 30.6533 135.99 31.6989 139.092C32.1191 140.346 31.7502 141.818 30.2363 141.805C29.8931 141.805 26.2301 139.518 25.7041 139.13H25.7009Z" fill="#FCD02B"/>
<path d="M3.05646e-05 141.885C0.0128606 141.85 0.429837 141.828 0.535685 141.581C1.84756 138.582 3.2332 137.071 6.83203 137.324C10.3988 137.574 22.3243 146.126 25.5543 148.749C26.6609 149.651 28.6592 150.815 28.1043 152.515C27.4852 154.407 22.5104 152.416 21.2177 152.121C16.0184 150.937 8.95862 149.785 4.09923 148.069C2.29982 147.434 1.1804 146.398 0.5421 144.57C0.4138 144.201 -0.0512896 142.007 3.05646e-05 141.882V141.885Z" fill="#FCD02B"/>
<path d="M62.6914 41.4902C91.9149 22.1875 138.542 29.013 150.805 64.8789C151.202 66.0399 151.398 68.4937 151.748 69.251H151.745C156.55 79.6689 152.717 100.139 148.759 111.202C136.041 146.745 104.726 173.682 66.1719 175.52C60.6742 175.783 55.2529 175.279 49.7969 174.868C47.7924 174.718 46.2465 175.311 44.4023 174.169C42.9301 173.258 42.2085 171.606 42.2373 169.894C42.3175 165.416 50.1729 151.525 53.6562 148.57C57.8612 145.004 60.337 146.356 65.209 146.01C74.0264 145.381 82.6614 142.241 89.7051 137.147C91.6456 135.746 93.4454 134.011 95.1807 132.362C83.8422 135.785 72.8274 136.369 61.582 132.292C49.0791 127.76 40.9418 118.557 37.0479 105.826C35.0657 99.3536 35.4402 95.803 35.2061 89.0674C34.6063 71.7757 48.1678 51.0839 62.6914 41.4902ZM83.6387 61.96H83.6377L83.6367 61.9609C81.8856 62.0572 79.509 63.9522 78.6172 65.4404C77.4304 67.4291 77.5809 69.3763 77.2217 71.583H77.2246C75.5439 81.8373 74.5848 92.2168 73.0869 102.5C73.6188 107.482 76.7179 109.783 80.9043 109.213C81.0412 109.194 81.1792 109.173 81.3184 109.148C81.4461 109.126 81.5746 109.1 81.7041 109.072C81.9433 109.021 82.1856 108.961 82.4307 108.892C82.7598 108.798 83.094 108.69 83.4326 108.564C83.468 108.551 83.5046 108.54 83.54 108.526C83.7078 108.463 83.8764 108.396 84.0469 108.33C85.092 107.923 86.1883 107.465 87.3223 106.966C95.4105 103.406 105.421 97.7665 112.517 93.8809C116.58 91.6581 120.673 90.1277 120.218 84.5596C120 81.8944 118.492 80.8585 116.757 79.2773C107.343 74.5816 98.2334 67.1369 88.7842 62.749C87.251 62.037 85.2713 61.8701 83.6387 61.96ZM65.6748 42.0742C65.1103 42.0486 64.5483 42.0327 63.9902 42.1514C61.9786 42.5723 54.6342 49.179 53.0049 51.0488C52.7096 51.389 51.1995 53.2195 51.2471 53.4541C51.587 55.1087 53.8085 51.8105 54.1143 51.5234C57.6906 48.1684 61.1839 45.7723 65.0713 42.8984C65.4305 42.6322 65.87 42.9015 65.6777 42.0742H65.6748ZM66.2266 41.3975L66.5186 42.0098L68.7822 40.9355L68.4902 40.3223L66.2266 41.3975ZM68.8916 40.3135C70.0687 39.9478 71.1336 39.3478 72.2979 38.8955C72.7565 38.7191 73.2311 39.2029 73.0547 38.2246H73.0518L68.8916 40.3135ZM74.0137 37.9072C73.5648 37.6767 73.1579 38.1094 73.376 38.2217C73.825 38.4524 74.2318 38.0195 74.0137 37.9072Z" fill="#FCD02B"/>
<path d="M83.6384 61.9609C83.138 64.0939 86.7176 61.9032 86.5251 63.5615C82.9712 63.6705 80.3058 65.3032 79.4879 68.8763L78.1824 78.316C77.6211 78.316 77.8296 76.9112 77.852 76.5391C77.9899 74.3804 78.7084 72.1833 78.5064 69.9797L77.2266 71.5835C77.589 69.3799 77.4351 67.4297 78.6218 65.4411C79.5103 63.9528 81.8903 62.054 83.6416 61.9609H83.6384Z" fill="#FDFDFD"/>
<path d="M65.6739 42.0729C65.8664 42.9037 65.4301 42.631 65.0677 42.8972C61.177 45.7712 57.6872 48.1672 54.1108 51.5222C53.8061 51.8077 51.5833 55.1082 51.2433 53.4532C51.1952 53.219 52.7059 51.3875 53.001 51.0475C54.6337 49.1743 61.9757 42.5701 63.9868 42.1499C64.5481 42.0312 65.1062 42.0472 65.6707 42.0729H65.6739Z" fill="#FCD02B"/>
<path d="M68.4922 40.3143L66.2266 41.3906L66.5183 42.0048L68.784 40.9286L68.4922 40.3143Z" fill="#FCD02B"/>
<path d="M73.0501 38.2246C73.2297 39.2029 72.7518 38.7154 72.2931 38.895C71.132 39.3472 70.0671 39.947 68.8867 40.3127L73.0469 38.2246H73.0501Z" fill="#FCD02B"/>
<path d="M74.0153 37.9066C74.2334 38.0189 73.8261 38.4519 73.377 38.221C73.1589 38.1087 73.5662 37.6757 74.0153 37.9066Z" fill="#FCD02B"/>
<path d="M470.626 65.4876H486.343C487.536 65.4876 486.282 68.6374 486.208 69.2019C485.342 75.726 483.924 82.0544 482.754 88.5207C479.184 108.276 476.396 128.73 472.288 148.299C470.972 154.563 469.045 159.859 462.534 162.281C458.479 163.788 447.702 164.186 443.346 163.65C442.506 163.548 442.204 163.496 442.224 162.528C442.564 158.496 442.105 153.604 442.554 149.675C442.865 146.952 444.241 148.174 445.931 148.235C449.729 148.376 455.92 148.819 456.847 144.081C460.243 123.662 464.009 103.224 467.492 82.8787C468.358 77.8173 468.843 72.31 469.924 67.3479C470.065 66.6968 470.071 65.9463 470.626 65.4844V65.4876Z" fill="#FDFDFD"/>
<path d="M464.532 74.791C464.638 74.8199 465.1 75.2914 465.171 75.4389C465.562 76.2601 464.212 83.2492 463.958 84.8017C460.911 103.389 457.203 121.903 454.025 140.455C453.948 140.551 453.377 140.866 453.309 140.866H440.479C440.367 140.866 440.097 140.507 440.043 140.333C443.408 118.641 447.443 96.9678 451.199 75.317C451.292 75.1855 452.241 74.791 452.35 74.791H464.539H464.532Z" fill="#FDFDFD"/>
<path d="M446.562 66.7787L389.462 66.7562C386.697 66.6247 387.044 69.5564 386.71 71.3815C383.647 88.1664 381.184 105.195 377.549 121.881C374.829 134.371 370.381 146.364 365.242 158.02L365.993 158.898C368.607 159.902 371.227 160.916 373.816 161.981C374.701 162.347 377.309 164.069 378.05 163.601C378.489 163.322 382.107 155.405 382.637 154.235C387.284 143.961 390.424 133.7 392.763 122.663C392.987 121.595 392.375 120.312 393.645 120.017C393.911 119.956 394.87 119.812 394.764 120.335L393.481 120.659H407.594L403.428 120.02L408.236 119.696L406.911 127.029C406.244 127.458 395.948 126.785 393.949 127.057C393.353 127.138 393.019 127.15 392.695 127.728L386.168 162.273C386.053 162.529 386.47 162.994 386.588 162.994H429.89C431.198 162.994 431.695 160.804 432.003 160.037C432.116 159.758 432.574 159.781 432.619 159.566C432.764 158.886 432.318 158.19 432.382 157.471C433.193 148.5 436.42 138.55 437.402 129.447C437.543 128.899 436.731 127.074 436.305 127.074H421.71L422.673 120.655C427.696 120.315 433.293 121.111 438.245 120.671C438.813 120.62 439.58 120.614 439.997 120.177C441.199 118.926 441.575 109.381 442.239 107.184H425.239C425.386 106.356 426.81 100.769 425.72 100.769H411.126L410.163 107.184H395.73C395.794 106.007 396.339 100.086 396.993 99.6268L424.777 99.4632C425.8 99.2868 425.608 100.409 426.349 100.474L441.26 100.272C442.559 99.6076 443.095 98.7448 443.502 97.3816C445.061 92.1854 446.065 84.2372 446.95 78.6978C447.338 76.2761 448.426 71.6573 448.362 69.4954C448.326 68.2926 447.791 67.1508 446.559 66.7787H446.562ZM421.072 139.904C420.2 142.441 420.427 147.39 419.472 149.616C419.388 149.808 418.468 150.168 418.346 150.168H402.95C402.854 150.168 402.376 149.69 402.517 149.43L404.393 139.904H421.072ZM429.251 86.9796H399.903C400.499 84.9011 400.454 82.4121 401.346 80.5646H430.695C430.457 81.4114 429.919 86.9796 429.251 86.9796Z" fill="#FDFDFD"/>
<path d="M286.354 74.1494H309.127L306.263 89.0868L295.046 89.2568L292.128 105.904H303.354L300.743 120.613L288.612 120.671C288.817 121.99 287.374 123.702 287.791 124.806C287.977 125.293 288.75 126.102 289.084 126.746C292.262 132.869 295.05 139.214 298.527 145.189L289.392 162.035C283.817 157.24 281.049 149.565 278.65 142.79C278.053 142.652 278.027 142.992 277.742 143.323C274.663 146.886 272.032 151.874 268.7 155.771C267.763 156.868 261.464 163.745 260.787 163.925C260.11 164.104 251.261 156.207 249.814 155.409C249.442 154.174 254.115 150.321 255.081 149.208C256.325 147.771 257.903 145.748 259.077 144.224C264.601 137.055 269.316 129.126 272.558 120.658H258.445L261.027 105.92C261.541 105.423 273.258 106.173 275.301 105.92C275.901 105.846 276.388 105.766 276.587 105.118L279.618 89.228L278.64 89.4397L272.555 99.6716L259.728 98.2058L273.877 65.3641L274.643 64.8477L289.257 65.6335C289.69 65.9286 289.578 66.4161 289.501 66.8556C289.119 69.0784 286.659 71.747 286.354 74.1527V74.1494Z" fill="white"/>
<path d="M309.77 147.28C309.251 143.034 308.353 138.829 307.493 134.643C307.317 133.777 306.268 132.308 307.025 131.224C307.451 130.611 320.028 129.588 321.802 128.969C322.074 128.988 322.402 128.978 322.623 129.139C323.329 129.649 325.317 144.256 325.446 146.28L323.242 147.28H331.582L341.352 128.975L356.6 130.925L347.94 147.284H359.647C360.141 147.284 360.106 148.249 360.064 148.637C359.737 151.607 358.326 155.671 357.659 158.773C357.524 159.398 357.54 161.717 356.764 161.717H295.34L298.387 147.284H309.774L309.77 147.28Z" fill="white"/>
<path d="M369.384 81.8012L311.877 82.1733C311.027 82.0418 313.782 68.7819 314.144 67.7042C315.383 67.0531 316.656 67.7299 317.933 67.7459C335.577 67.9769 353.241 67.5535 370.889 67.717C371.225 67.7235 371.649 67.7395 371.86 68.0346C372.3 68.644 369.458 80.0114 369.384 81.8012Z" fill="white"/>
<path d="M365.421 87.3001H313.138L305.289 126.634C305.148 126.974 305.514 127.715 305.761 127.715H357.081C357.309 127.715 358.512 126.74 358.669 126.416C361.231 113.785 363.47 101.057 365.882 88.3714C366.036 88.0314 365.664 87.2969 365.417 87.2969L365.421 87.3001ZM345.168 113.307C338.048 113.89 330.715 113.663 323.566 113.278L325.548 101.952L325.968 101.41H347.298L345.168 113.307Z" fill="white"/>
<path d="M203.82 48.3031C204.529 48.4666 205.908 48.8932 206.944 49.1563C209.866 49.9004 218.613 51.8057 220.714 53.0277C222.786 54.2337 223.421 56.6747 223.177 58.9263L202.479 162.677C201.433 166.23 198.322 168.418 194.746 169.056L164.781 168.941C162.494 168.081 161.359 165.903 161.285 163.527L176.665 86.1934C176.671 85.9464 175.594 85.8181 175.337 85.8694C172.264 86.5045 167.722 89.8083 164.598 90.1643C161.542 90.5107 159.101 88.7017 159.332 85.5487C160.765 79.6116 160.801 72.2856 162.436 66.5089C162.847 65.0591 163.861 63.8274 165.214 63.1923C174.821 58.8044 184.341 52.7423 194.079 48.7553C198.277 47.0361 199.506 47.3119 203.817 48.3031H203.82Z" fill="#FCD02B"/>
<path d="M217.721 2.13574C221.749 -3.29773 229.63 2.70001 225.274 8.0918C224.986 8.45104 224.287 8.63105 224.203 8.77539L224.197 8.77832C223.819 9.41982 224.566 11.8519 224.79 12.7051C225.518 15.4828 226.673 20.5563 227.661 22.9844C227.786 23.2923 227.767 23.6454 228.178 23.7705C228.992 24.0163 239.786 19.9539 241.585 19.5947C242.364 19.0142 241.854 17.6667 242.887 16.4062C246.733 11.6976 254.268 16.8877 250.435 22.0293C249.059 23.8734 248.009 23.2992 246.479 23.8477C245.158 24.3225 235.42 45.9666 233.492 48.709C233.146 49.3023 232.703 49.976 231.969 50.0947C230.564 50.3225 224.925 47.9902 223.09 47.4385C212.508 44.2599 202.321 41.5209 191.483 39.2725C189.594 38.8812 186.451 39.4552 186.624 36.5078L190.643 9.81738C188.327 8.07873 188.052 4.18555 190.287 2.29004C193.973 -0.837286 199.107 2.57577 197.687 7.12402C197.487 7.75886 196.507 8.23362 197.017 9.02246L205.369 18.3242C207.739 16.0022 210.135 13.6735 212.582 11.4316C213.432 10.6522 217.002 8.06931 217.079 7.25781C217.258 5.30796 215.928 4.55403 217.721 2.13574ZM219.479 11.9248C217.034 13.2302 206.575 24.6463 205.022 24.7461C204.48 24.7814 204.031 24.5848 203.617 24.248C202.257 23.1348 199.858 19.4505 198.46 17.8467C198.216 17.5677 197.793 17.487 197.604 17.2754C197.453 17.1054 195.249 13.4103 194.62 14.1641L192.051 34.3682V34.373C205.054 36.3168 217.502 40.5537 230.024 44.3418L240.161 25.0762L240.163 25.0771V25.0742L240.161 25.0762L240.16 25.0752L240.162 25.0723C239.572 24.5196 226.682 30.0984 225.399 29.749C224.575 29.5244 224.389 28.8194 224.116 28.1299C222.269 23.4502 221.711 17.4201 219.953 12.7275C219.834 12.41 219.831 12.1301 219.479 11.9248Z" fill="#FCD02B"/>
</g>
<defs>
<clipPath id="clip0_45235_124956">
<rect width="500" height="175.589" fill="white"/>
</clipPath>
</defs>
</svg><path d="M0 98Q64 85 128 98V128H0Z" fill="#FFC000"/><text x="64" y="119" text-anchor="middle" fill="#191919" font-family="PingFang SC,Microsoft YaHei,sans-serif" font-weight="700" font-size="18">APP 下载</text></g><rect x="1" y="1" width="126" height="126" rx="28" fill="none" stroke="#ffffff30" stroke-width="2"/></svg>

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 17 KiB

+3 -3
View File
@@ -59,7 +59,7 @@ function installPlayerControls(screen,currentEpisode=1,actions={}){
async function retry(){overlay.querySelector('.ui-empty-state')?.remove();center.hidden=false;status.hidden=false;status.textContent='正在重新加载…';try{if(actions.retry)await actions.retry();else{video.load();await play()}}catch{status.textContent='视频加载失败,请重试';overlay.hidden=false}}
async function fullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else if(screen.requestFullscreen)await screen.requestFullscreen();else if(video.webkitEnterFullscreen)video.webkitEnterFullscreen();else throw Error()}catch{status.textContent='当前浏览器暂不支持全屏';overlay.hidden=false}}
function closeMenus(){bar.querySelectorAll('[data-options]').forEach(e=>e.hidden=true);bar.querySelectorAll('[data-menu]').forEach(e=>e.setAttribute('aria-expanded','false'))}
screen.addEventListener('click',e=>{const b=e.target.closest('button');if(!b){if(e.target===video){closeMenus();play()}return}if(['previous','next'].includes(b.dataset.playAction)||b.hasAttribute('data-show-unlock'))return;e.stopPropagation();if(b.dataset.media==='retry')void retry();if(b.dataset.media==='toggle')play();if(b.dataset.media==='mute'){if(video.muted||!video.volume){video.muted=false;if(!video.volume)video.volume=.5}else video.muted=true;sync()}if(b.dataset.media==='fullscreen')fullscreen();if(b.dataset.menu){const menu=bar.querySelector('[data-options="'+b.dataset.menu+'"]'),open=menu.hidden;closeMenus();menu.hidden=!open;b.setAttribute('aria-expanded',String(open))}else if(b.dataset.speed){video.playbackRate=Number(b.dataset.speed);bar.querySelector('[data-menu="speed"]').textContent=b.dataset.speed==='1'?'倍速':b.dataset.speed+'×';bar.querySelectorAll('[data-speed]').forEach(e=>e.setAttribute('aria-checked',String(e===b)));closeMenus()}else closeMenus()});
screen.addEventListener('click',e=>{const b=e.target.closest('button');if(!b){if(e.target===video){closeMenus();play()}return}if(['previous','next'].includes(b.dataset.playAction))return;e.stopPropagation();if(b.dataset.media==='retry')void retry();if(b.dataset.media==='toggle')play();if(b.dataset.media==='mute'){if(video.muted||!video.volume){video.muted=false;if(!video.volume)video.volume=.5}else video.muted=true;sync()}if(b.dataset.media==='fullscreen')fullscreen();if(b.dataset.menu){const menu=bar.querySelector('[data-options="'+b.dataset.menu+'"]'),open=menu.hidden;closeMenus();menu.hidden=!open;b.setAttribute('aria-expanded',String(open))}else if(b.dataset.speed){video.playbackRate=Number(b.dataset.speed);bar.querySelector('[data-menu="speed"]').textContent=b.dataset.speed==='1'?'倍速':b.dataset.speed+'×';bar.querySelectorAll('[data-speed]').forEach(e=>e.setAttribute('aria-checked',String(e===b)));closeMenus()}else closeMenus()});
seek.addEventListener('input',()=>{if(Number.isFinite(video.duration)&&video.duration>0){video.currentTime=Math.min(video.duration,Math.max(0,Number(seek.value)));sync()}});
volume.addEventListener('input',()=>{video.volume=Number(volume.value);video.muted=video.volume===0;sync()});
screen.addEventListener('keydown',e=>{if(e.target.matches('input,button'))return;if(e.code==='Space'){e.preventDefault();play()}else if(e.key.toLowerCase()==='m'){mute.click()}else if(e.key.toLowerCase()==='f')fullscreen();else if(['ArrowLeft','ArrowRight'].includes(e.key)&&Number.isFinite(video.duration)){e.preventDefault();video.currentTime=Math.min(video.duration,Math.max(0,video.currentTime+(e.key==='ArrowRight'?5:-5)));sync()}else if(e.key==='Escape')closeMenus()});
@@ -95,14 +95,14 @@ function installPlayerControls(screen,currentEpisode=1,actions={}){
const header=document.querySelector('#global-site-header .top'),sidebar=document.getElementById('site-sidebar');if(!header||!sidebar)return;
const mobile=matchMedia('(max-width:760px)'),trigger=document.createElement('button');trigger.type='button';trigger.className='mobile-menu-trigger';trigger.setAttribute('aria-label','打开导航菜单');trigger.setAttribute('aria-controls','mobile-navigation-drawer');trigger.setAttribute('aria-expanded','false');trigger.innerHTML='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>';header.prepend(trigger);
const drawer=document.createElement('dialog');drawer.id='mobile-navigation-drawer';drawer.setAttribute('aria-label','网站导航');
drawer.innerHTML='<button type="button" class="mobile-drawer-close" aria-label="关闭导航菜单" autofocus><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 5 14 14M19 5 5 19"/></svg></button><a class="mobile-drawer-brand" href="/" aria-label="91短剧首页"></a><nav class="mobile-drawer-links" aria-label="手机端主导航"></nav><div class="mobile-drawer-info"></div><div class="mobile-drawer-social" aria-label="官方社区"></div>';
drawer.innerHTML='<button type="button" class="mobile-drawer-close" aria-label="关闭导航菜单" autofocus><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 5 14 14M19 5 5 19"/></svg></button><a class="mobile-drawer-brand" href="/" aria-label="黄果吃瓜首页"></a><nav class="mobile-drawer-links" aria-label="手机端主导航"></nav><div class="mobile-drawer-info"></div><div class="mobile-drawer-social" aria-label="官方社区"></div>';
const brand=sidebar.querySelector('.sidebar-brand').cloneNode(true);brand.removeAttribute('class');drawer.querySelector('.mobile-drawer-brand').append(brand);
const nav=drawer.querySelector('.mobile-drawer-links');
const sourceNavigation=sidebar.querySelector('[data-site-navigation="sidebar"]');
if(sourceNavigation)nav.innerHTML=sourceNavigation.innerHTML;
nav.querySelectorAll('a[data-page]').forEach(link=>{link.dataset.mobileRoute=link.dataset.page;link.dataset.siteNav='sidebar';});
const pcInfo=sidebar.querySelector('.side-info');if(pcInfo)drawer.querySelector('.mobile-drawer-info').innerHTML=pcInfo.innerHTML;
const footerLogo=drawer.querySelector('.mobile-drawer-info .side-info-wordmark');if(footerLogo){const brandText=document.createElement('span');brandText.className='mobile-drawer-brand-text';brandText.textContent='91短剧';footerLogo.replaceWith(brandText);}
const footerLogo=drawer.querySelector('.mobile-drawer-info .side-info-wordmark');if(footerLogo){const brandText=document.createElement('span');brandText.className='mobile-drawer-brand-text';brandText.textContent='黄果吃瓜';footerLogo.replaceWith(brandText);}
sidebar.querySelectorAll('.sidebar-social a').forEach(link=>drawer.querySelector('.mobile-drawer-social').append(link.cloneNode(true)));
document.body.append(drawer);let timer=null,start=null;
function finishClose(){clearTimeout(timer);timer=null;drawer.classList.remove('is-closing');if(drawer.open)drawer.close();document.body.classList.remove('mobile-drawer-open');trigger.setAttribute('aria-expanded','false')}
File diff suppressed because one or more lines are too long
+6 -8
View File
@@ -36,10 +36,10 @@ if (!publicOnly) {
}
console.log("PASS: SSR homepage");
const checks = ["config/info", "config/footer/info", "navigation/header", "translation/category/list", "theme/list", "playlet/tag/options", "playlet/all/list?sortType=1&pageNum=1&pageSize=4", "playlet/hot/list?pageNum=1&pageSize=4", ...[1,2,3].map(rank=>`playlet/hot/rank/list?rankType=${rank}&pageNum=1&pageSize=4`), "playlet/topic/list?pageNum=1&pageSize=4", "applist/list?categoryId=0", "recharge/package/list", "playlet/hot-like", ...(!publicOnly?["auth/getInfo", "user/center/data", "playlet/history/list?pageNum=1&pageSize=4", "playlet/collect/list?pageNum=1&pageSize=4&watchStatus=0", "playlet/bought/list?pageNum=1&pageSize=4", "coin/trans/list?page=1&pageSize=10&type=0"]:[])];
const checks = ["config/info", "config/footer/info", "navigation/header", "translation/category/list", "theme/list", "playlet/tag/options", "playlet/all/list?sortType=1&pageNum=1&pageSize=4", "playlet/hot/list?pageNum=1&pageSize=4", ...[1,2,3].map(rank=>`playlet/hot/rank/list?rankType=${rank}&pageNum=1&pageSize=4`), "playlet/topic/list?pageNum=1&pageSize=4", "applist/list?categoryId=0", "playlet/hot-like", ...(!publicOnly?["auth/getInfo", "user/center/data", "playlet/history/list?pageNum=1&pageSize=4", "playlet/collect/list?pageNum=1&pageSize=4&watchStatus=0", "playlet/bought/list?pageNum=1&pageSize=4", "coin/trans/list?page=1&pageSize=10&type=0"]:[])];
const dataByPath=new Map();
for(let offset=0;offset<checks.length;offset+=4)await Promise.all(checks.slice(offset,offset+4).map(async path=>{const data=await request(path);assert.ok(data,`${path}: missing data`);dataByPath.set(path.split('?')[0],data);console.log(`PASS: ${path.split("?")[0]}`);}));
const pages=['/shorts/','/shorts/page/2/','/rank/hot/','/rank/new/','/rank/follow/','/topics/','/apps/','/home-road/','/navigation/','/info/about-us/','/info/user-agreement/','/info/privacy-policy/','/info/help-center/','/info/2257/','/info/dmca/','/info/feedback/'];
const pages=['/shorts/','/shorts/page/2/','/rank/hot/','/rank/new/','/rank/follow/','/topics/','/apps/','/home-road/','/navigation/','/info/about-us/','/info/user-agreement/','/info/privacy-policy/','/info/help-center/','/info/2257/','/info/dmca/'];
const topic=dataByPath.get('playlet/topic/list')?.list?.[0];
if(topic){
const detail=await request(`playlet/topic/detail?id=${topic.id}&pageNum=1&pageSize=4`);
@@ -72,17 +72,15 @@ if (first) {
}
console.log("PASS: search");
}
if(!publicOnly)pages.push('/member/center/','/member/favorites/','/member/history/','/member/purchased/','/member/settings/','/info/deposit/','/info/deposit/vip/');
for(let offset=0;offset<pages.length;offset+=3)await Promise.all(pages.slice(offset,offset+3).map(async path=>{
const response=await fetch(base+path,{headers:{Cookie:cookieHeader()},redirect:'manual'}),html=await response.text();
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null,path);assert.ok(!html.includes('内容暂不可用,请稍后重试'),path);assert.ok(!html.includes('class="api-loading"'),path);
const marker=path.startsWith('/member/settings/')?'member-settings':path.startsWith('/member/')?'member-center':path.startsWith('/info/deposit/')?'wallet-page':path==='/info/feedback/'?'id="feedback-form"':path==='/home-road/'?'return-home-directory':path.startsWith('/search/')?'data-search-results':'';
const marker=path==='/home-road/'?'return-home-directory':path.startsWith('/search/')?'data-search-results':'';
if(marker)assert.ok(html.includes(marker),path+' must contain the complete server-rendered page');
console.log(`PASS: page ${path}`);
}));
const rejected=await fetch(`${base}/api/not-allowed`);
assert.equal(rejected.status,404);
const invalidLogin=await fetch(`${base}/api/auth/login`,{method:"POST",headers:{Origin:new URL(base).origin,"Sec-Fetch-Site":"same-origin","Content-Type":"application/json"},body:"{}"});
assert.equal(invalidLogin.status,400);
assert.equal((await invalidLogin.json()).code,400);
console.log("PASS: proxy endpoint allowlist and browser login input validation");
const removedLogin=await fetch(`${base}/api/auth/login`,{method:"POST",headers:{Origin:new URL(base).origin,"Sec-Fetch-Site":"same-origin","Content-Type":"application/json"},body:"{}"});
assert.equal(removedLogin.status,404);
console.log("PASS: proxy endpoint allowlist rejects removed account routes");
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function Page({params,searchParams}:PageProps) {
if (!route) notFound();
const {bootstrap,view,session,shared}=await getPageSeo(pathname(path),queryString(await searchParams));
assertPageContent(pathname(path),bootstrap.content);
const title=bootstrap.content.title||({'apps':'福利应用','home-road':'回家的路',center:'个人中心',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置',deposit:'金币充值',feedback:'意见反馈'} as Record<string,string>)[route.key]||'内容列表';
const title=bootstrap.content.title||({'apps':'福利应用','home-road':'回家的路'} as Record<string,string>)[route.key]||'内容列表';
const fallback:PageView={panel:'channel',title,html:empty(bootstrap.content.failed?'内容暂不可用,请稍后重试':'暂无内容',true)};
return <SiteFrame home={{}} homeLoaded={false} view={view||fallback} seo={bootstrap} session={session} shared={shared} />;
}
+18 -63
View File
@@ -4,27 +4,23 @@ import { isIP } from "node:net";
import { allowedRequest, PUBLIC_PATHS, upstream } from "@/lib/api/server";
import { UPSTREAM_HOP_HEADER } from '@/lib/api/config';
import { ApiError, EXPIRED_CODES } from "@/lib/api/protocol";
import { trackingHeaders } from "@/lib/api/tracking-headers";
import type { Envelope, User } from "@/lib/api/types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const AUTH = "drama_session";
const DEVICE = "drama_device";
const REGISTERED = "drama_registered";
const CHANNEL = "drama_channel";
const SKIP_AUTO = "drama_skip_auto";
const CREDENTIALS = new Set(['auth/login','auth/register','auth/resetPassword']);
const text = (value: unknown) => typeof value === 'string' ? value.trim() : '';
const deviceId = (value: unknown) => /^[a-zA-Z0-9_-]{1,100}$/.test(text(value)) ? text(value) : '';
const expired = (code: number) => EXPIRED_CODES.has(code) || code === 401;
function sessionUser(value: unknown, token = '', registered = false): User {
function sessionUser(value: unknown, token = ''): User {
if (!value || typeof value !== 'object' || !Number.isSafeInteger((value as User).userId) || (value as User).userId <= 0) throw new ApiError('登录响应缺少用户信息,请稍后重试',502);
const user = value as User;
const sessionToken = text(user.token) || token;
if (!sessionToken) throw new ApiError('登录响应缺少凭证,请稍后重试',502);
return {...user, token:sessionToken, isRegister:user.isRegister || user.type === 1 || (user.isRegister === undefined && user.type === undefined && registered)};
return {...user, token:sessionToken};
}
async function handler(request: NextRequest, context: {params: Promise<{path: string[]}>}) {
@@ -32,30 +28,17 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
const method = request.method;
const headers = {'Cache-Control':'no-store'};
if (request.headers.has(UPSTREAM_HOP_HEADER)) return NextResponse.json({code:502,msg:'上游请求返回本站,请检查 Nginx 的 /api/app/ 后端转发配置',data:null},{status:502,headers:{...headers,[UPSTREAM_HOP_HEADER]:'1'}});
if (!allowedRequest(path,method) && path !== 'auth/logout') return NextResponse.json({code:404,msg:'接口不存在',data:null},{status:404,headers});
if (!allowedRequest(path,method)) return NextResponse.json({code:404,msg:'接口不存在',data:null},{status:404,headers});
const secure = request.nextUrl.protocol === 'https:';
const cookieOptions = {httpOnly:true,sameSite:'lax' as const,secure,path:'/',maxAge:7*86400};
const clearSession = (response: NextResponse) => {
response.cookies.set(AUTH,'',{...cookieOptions,maxAge:0});
response.cookies.set(REGISTERED,'',{...cookieOptions,maxAge:0});
};
if (path === 'auth/logout') {
if (method !== 'POST') return NextResponse.json({code:405,data:null},{status:405,headers});
const response = NextResponse.json({code:200,data:null},{headers});
clearSession(response);
response.cookies.set(SKIP_AUTO,'1',{httpOnly:true,sameSite:'lax',secure,path:'/'});
return response;
}
const clearSession = (response: NextResponse) => response.cookies.set(AUTH,'',{...cookieOptions,maxAge:0});
let token = PUBLIC_PATHS.has(path) ? '' : request.cookies.get(AUTH)?.value || '';
const registered = request.cookies.get(REGISTERED)?.value === '1';
let device = deviceId(request.cookies.get(DEVICE)?.value) || randomUUID();
let channel = text(request.cookies.get(CHANNEL)?.value).slice(0,100);
const establishesDevice = ['auth/guest','auth/login','auth/register'].includes(path);
const establishesDevice = path === 'auth/guest';
const userAgent = request.headers.get('user-agent') || '';
const forwardedIp = (request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for')?.split(',')[0] || '').trim();
const clientIp = isIP(forwardedIp) ? forwardedIp : undefined;
const tracking=trackingHeaders(request.headers,path,method);
const identity=trackingHeaders(request.headers,'auth/guest','POST');
const setContextCookies = (response: NextResponse) => {
if (establishesDevice) {
response.cookies.set(DEVICE,device,{...cookieOptions,httpOnly:false,maxAge:365*86400});
@@ -63,13 +46,13 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
}
};
const guest = async () => {
const result = await upstream<User>('auth/guest',{request,method:'POST',body:{devID:device,deviceId:device,channel,mac:''},device,userAgent,clientIp,tracking:identity});
const result = await upstream<User>('auth/guest',{request,method:'POST',body:{devID:device,deviceId:device,channel,mac:''},device,userAgent,clientIp});
if (result.code !== 200) throw new ApiError(result.tip || result.msg || '游客登录失败',result.code);
return sessionUser(result.data);
};
// Normalize HTTP 401 into the same recovery path as an expired business envelope.
const call = async (callPath: string, options: Parameters<typeof upstream>[1]): Promise<Envelope<unknown>> => {
try { return await upstream(callPath,{...options,request,clientIp,tracking:callPath.split('?')[0]===path?tracking:identity}); }
try { return await upstream(callPath,{...options,request,clientIp}); }
catch (error) {
if (error instanceof ApiError && error.code === 401) return {code:401,msg:error.message,data:null};
throw error;
@@ -90,66 +73,38 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
let session: User | undefined;
let result: Envelope<unknown>;
if (path === 'auth/guest') {
if (!token && input.automatic === true && request.cookies.get(SKIP_AUTO)?.value === '1') {
const response = NextResponse.json({code:200,data:null},{headers});
response.cookies.set(SKIP_AUTO,'',{...cookieOptions,maxAge:0});
setContextCookies(response);
return response;
}
if (token) {
const info = await call('auth/getInfo',{token,device,userAgent});
if (info.code === 200) session = sessionUser(info.data,token,registered);
else if (!expired(info.code) || registered) throw new ApiError(info.tip || info.msg || '登录已过期,请重新登录',info.code);
if (info.code === 200) session = sessionUser(info.data,token);
else if (!expired(info.code)) throw new ApiError(info.tip || info.msg || '会话暂不可用,请稍后重试',info.code);
}
session ||= await guest();
result = {code:200,data:session};
} else {
if (path === 'auth/login' || path === 'auth/register') {
const account = text(input[path === 'auth/login' ? 'account' : 'accountNo']);
const password = typeof input.password === 'string' ? input.password : '';
if (!account || !password) throw new ApiError('请填写账号和密码',400);
body = path === 'auth/login'
? {account,password,devID:device,channel}
: {accountNo:account,password,email:text(input.email) || account+'@91dj.local',devID:device,channel,...(text(input.agentInviteCode) ? {agentInviteCode:text(input.agentInviteCode).slice(0,100)} : {})};
// Only guest binding inherits the old identity; account switching does not.
if (path === 'auth/login' || registered) token = '';
} else if (path === 'auth/resetPassword') {
body = {accountNo:text(input.accountNo),email:text(input.email),newPassword:input.newPassword};
token = '';
}
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
// Retry only an explicitly rejected guest request, once; never downgrade registered actions.
if (expired(result.code) && !PUBLIC_PATHS.has(path) && !CREDENTIALS.has(path) && !registered) {
// Retry an explicitly rejected session once with a fresh guest.
if (expired(result.code) && !PUBLIC_PATHS.has(path)) {
session = await guest();
token = session.token!;
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
}
if (result.code === 200 && ['auth/login','auth/register','auth/getInfo'].includes(path)) {
session = sessionUser(result.data,path === 'auth/getInfo' ? token : '',registered || CREDENTIALS.has(path));
if (result.code === 200 && path === 'auth/getInfo') {
session = sessionUser(result.data,token);
result = {...result,data:session};
}
}
const data = result.data && typeof result.data === 'object' && 'token' in result.data ? {...result.data,token:undefined} : result.data;
const isExpired = expired(result.code) && !CREDENTIALS.has(path);
const isExpired = expired(result.code);
const response = NextResponse.json({...result,data},{status:isExpired ? 401 : 200,headers});
if (isExpired) {
clearSession(response);
if (registered) response.headers.set('X-Auth-State','login-required');
} else if (session?.token) {
response.cookies.set(AUTH,session.token,cookieOptions);
response.cookies.set(REGISTERED,session.isRegister || session.type === 1 ? '1' : '0',cookieOptions);
response.cookies.set(SKIP_AUTO,'',{...cookieOptions,maxAge:0});
}
if (isExpired) clearSession(response);
else if (session?.token) response.cookies.set(AUTH,session.token,cookieOptions);
setContextCookies(response);
return response;
} catch (error) {
const code = error instanceof ApiError ? error.code : 502;
const isExpired = expired(code) && !CREDENTIALS.has(path);
const isExpired = expired(code);
const response = NextResponse.json({code,msg:error instanceof Error ? error.message : '请求失败',data:null},{status:isExpired ? 401 : code >= 400 && code <= 599 ? code : 502,headers});
if (isExpired) {
clearSession(response);
if (registered) response.headers.set('X-Auth-State','login-required');
}
if (isExpired) clearSession(response);
setContextCookies(response);
return response;
}
+1 -1
View File
@@ -2,7 +2,7 @@
export default function PageError({retry}:{retry:()=>void}) {
return <main className="route-not-found">
<title>页面暂不可用 - 91短剧</title>
<title>页面暂不可用 - 黄果吃瓜</title>
<meta name="robots" content="noindex,follow" />
<h1>页面暂不可用</h1>
<p>内容服务暂时异常,请稍后重试</p>
+499 -515
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

-32
View File
@@ -1,32 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_46678_103162)">
<rect x="-108" y="-67" width="700" height="748" fill="#FEDC5A"/>
<path d="M98.0592 271.012C90.5364 265.446 83.1513 258.664 75.7852 252.703C71.5678 249.293 63.6888 245.266 63.6603 239.149C63.6223 231.446 71.7198 227.394 78.2785 230.738C82.2774 232.775 89.4346 243.917 92.2984 248.063C95.5944 252.827 105.392 266.362 106.94 270.955C107.563 272.812 107.016 274.992 104.775 274.973C104.267 274.973 98.8429 271.586 98.064 271.012H98.0592Z" fill="black"/>
<path d="M60.0058 275.091C60.0248 275.039 60.6422 275.005 60.799 274.64C62.7414 270.199 64.7931 267.962 70.1218 268.337C75.403 268.708 93.0607 281.369 97.8433 285.254C99.4818 286.589 102.441 288.313 101.619 290.83C100.702 293.632 93.3362 290.683 91.4222 290.246C83.7237 288.493 73.2705 286.788 66.0754 284.247C63.4111 283.307 61.7536 281.773 60.8085 279.066C60.6185 278.52 59.9298 275.271 60.0058 275.086V275.091Z" fill="black"/>
<path d="M284.689 167.545C291.803 182.97 286.128 213.28 280.267 229.66C261.437 282.287 215.07 322.171 157.983 324.893C149.843 325.282 141.817 324.536 133.738 323.929C130.77 323.705 128.481 324.584 125.75 322.893C123.57 321.544 122.502 319.099 122.544 316.562C122.663 309.932 134.294 289.363 139.452 284.989C145.678 279.708 149.344 281.712 156.559 281.199C169.614 280.268 182.399 275.619 192.829 268.077C195.702 266.002 198.366 263.432 200.936 260.991C184.147 266.059 167.838 266.923 151.187 260.887C132.674 254.176 120.626 240.55 114.86 221.7C111.925 212.116 112.481 206.859 112.134 196.886C111.246 171.282 131.326 140.645 152.83 126.44C196.101 97.8585 265.141 107.965 283.298 161.071C283.886 162.791 284.176 166.424 284.694 167.545H284.689ZM169.595 121.135C168.93 120.793 168.327 121.434 168.65 121.6C169.315 121.942 169.918 121.301 169.595 121.135ZM168.17 121.605L162.011 124.697C163.754 124.155 165.33 123.267 167.054 122.597C167.734 122.336 168.436 123.053 168.175 121.605H168.17ZM161.417 124.711L158.064 126.302L158.496 127.209L161.849 125.618L161.417 124.711ZM157.247 127.304C156.411 127.266 155.58 127.242 154.754 127.418C151.776 128.04 140.9 137.824 138.488 140.592C138.051 141.096 135.814 143.808 135.885 144.154C136.388 146.605 139.68 141.718 140.131 141.295C145.426 136.328 150.598 132.78 156.354 128.525C156.886 128.13 157.537 128.529 157.252 127.304H157.247ZM174.349 170.997C171.861 186.181 170.441 201.549 168.223 216.776C169.168 225.628 175.589 228.772 183.696 225.699C196.324 220.912 214.381 210.706 226.601 204.014C232.618 200.718 238.678 198.453 238.004 190.213C237.681 186.266 235.449 184.727 232.879 182.391C218.94 175.438 205.452 164.415 191.461 157.918C189.186 156.863 186.26 156.617 183.843 156.749C181.25 156.892 177.731 159.699 176.41 161.902C174.653 164.847 174.876 167.73 174.345 170.997H174.349Z" fill="black"/>
<path d="M183.847 156.748C186.264 156.615 189.195 156.862 191.465 157.916C205.456 164.413 218.944 175.436 232.883 182.389C235.452 184.73 237.685 186.264 238.008 190.211C238.682 198.456 232.622 200.721 226.605 204.012C214.385 210.704 196.328 220.91 183.7 225.698C175.593 228.77 169.172 225.626 168.227 216.774C170.444 201.548 171.864 186.179 174.353 170.996L176.248 168.621C176.547 171.888 175.483 175.142 175.279 178.333C175.246 178.884 174.937 180.964 175.768 180.964L177.701 166.987C178.912 161.696 182.859 159.279 188.121 159.118C188.406 156.662 183.101 159.906 183.847 156.748Z" fill="#FCD02B"/>
<path d="M183.845 156.75C183.104 159.908 188.405 156.665 188.12 159.12C182.857 159.281 178.911 161.699 177.7 166.989L175.767 180.967C174.936 180.967 175.244 178.886 175.278 178.335C175.482 175.139 176.546 171.886 176.247 168.623L174.352 170.998C174.888 167.735 174.66 164.848 176.417 161.903C177.733 159.699 181.257 156.888 183.85 156.75H183.845Z" fill="#FDFDFD"/>
<path d="M157.244 127.302C157.529 128.532 156.883 128.129 156.346 128.523C150.585 132.778 145.418 136.326 140.123 141.294C139.671 141.716 136.38 146.603 135.877 144.153C135.805 143.806 138.042 141.094 138.479 140.591C140.897 137.817 151.768 128.039 154.746 127.416C155.577 127.241 156.403 127.264 157.239 127.302H157.244Z" fill="#FCD02B"/>
<path d="M161.417 124.697L158.062 126.291L158.495 127.2L161.849 125.607L161.417 124.697Z" fill="#FCD02B"/>
<path d="M168.168 121.604C168.434 123.052 167.727 122.33 167.048 122.596C165.328 123.266 163.752 124.154 162.004 124.695L168.164 121.604H168.168Z" fill="#FCD02B"/>
<path d="M169.597 121.132C169.92 121.298 169.317 121.939 168.652 121.598C168.329 121.431 168.932 120.79 169.597 121.132Z" fill="#FCD02B"/>
<path d="M361.793 136.527C362.843 136.769 364.885 137.401 366.419 137.79C370.746 138.892 383.697 141.713 386.808 143.523C389.876 145.309 390.816 148.923 390.455 152.257L359.808 305.877C358.26 311.139 353.653 314.378 348.358 315.323L303.99 315.152C300.604 313.879 298.922 310.654 298.813 307.135L321.586 192.63C321.595 192.265 320 192.075 319.62 192.151C315.07 193.091 308.345 197.983 303.719 198.51C299.193 199.023 295.579 196.344 295.921 191.676C298.044 182.885 298.096 172.037 300.518 163.484C301.126 161.337 302.627 159.514 304.631 158.573C318.855 152.076 332.951 143.1 347.37 137.197C353.586 134.651 355.405 135.06 361.788 136.527H361.793Z" fill="black"/>
<path d="M391.967 78.004C391.407 78.9539 392.513 82.5538 392.846 83.8171C393.924 87.93 395.634 95.4433 397.097 99.0385C397.282 99.4945 397.253 100.017 397.861 100.202C399.063 100.568 415.049 94.5505 417.713 94.0186C418.867 93.1589 418.112 91.1642 419.641 89.2978C425.336 82.3259 436.492 90.0102 430.816 97.6233C428.779 100.354 427.226 99.504 424.96 100.316C423.004 101.019 408.585 133.067 405.731 137.128C405.218 138.006 404.562 139.004 403.475 139.179C401.395 139.517 393.045 136.064 390.329 135.247C374.661 130.54 359.577 126.485 343.53 123.155C340.732 122.576 336.078 123.426 336.334 119.061L342.285 79.5428C338.856 76.9687 338.448 71.2031 341.758 68.3963C347.215 63.7657 354.819 68.8189 352.715 75.5534C352.42 76.4938 350.967 77.1966 351.722 78.365L364.089 92.1378C367.599 88.6994 371.147 85.2514 374.77 81.9317C376.029 80.7776 381.315 76.9544 381.429 75.7529C381.695 72.8653 379.724 71.7492 382.379 68.1683C388.344 60.123 400.013 69.0042 393.563 76.9877C393.136 77.5196 392.1 77.7856 391.977 77.9993L391.967 78.004ZM415.609 102.13C414.759 101.299 395.648 109.572 393.748 109.055C392.528 108.722 392.252 107.677 391.849 106.656C389.113 99.7272 388.287 90.7986 385.684 83.8504C385.508 83.3802 385.504 82.967 384.981 82.6631C381.362 84.596 365.875 101.499 363.576 101.646C362.774 101.698 362.109 101.408 361.496 100.91C359.482 99.2617 355.93 93.8048 353.859 91.4302C353.498 91.017 352.871 90.8983 352.591 90.5848C352.368 90.3331 349.105 84.862 348.174 85.978L344.37 115.894C363.619 118.772 382.056 125.046 400.597 130.654L415.609 102.126V102.13Z" fill="black"/>
<path d="M415.606 102.132L400.594 130.661C382.053 125.052 363.621 118.779 344.367 115.901L348.171 85.9849C349.107 84.8688 352.365 90.34 352.588 90.5917C352.868 90.9052 353.495 91.0239 353.856 91.4371C355.927 93.8117 359.479 99.2686 361.493 100.917C362.106 101.415 362.766 101.705 363.573 101.653C365.872 101.506 381.359 84.6029 384.978 82.6699C385.501 82.9739 385.505 83.3871 385.681 83.8572C388.284 90.8102 389.11 99.734 391.846 106.663C392.249 107.684 392.53 108.729 393.745 109.062C395.645 109.579 414.756 101.301 415.606 102.137V102.132Z" fill="#FCD02B"/>
<path d="M384.145 264.584C383.824 263.367 384.081 262.224 384.476 261.065C386.014 256.589 391.478 250.094 394.122 246.168C398.929 239.033 408.217 223.992 414.498 218.987C425.218 210.447 438.464 221.829 429.998 232.885C416.934 242.189 404.484 253.571 391.179 262.432C388.984 263.895 386.853 266.181 384.145 264.59V264.584Z" fill="black"/>
<path d="M407.763 278.755C407.629 278.787 407.602 279.444 407.18 279.583C406.465 279.813 399.682 280.165 398.875 280.037C392.028 278.953 404.104 271.454 405.589 270.418C411.197 266.514 429.709 254.09 435.616 253.935C444.734 253.695 448.579 265.568 440.872 270.45C439.398 271.385 437.854 271.721 436.22 272.207C426.948 274.952 417.152 276.437 407.768 278.755H407.763Z" fill="black"/>
<ellipse cx="255.5" cy="688" rx="408.5" ry="353" fill="#0E0E0F"/>
<path d="M355.492 368.003H369.882C370.975 368.003 369.827 370.887 369.759 371.404C368.966 377.378 367.668 383.172 366.596 389.093C363.327 407.181 360.775 425.91 357.013 443.828C355.809 449.564 354.044 454.413 348.082 456.63C344.369 458.01 334.501 458.375 330.513 457.884C329.744 457.79 329.468 457.743 329.485 456.856C329.796 453.164 329.377 448.686 329.788 445.088C330.073 442.595 331.332 443.713 332.88 443.769C336.358 443.899 342.026 444.304 342.875 439.966C345.985 421.27 349.433 402.556 352.622 383.927C353.415 379.292 353.859 374.25 354.848 369.706C354.978 369.11 354.983 368.423 355.492 368V368.003Z" fill="#F9C027"/>
<path d="M349.912 376.521C350.008 376.548 350.431 376.98 350.496 377.115C350.854 377.867 349.618 384.266 349.386 385.688C346.596 402.707 343.201 419.659 340.29 436.646C340.22 436.734 339.697 437.022 339.635 437.022H327.888C327.785 437.022 327.538 436.693 327.488 436.534C330.569 416.672 334.264 396.827 337.703 377.003C337.788 376.883 338.657 376.521 338.757 376.521H349.917H349.912Z" fill="#F9C027"/>
<path d="M333.459 369.187L281.177 369.166C278.645 369.046 278.962 371.73 278.657 373.401C275.852 388.77 273.596 404.362 270.269 419.64C267.778 431.076 263.705 442.057 259 452.73L259.687 453.535C262.081 454.454 264.48 455.382 266.85 456.357C267.661 456.692 270.049 458.269 270.727 457.84C271.129 457.585 274.442 450.336 274.927 449.264C279.182 439.857 282.058 430.462 284.199 420.356C284.404 419.378 283.843 418.204 285.006 417.933C285.25 417.878 286.128 417.745 286.031 418.224L284.857 418.521H297.779L293.964 417.936L298.366 417.64L297.153 424.353C296.543 424.747 287.115 424.13 285.285 424.38C284.739 424.453 284.434 424.465 284.137 424.994L278.16 456.624C278.055 456.859 278.436 457.285 278.545 457.285H318.193C319.392 457.285 319.847 455.279 320.129 454.577C320.232 454.322 320.652 454.342 320.693 454.145C320.825 453.523 320.417 452.886 320.475 452.228C321.218 444.013 324.173 434.903 325.072 426.568C325.201 426.066 324.458 424.395 324.067 424.395H310.704L311.585 418.518C316.185 418.207 321.309 418.935 325.844 418.533C326.364 418.486 327.066 418.48 327.448 418.08C328.549 416.935 328.893 408.195 329.501 406.183H313.935C314.07 405.425 315.374 400.309 314.375 400.309H301.012L300.131 406.183H286.915C286.974 405.105 287.473 399.683 288.072 399.263L313.512 399.114C314.449 398.952 314.273 399.98 314.951 400.039L328.605 399.854C329.794 399.246 330.285 398.456 330.658 397.208C332.085 392.45 333.004 385.172 333.815 380.1C334.17 377.883 335.166 373.654 335.107 371.674C335.075 370.573 334.584 369.527 333.457 369.187H333.459ZM310.12 436.142C309.321 438.465 309.53 442.997 308.654 445.035C308.578 445.211 307.735 445.54 307.623 445.54H293.526C293.438 445.54 293.001 445.103 293.13 444.865L294.848 436.142H310.12ZM317.609 387.683H290.736C291.283 385.78 291.241 383.501 292.058 381.809H318.931C318.713 382.585 318.22 387.683 317.609 387.683Z" fill="#F9C027"/>
<path d="M180.477 376.517H201.329L198.706 390.194L188.436 390.35L185.763 405.592H196.042L193.652 419.061L182.544 419.114C182.732 420.321 181.41 421.889 181.792 422.9C181.963 423.346 182.67 424.086 182.976 424.676C185.886 430.283 188.438 436.092 191.622 441.564L183.258 456.988C178.153 452.598 175.619 445.57 173.422 439.367C172.876 439.241 172.852 439.552 172.591 439.854C169.771 443.117 167.363 447.684 164.312 451.253C163.454 452.257 157.686 458.554 157.066 458.718C156.447 458.883 148.344 451.652 147.019 450.921C146.679 449.79 150.958 446.263 151.842 445.244C152.981 443.928 154.426 442.075 155.501 440.68C160.558 434.116 164.876 426.856 167.845 419.102H154.922L157.287 405.607C157.757 405.152 168.485 405.839 170.356 405.607C170.905 405.54 171.352 405.466 171.534 404.873L174.309 390.323L173.413 390.517L167.842 399.886L156.097 398.544L169.052 368.473L169.754 368L183.134 368.72C183.531 368.99 183.428 369.436 183.358 369.839C183.008 371.874 180.756 374.317 180.477 376.52V376.517Z" fill="white"/>
<path d="M201.916 443.478C201.44 439.589 200.618 435.739 199.831 431.906C199.67 431.113 198.709 429.768 199.402 428.776C199.793 428.215 211.309 427.278 212.933 426.711C213.182 426.729 213.482 426.72 213.684 426.867C214.331 427.334 216.151 440.708 216.269 442.561L214.251 443.478H221.887L230.833 426.717L244.795 428.502L236.865 443.481H247.585C248.037 443.481 248.005 444.365 247.967 444.72C247.667 447.44 246.375 451.161 245.764 454.001C245.641 454.573 245.656 456.697 244.945 456.697H188.703L191.493 443.481H201.919L201.916 443.478Z" fill="white"/>
<path d="M256.503 383.524L203.847 383.864C203.069 383.744 205.592 371.603 205.924 370.616C207.058 370.02 208.223 370.639 209.392 370.654C225.548 370.866 241.722 370.478 257.881 370.628C258.189 370.634 258.577 370.648 258.771 370.918C259.173 371.476 256.571 381.885 256.503 383.524Z" fill="white"/>
<path d="M252.872 388.558H205.001L197.814 424.573C197.685 424.884 198.02 425.563 198.246 425.563H245.236C245.445 425.563 246.546 424.67 246.69 424.373C249.037 412.808 251.087 401.154 253.295 389.539C253.436 389.227 253.095 388.555 252.869 388.555L252.872 388.558ZM234.329 412.37C227.809 412.905 221.095 412.696 214.548 412.344L216.363 401.973L216.748 401.477H236.279L234.329 412.37Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_46678_103162">
<rect width="512" height="512" rx="256" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "91短剧 · 百万大神原创短剧",
applicationName: "91短剧",
title: "黄果吃瓜 · 百万大神原创短剧",
applicationName: "黄果吃瓜",
icons: {icon:[{url:'/favicon.png',sizes:'48x48',type:'image/png'}],apple:[{url:'/apple-touch-icon.png',sizes:'180x180',type:'image/png'}]},
};
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Metadata } from 'next';
export const metadata:Metadata={title:'页面不存在 - 91短剧',robots:{index:false,follow:true},alternates:{canonical:null}};
export const metadata:Metadata={title:'页面不存在 - 黄果吃瓜',robots:{index:false,follow:true},alternates:{canonical:null}};
export default function NotFound() {
return (
+3 -3
View File
@@ -20,16 +20,16 @@ export function AppDownloadDialog({download, qr, status, dialogRef, onClose, onC
</svg>
</button>
<h2 id={"app-download-heading"}>
{"下载91短剧 App"}
{"下载黄果吃瓜 App"}
</h2>
<p className={"app-download-intro"}>
{"扫码下载,随时继续追剧"}
</p>
<div className={"app-download-code"}>
<img src={qr || undefined} id={"app-platform-qr"} width={"192"} height={"192"} alt={"91短剧 App 下载二维码"} />
<img src={qr || undefined} id={"app-platform-qr"} width={"192"} height={"192"} alt={"黄果吃瓜 App 下载二维码"} />
</div>
<p id={"app-platform-caption"}>
{download === undefined ? "" : download ? "扫码下载安装 91短剧 App" : "暂未配置 App 下载地址"}
{download === undefined ? "" : download ? "扫码下载安装 黄果吃瓜 App" : "暂未配置 App 下载地址"}
</p>
<div className={"app-download-link"}>
<input id={"app-platform-link"} value={download || ""} readOnly aria-label={"下载链接"} />
-3
View File
@@ -63,9 +63,6 @@ export function ChannelPages({info,view}: {info?:InfoPageDefinition;view?:PageVi
<a href={"/info/dmca/"} data-info={"dmca"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"} data-info={"feedback"}>
{"意见反馈"}
</a>
</nav>
<article className={"info-card"} id={"info-content"} dangerouslySetInnerHTML={info?{__html:info.html}:undefined} />
</div>
+4 -16
View File
@@ -1,10 +1,11 @@
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/site";
export function Footer({content}:{content?:string}={}) {
return (
<>
<footer className={"hg-footer hg-footer-original"} aria-label={"网站底部"}>
<div className={"hg-footer-original-info"}>
<strong className={"hg-footer-original-title"}>
<span className="footer-brand-text">91短剧</span>
<span className="footer-brand-text">黄果吃瓜</span>
</strong>
<p className={"hg-footer-original-slogan"}>
{content||"百万大神原创短剧"}
@@ -28,28 +29,15 @@ export function Footer({content}:{content?:string}={}) {
<a href={"/info/dmca/"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"}>
{"意见反馈"}
</a>
</nav>
<small className={"hg-footer-original-copyright"}>
{"© 2026 91短剧 All rights reserved."}
{"© 2026 黄果吃瓜 All rights reserved."}
</small>
<div className={"hg-footer-social"} aria-label={"联系我们"}>
<a href={"https://t.me/huangguoAI_2026"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方社群"} title={"官方社群"}>
<img src={"/assets/c9e57990dff7b688.png"} alt={"官方社群图标"} />
</a>
<a href={"https://t.me/huangguoAI_2026"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"Telegram"} title={"Telegram"}>
<a href={OFFICIAL_TG_URL} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方TG "+OFFICIAL_TG_HANDLE} title={"官方TG "+OFFICIAL_TG_HANDLE}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M21.7 3.3 18.4 20c-.2 1.2-.9 1.5-1.9.9l-5-3.7-2.4 2.3c-.3.3-.5.5-1 .5l.4-5.1 9.3-8.4c.4-.4-.1-.6-.6-.3L5.7 13.4.8 11.9c-1-.3-1-1 .2-1.5L20.2 3c.9-.3 1.7.2 1.5.3Z"}>
</path>
</svg>
</a>
<a href={"https://x.com/huangguoAITV"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"X"} title={"X"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M18.9 2H22l-6.8 7.8L23 22h-6.3l-4.9-7.4L5.3 22H2l8.3-9.5L2 2h6.5l4.4 6.8L18.9 2ZM17.8 20h1.8L7.5 4H5.6l12.2 16Z"}>
</path>
</svg>
</a>
+4 -28
View File
@@ -1,14 +1,14 @@
import type { HomeData, User } from "@/lib/api/types";
import type { HomeData } from "@/lib/api/types";
import { renderHome } from "@/lib/client/render";
import type { NavigationEntry } from '@/lib/site-navigation';
import { SiteNavigation } from './SiteNavigation';
export function HomePage({home,error,hideContent=false,navigation=[],address='/',navigationError=false,user=null}: {user?:User|null;home: HomeData; error?: string;hideContent?:boolean;navigation?:NavigationEntry[];address?:string;navigationError?:boolean}) {
export function HomePage({home,error,hideContent=false,navigation=[],address='/',navigationError=false}: {home: HomeData; error?: string;hideContent?:boolean;navigation?:NavigationEntry[];address?:string;navigationError?:boolean}) {
return <>
<div id="global-site-header" className="shell">
<header className={"top"}>
<a className="hg-header-brand" href="/" aria-label="91短剧首页"><img className="hg-complete-logo" src="/assets/5178e628105b82b4.svg" width={125} height={36} alt="91短剧" /></a>
<a className="hg-header-brand" href="/" aria-label="黄果吃瓜首页"><img className="hg-complete-logo" src="/assets/hgcg-logo.png" width={125} height={44} alt="黄果吃瓜" /></a>
<SiteNavigation entries={navigation} address={address} surface="top" error={navigationError} />
<form className={"search"} id={"search"} role={"search"}>
<input id={"query"} aria-controls={"search-discovery"} aria-expanded={"false"} type={"search"} placeholder={"找一部,想看的好剧"} aria-label={"搜索短剧"} autoComplete={"off"} />
@@ -71,17 +71,6 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
</section>
</form>
<div className={"header-actions"} aria-label={"用户服务"}>
<button className={"header-action coin-entry"} data-account-action={"coins"} aria-label={'金币余额 '+(user?.recharge||0)}>
<span className={"coin-symbol"} aria-hidden={"true"}>
<img className={"coin-soft-star"} src={"/assets/4532d28d6a61c426.svg"} alt={"金币图标"} aria-hidden={"true"} />
</span>
<span className={"coin-caption"}>
{"金币 "}
<span className={"coin-value"}>
{user?.recharge||0}
</span>
</span>
</button>
<div className={"header-download-wrap"} id={"header-download-wrap"} data-app-download-entry="" hidden>
<a className={"header-action download-entry"} id={"header-download-button"} data-app-download="" target="_blank" rel="sponsored nofollow noopener noreferrer" aria-label={"下载 App"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
@@ -97,19 +86,6 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
</span>
</a>
</div>
<button className={"header-action account-entry "} id={"account-entry"} aria-controls={"profile-popover"} aria-expanded={"false"}>
<span className={"account-avatar"} aria-hidden={"true"}>
<img className={"member-avatar-image"} src={"/assets/8837c3e58159bd48.png"} alt={"默认用户头像"} width={"50"} height={"50"} />
</span>
<span className={"account-copy"}>
<strong>
{user?.nickName||user?.accountNo||'我的'}
</strong>
<small id={"account-status"}>
{user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录'}
</small>
</span>
</button>
</div>
<button className={"saved header-saved-proxy"} id={"saved"} hidden>
{"♡ 我的片单 "}
@@ -120,7 +96,7 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
</header>
</div>
<main className="shell" hidden={hideContent} aria-labelledby={hideContent?undefined:'home-title'}>
{!hideContent&&<h1 id="home-title" className="home-page-title">91短剧 · 百万大神原创短剧</h1>}
{!hideContent&&<h1 id="home-title" className="home-page-title">黄果吃瓜 · 百万大神原创短剧</h1>}
<div className="home-content" hidden={hideContent} dangerouslySetInnerHTML={{__html:hideContent?'':renderHome(home,error)}} />
</main>
</>;
+11 -27
View File
@@ -1,19 +1,17 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { startTracker } from "@/lib/client/analytics";
import { useRef, useState, type MouseEvent } from "react";
import type { Config } from "@/lib/api/types";
import { navigationPageData } from "@/lib/navigation-page";
import { AppDownloadDialog } from "./AppDownloadDialog";
import { createDownloadQr, shouldUseDownloadDialog } from "@/lib/client/download";
export function NavigationPage({config, download, error}: {config: Config; download: string; error?: string}) {
useEffect(()=>{startTracker(config);},[config]);
const dialog = useRef<HTMLDialogElement>(null);
const [status, setStatus] = useState("");
const [qr, setQr] = useState<string>();
const {siteName,description,routes,permanent,telegram,tgGroup,twitter}=navigationPageData(config);
const channelCount=2+Number(!!tgGroup)+Number(!!twitter)+Number(!!download);
const {siteName,description,routes,permanent,telegram,telegramHandle}=navigationPageData(config);
const channelCount=3+Number(!!download);
const openDownload = async (event: MouseEvent<HTMLAnchorElement>) => {
if (!shouldUseDownloadDialog(event)) return;
event.preventDefault();setStatus("");dialog.current?.showModal();
@@ -27,12 +25,12 @@ export function NavigationPage({config, download, error}: {config: Config; downl
return (
<>
<div id={"entry-navigation"} className={"navigation-page"}>
<img className={"entry-background"} src={"/assets/7f137587edc271d0.png"} alt={"91短剧官方入口背景"} aria-hidden={"true"} />
<img className={"entry-background"} src={"/assets/7f137587edc271d0.png"} alt={"黄果吃瓜官方入口背景"} aria-hidden={"true"} />
<div className={"entry-stage"}>
<main className={"entry-content"} aria-labelledby={"entry-page-title"}>
<header className={"entry-brand"}>
<h1 id="entry-page-title" className="entry-page-title">{siteName+"官方入口"}</h1>
<img className={"entry-brand-logo hg-complete-logo"} src={"/assets/5178e628105b82b4.svg"} alt={siteName} width={"500"} height={"176"} />
<img className={"entry-brand-logo hg-complete-logo"} src={"/assets/hgcg-logo.png"} alt={siteName} width={"500"} height={"176"} />
<p>
{description}
</p>
@@ -68,11 +66,11 @@ export function NavigationPage({config, download, error}: {config: Config; downl
{siteName+" · APP 下载"}
</span>
</a>}
{telegram&&<a className={"entry-contact"} href={telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
{"官方联系:" + telegram}
</a>}
<a className={"entry-contact"} href={telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
{"官方TG:" + telegramHandle}
</a>
<nav className={"entry-channels"} aria-label={"官方渠道"} style={{gridTemplateColumns: `repeat(${channelCount}, minmax(0, 1fr))`}}>
{tgGroup&&<a className={"entry-channel"} href={tgGroup} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
<a className={"entry-channel"} href={telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
<span className={"entry-channel-icon"}>
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
<g>
@@ -83,23 +81,9 @@ export function NavigationPage({config, download, error}: {config: Config; downl
</svg>
</span>
<span>
{"官方TG群"}
{"官方TG"}
</span>
</a>}
{twitter&&<a className={"entry-channel"} href={twitter} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
<span className={"entry-channel-icon"}>
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
<g>
<path d={"M11.9025 1.90609H13.97L9.45311 7.06859L14.7669 14.0936H10.6062L7.34748 9.83296L3.61872 14.0936H1.54998L6.38123 8.57171L1.28373 1.90609H5.54999L8.49561 5.80046L11.9025 1.90609ZM11.1769 12.8561H12.3225L4.92747 3.07859H3.69809L11.1769 12.8561Z"} fill={"currentColor"}>
</path>
</g>
</svg>
</span>
<span>
{"官方推特"}
</span>
</a>}
</a>
<a className={"entry-channel"} href={"/home-road/"}>
<span className={"entry-channel-icon"}>
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
+1 -11
View File
@@ -11,19 +11,9 @@ export function PageTools() {
</svg>
</button>
<a className={"page-tool"} id={"floating-download"} data-app-download="" target="_blank" rel="sponsored nofollow noopener noreferrer" aria-label={"下载 App"}>
<img className={"floating-app-icon"} src={"/assets/6dd2c76631816aa2.svg"} alt={"91短剧 App"} width={"88"} height={"88"} />
<img className={"floating-app-icon"} src={"/assets/hgcg-app-icon.png"} alt={"黄果吃瓜 App"} width={"88"} height={"88"} />
</a>
</div>
<button className={"page-tool"} id={"floating-history"} type={"button"} aria-label={"历史记录"} title={"历史记录"} aria-haspopup={"dialog"} aria-controls={"history-dialog"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<circle cx={"12"} cy={"12"} r={"9"} fill={"none"} stroke={"currentColor"} strokeWidth={"1.8"}>
</circle>
<path d={"M12 7v5l3 2"} fill={"none"} stroke={"currentColor"} strokeWidth={"1.8"} strokeLinecap={"round"} strokeLinejoin={"round"}>
</path>
</svg>
</button>
<button className={"page-tool"} id={"back-to-top"} type={"button"} aria-label={"回到顶部"} title={"回到顶部"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M5 4h14M12 20V8m-6 6 6-6 6 6"}>
+6 -18
View File
@@ -1,3 +1,4 @@
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/site";
import type { NavigationEntry } from '@/lib/site-navigation';
import { SiteNavigation } from './SiteNavigation';
@@ -6,9 +7,9 @@ export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[]
<>
<aside className={"side"} id={"site-sidebar"} aria-label={"网站侧边栏"}>
<div className={"sidebar-header"}>
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"91短剧首页"}>
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"黄果吃瓜首页"}>
<span className={"sidebar-brand-window"}>
<img className={"sidebar-brand"} src={"/assets/5178e628105b82b4.svg"} alt={"91短剧"} width={"500"} height={"176"} />
<img className={"sidebar-brand"} src={"/assets/hgcg-logo.png"} alt={"黄果吃瓜"} width={"500"} height={"176"} />
</span>
</a>
<button className={"sidebar-toggle"} id={"sidebar-toggle"} type={"button"} aria-controls={"site-sidebar"} aria-expanded={"true"} aria-label={"收起侧边栏"} title={"收起侧边栏"}>
@@ -25,7 +26,7 @@ export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[]
<SiteNavigation entries={navigation} address={address} error={error} />
<div className={"side-info"}>
<strong>
<img className={"side-info-wordmark"} src={"/assets/5178e628105b82b4.svg"} alt={"91短剧"} width={"500"} height={"176"} />
<img className={"side-info-wordmark"} src={"/assets/hgcg-logo.png"} alt={"黄果吃瓜"} width={"500"} height={"176"} />
<i className={"side-info-brand-dot"} aria-hidden={"true"}>
</i>
@@ -52,12 +53,9 @@ export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[]
<a href={"/info/dmca/"} data-info={"dmca"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"} data-info={"feedback"}>
{"意见反馈"}
</a>
</div>
<small>
{"© 2026 91短剧"}
{"© 2026 黄果吃瓜"}
<br />
{"All rights reserved."}
</small>
@@ -73,20 +71,10 @@ export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[]
</svg>
</a>
<div className={"sidebar-social"} aria-label={"联系我们"}>
<a href={"https://t.me/huangguoAI_2026"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"QQ群(参考站链接)"} title={"QQ群(参考站链接)"}>
<img src={"/assets/c9e57990dff7b688.png"} alt={"官方社群图标"} />
</a>
<a href={"https://t.me/huangguoAI_2026"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"Telegram"} title={"Telegram"}>
<a href={OFFICIAL_TG_URL} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方TG "+OFFICIAL_TG_HANDLE} title={"官方TG "+OFFICIAL_TG_HANDLE}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M21.7 3.3 18.4 20c-.2 1.2-.9 1.5-1.9.9l-5-3.7-2.4 2.3c-.3.3-.5.5-1 .5l.4-5.1 9.3-8.4c.4-.4-.1-.6-.6-.3L5.7 13.4.8 11.9c-1-.3-1-1 .2-1.5L20.2 3c.9-.3 1.7.2 1.5.3Z"}>
</path>
</svg>
</a>
<a href={"https://x.com/huangguoAITV"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"X"} title={"X"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M18.9 2H22l-6.8 7.8L23 22h-6.3l-4.9-7.4L5.3 22H2l8.3-9.5L2 2h6.5l4.4 6.8L18.9 2ZM17.8 20h1.8L7.5 4H5.6l12.2 16Z"}>
</path>
</svg>
</a>
+1 -97
View File
@@ -9,103 +9,7 @@ export function SiteDialogs() {
<div className={"toast hidden"} id={"toast"} role={"status"}>
</div>
<dialog id={"account-dialog"} aria-labelledby={"account-dialog-title"}>
</dialog>
<dialog id={"history-dialog"} className={"history-dialog"} aria-labelledby={"history-dialog-title"}>
<button className={"close"} id={"history-close"} type={"button"} aria-label={"关闭历史记录"}>
<svg className={"dialog-close-icon"} viewBox={"0 0 24 24"} aria-hidden={"true"} focusable={"false"}>
<path d={"m6 6 12 12M18 6 6 18"}>
</path>
</svg>
</button>
<h2 id={"history-dialog-title"}>
{"历史记录"}
</h2>
<p className={"history-note"}>
{"最近浏览的剧集"}
</p>
<div className={"account-history-list"} id={"history-list"}>
</div>
<button type={"button"} className={"history-clear"} id={"history-clear"}>
{"清空记录"}
</button>
</dialog>
<AppDownloadDialog />
<dialog id={"member-delete-dialog"} aria-labelledby={"member-delete-title"} aria-describedby={"member-delete-description"}>
<form method={"dialog"}>
<h2 id={"member-delete-title"}>
{"确认删除"}
</h2>
<p id={"member-delete-description"}>
</p>
<div className={"member-delete-actions"}>
<button value={"cancel"} autoFocus>
{"取消"}
</button>
<button value={"confirm"}>
{"确认删除"}
</button>
</div>
</form>
</dialog>
<dialog id={"member-auth-dialog"} aria-labelledby={"member-auth-title"}>
</dialog>
<dialog id={"wallet-payment-dialog"} aria-labelledby={"wallet-payment-title"}>
</dialog>
<dialog id={"play-episode-lock"} aria-labelledby={"episode-lock-title"} aria-describedby={"episode-lock-description episode-lock-note"}>
<button type={"button"} className={"unlock-close"} aria-label={"关闭金币解锁弹窗"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"m6 6 12 12M18 6 6 18"}>
</path>
</svg>
</button>
<div className={"unlock-coin"} aria-hidden={"true"}>
<span className={"coin-symbol"} aria-hidden={"true"}>
<img className={"coin-soft-star"} src={"/assets/4532d28d6a61c426.svg"} alt={"金币图标"} aria-hidden={"true"} />
</span>
</div>
<h2 id={"episode-lock-title"}>
{"使用金币解锁本集"}
</h2>
<p id={"episode-lock-description"} className={"episode-lock-copy"}>
{"解锁后可永久观看本集内容。"}
</p>
<div className={"unlock-cost-summary"}>
<div>
<span>
{"当前余额"}
</span>
<strong id={"episode-lock-balance"}>
{"— 金币"}
</strong>
</div>
<div>
<span>
{"本次消耗"}
</span>
<strong className={"unlock-cost"}>
{"— 金币"}
</strong>
</div>
</div>
<p id={"episode-lock-note"} className={"unlock-confirm-note"}>
{"后续付费剧集仍需逐集确认"}
</p>
<p id={"episode-lock-error"} className={"unlock-error"} role={"alert"} hidden></p>
<button type={"button"} className={"unlock-pay"}>
{"支付 — 金币并播放"}
</button>
<a className={"unlock-recharge"} href={"/info/deposit/"} data-close-dialog="">
{"金币不足?去充值"}
</a>
</dialog>
<dialog id={"startup-age"} className={"startup-popup"} aria-labelledby={"startup-age-title"} aria-describedby={"startup-age-description"}>
<div className={"startup-age-layout"}>
@@ -150,7 +54,7 @@ export function SiteDialogs() {
</button>
</dialog>
<dialog id={"startup-ad"} className={"startup-popup"} aria-label={"广告推荐"}>
<a className={"startup-ad-media"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} data-track-ad="external-dialog" data-ad-type="interstitial" hidden>
<a className={"startup-ad-media"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} data-ad hidden>
<img className={"startup-ad-image"} width={"400"} height={"602"} alt={"广告"} />
</a>
<p className={"startup-ad-loading"} role={"status"} hidden>{"正在加载下一条广告…"}</p>
+1 -1
View File
@@ -21,7 +21,7 @@ export async function SiteFrame({home,error,info,seo,view,homeLoaded=true,sessio
return <>
<StructuredData seo={seo} />
<Sidebar navigation={navigation} address={address} error={header.error} />
<HomePage user={session?.user||view?.bootstrap?.session?.user} navigation={navigation} address={address} navigationError={header.error} home={home} error={error} hideContent={!!info||!!view} />
<HomePage navigation={navigation} address={address} navigationError={header.error} home={home} error={error} hideContent={!!info||!!view} />
<ChannelPages info={info} view={view} />
<PlayerPage view={view} />
<PageTools />
+1 -2
View File
@@ -3,7 +3,6 @@
import Script from "next/script";
import type { HomeData } from "@/lib/api/types";
import { startApplication } from "@/lib/client/application";
import { startTracker } from "@/lib/client/analytics";
import type { SeoBootstrap } from '@/lib/seo';
import type { PageBootstrap, SiteSharedData } from '@/lib/page-data';
import type { NavigationEntry } from '@/lib/site-navigation';
@@ -11,6 +10,6 @@ import type { NavigationEntry } from '@/lib/site-navigation';
/** Initialize the reference interactions once, after React finishes hydration. */
export function SiteRuntime({home,homeLoaded=true,seo,page,session,shared}: {home: HomeData;homeLoaded?:boolean;seo?:SeoBootstrap;page?:PageBootstrap;navigation?:NavigationEntry[];session?:PageBootstrap['session'];shared?:SiteSharedData}) {
return <Script src="/scripts/site-runtime.js" strategy="afterInteractive" onReady={()=>{
startTracker();startApplication(home,homeLoaded,seo,page,session,shared);
startApplication(home,homeLoaded,seo,page,session,shared);
}} />;
}
-8
View File
@@ -123,10 +123,6 @@
"type": "image/svg+xml",
"bytes": 12572
},
"5178e628105b82b4.svg": {
"type": "image/svg+xml",
"bytes": 12572
},
"c9e57990dff7b688.png": {
"type": "image/png",
"bytes": 538
@@ -415,10 +411,6 @@
"type": "image/svg+xml",
"bytes": 474
},
"6dd2c76631816aa2.svg": {
"type": "image/svg+xml",
"bytes": 13124
},
"388ac2896091bdb8.png": {
"type": "image/png",
"bytes": 173198
-26
View File
@@ -1,26 +0,0 @@
import type { Drama, Package, PageData } from './api/types';
import { recordList } from './api/protocol';
import { publicDrama } from './player-data';
import type { WalletInitialData } from './page-data';
export function accountList(data:PageData<Drama>):PageData<Drama> {
return {list:recordList<Drama>(data.list).map(publicDrama),totalCount:typeof data.totalCount==='number'?data.totalCount:0};
}
export function accountPackages(data:WalletInitialData['packages']):WalletInitialData['packages'] {
const items=(value:unknown)=>recordList<Package>(value).map(item=>{
const result:Record<string,unknown>={};
for(const key of ['id','packageType','price','originalPrice','baseCoin','giftCoin','totalCoin','durationDays','durationHours'] as const)if(typeof item[key]==='number'&&Number.isFinite(item[key]))result[key]=item[key];
for(const key of ['name','subTitle'] as const)if(typeof item[key]==='string')result[key]=item[key];
result.payMethods=recordList<{type:string;typeName:string}>(item.payMethods).map(method=>({type:String(method.type||''),typeName:String(method.typeName||'')}));
return result as unknown as Package;
});
return {coinList:items(data.coinList),vipList:items(data.vipList)};
}
export function accountTransactions(data:WalletInitialData['transactions']):WalletInitialData['transactions'] {
return {totalCount:typeof data.totalCount==='number'?data.totalCount:0,...(data.error?{error:'余额明细暂不可用,请稍后刷新'}:{}),list:recordList<Record<string,unknown>>(data.list).map(item=>{
const result:Record<string,unknown>={};
for(const key of ['changeAmount','amount','coin','changeCoin','flowDir'])if(typeof item[key]==='number'&&Number.isFinite(item[key]))result[key]=item[key];
for(const key of ['title','tranTypeName','name','remark','typeName','createdAt','time'])if(typeof item[key]==='string')result[key]=item[key];
return result;
})};
}
+2 -4
View File
@@ -1,4 +1,4 @@
export const DEFAULT_API_BASE_URL = 'http://172.104.181.99:21022';
export const DEFAULT_API_BASE_URL = 'https://webhgcgapi.mfcg.me';
export const UPSTREAM_HOP_HEADER = 'x-91dj-upstream';
export interface ApiRequestContext {
@@ -7,9 +7,7 @@ export interface ApiRequestContext {
}
function configuredApiUrl(): string {
const environment = process.env.APP_ENV || (process.env.NODE_ENV === 'production' ? 'production' : 'development');
const fallback = environment === 'production' ? 'http://172.104.56.28:26125' : DEFAULT_API_BASE_URL;
return (process.env.UPSTREAM_API_URL ?? process.env.API_BASE_URL ?? fallback).trim();
return (process.env.UPSTREAM_API_URL ?? process.env.API_BASE_URL ?? DEFAULT_API_BASE_URL).trim();
}
export function usesCurrentApiOrigin(): boolean {
+6 -9
View File
@@ -21,7 +21,7 @@ export async function preparePageSession(request:NextRequest,required:boolean) {
const channel=(cookies.get('drama_channel')||request.nextUrl.searchParams.get('dc')||request.nextUrl.searchParams.get('ch')||request.nextUrl.searchParams.get('pc')||process.env.API_CHANNEL||'').trim().slice(0,100);
const address=(request.headers.get('x-real-ip')||request.headers.get('x-forwarded-for')?.split(',')[0]||'').trim();
const identity={request,device,userAgent:request.headers.get('user-agent')||'',clientIp:isIP(address)?address:undefined,timeoutMs:5000};
let token=cookies.get('drama_session')||'',registered=cookies.get('drama_registered')==='1',user:User|null=null,state=registered?'login-required':'unavailable';
let token=cookies.get('drama_session')||'',user:User|null=null,state='unavailable';
const updates=new Map<string,string>();
const set=(key:string,value:string)=>{if(value)cookies.set(key,value);else cookies.delete(key);updates.set(key,value);};
set('drama_device',device);if(channel)set('drama_channel',channel);
@@ -31,20 +31,17 @@ export async function preparePageSession(request:NextRequest,required:boolean) {
try{result=await upstream<User>('auth/getInfo',{...identity,token});}
catch(error){if(error&&typeof error==='object'&&'code' in error&&expired(Number(error.code)))result={code:Number(error.code),data:null};else throw error;}
if(result.code===200){user=publicUser(result.data);if(!user)throw new Error('账号信息无效');token=result.data?.token||token;}
else if(expired(result.code)){set('drama_session','');set('drama_registered','');token='';if(registered)state='login-required';}
else if(expired(result.code)){set('drama_session','');token='';}
else throw new Error('账号服务暂不可用');
}
if(!user&&!registered&&!cookies.has('drama_skip_auto')){
if(!user){
const result=await upstream<User>('auth/guest',{...identity,method:'POST',body:{devID:device,deviceId:device,channel,mac:''}});
user=result.code===200?publicUser(result.data):null;token=result.data?.token||'';
if(!user||!token)throw new Error('游客会话暂不可用');
}
if(user){
registered=user.isRegister===true||user.type===1||registered;
set('drama_session',token);set('drama_registered',registered?'1':'0');set('drama_device',device);if(channel)set('drama_channel',channel);
headers.set(SSR_USER_HEADER,Buffer.from(JSON.stringify(user)).toString('base64url'));state='ready';
}else if(cookies.has('drama_skip_auto')){set('drama_skip_auto','');state='login-required';}
}catch{/* Render a retry state; authentication failure must never expose a partial private page. */}
set('drama_session',token);set('drama_device',device);if(channel)set('drama_channel',channel);
headers.set(SSR_USER_HEADER,Buffer.from(JSON.stringify(user)).toString('base64url'));state='ready';
}catch{/* Render a retry state when the guest session is unavailable. */}
headers.set(SSR_SESSION_HEADER,state);
headers.set('cookie',[...cookies].map(([key,value])=>`${key}=${encodeURIComponent(value)}`).join('; '));
const response=NextResponse.next({request:{headers}});
+6 -11
View File
@@ -10,22 +10,18 @@ export { DEFAULT_API_BASE_URL } from './config';
export const PUBLIC_PATHS = new Set([
"playlet/home", "config/info", "config/footer/info", "config/richtext/list",
"navigation/header", "theme/list", "playlet/tag/options", "translation/category/list",
"recharge/package/list", "applist/list", "playlet/all/list", "playlet/hot/list",
"applist/list", "playlet/all/list", "playlet/hot/list",
"playlet/hot/rank/list", "playlet/topic/list", "playlet/topic/detail", "playlet/tag/list",
"playlet/guess-like/list", "playlet/hot-like",
]);
const GET_PATHS = [
...PUBLIC_PATHS, "auth/getInfo", "playlet/detail", "playlet/chapter/list", "playlet/chapter/detail",
"playlet/search", "playlet/search/suggest", "playlet/collect/list", "playlet/chapter/collect/list",
"playlet/history/list", "playlet/like/list", "playlet/bought/list", "user/center/data", "coin/orders", "coin/trans/list",
"vip/orders", "comment/list",
"playlet/search", "playlet/search/suggest",
];
const POST_PATHS = [
"auth/guest", "auth/login", "auth/register", "auth/resetPassword", "auth/profile",
"auth/guest",
"playlet/collect/set", "playlet/like/set", "playlet/chapter/collect/set", "playlet/chapter/like/set",
"playlet/chapter/unlock", "playlet/chapter/watch/report", "playlet/forward/heat",
"playlet/history/clear", "playlet/history/delete", "comment/send", "comment/like/set",
"coin/buy", "vip/pay", "feedback/add", "upload/image",
"playlet/chapter/watch/report", "playlet/forward/heat",
];
export function allowedRequest(path: string, method: string): boolean {
return (method === "GET" ? GET_PATHS : method === "POST" ? POST_PATHS : []).includes(path);
@@ -35,10 +31,10 @@ export async function getServerApiBaseUrl(request?: ApiRequestContext): Promise<
return getApiBaseUrl(request || (usesCurrentApiOrigin() ? {headers: await headers()} : undefined));
}
export async function upstream<T>(path: string, options: {method?: string; body?: unknown; token?: string; device?: string; userAgent?: string; clientIp?: string; tracking?:Record<string,string>; timeoutMs?:number; request?:ApiRequestContext} = {}): Promise<Envelope<T>> {
export async function upstream<T>(path: string, options: {method?: string; body?: unknown; token?: string; device?: string; userAgent?: string; clientIp?: string; timeoutMs?:number; request?:ApiRequestContext} = {}): Promise<Envelope<T>> {
const url=new URL(`/api/app/${path}`,await getServerApiBaseUrl(options.request));
const policy=publicCachePolicy(url.pathname.slice('/api/app/'.length));
if(policy&&(!options.method||options.method==='GET')&&!options.token&&options.body===undefined&&!Object.keys(options.tracking||{}).length){
if(policy&&(!options.method||options.method==='GET')&&!options.token&&options.body===undefined){
url.searchParams.sort();
return publicDataCache.read(url.href,policy.ttl,()=>requestUpstream<T>(url,options),{stale:policy.stale,accept:result=>result.code===200&&!!result.data&&typeof result.data==='object'&&!Array.isArray(result.data)});
}
@@ -56,7 +52,6 @@ async function requestUpstream<T>(url:URL,options:Parameters<typeof upstream>[1]
};
if (options.token) headers.Authorization = options.token;
if (options.clientIp) headers['X-Real-IP'] = options.clientIp;
for(const key of ['X-SDK-Common','X-User-Agent','X-Tracker-Request-Id','X-Tracker-Event-Name','X-DC-Request-Context'])if(options.tracking?.[key])headers[key]=options.tracking[key];
const multipart = options.body instanceof FormData;
if (options.body && !multipart) headers["Content-Type"] = "application/json";
let response: Response;
-40
View File
@@ -1,40 +0,0 @@
import { COMMON_KEYS, COMMON_FIELD_LIMITS, SDK_FIELD_KEYS, SERVER_EVENTS, TRACKER_APP_ID } from '../tracking';
const object=(value:unknown):value is Record<string,unknown>=>!!value&&typeof value==='object'&&!Array.isArray(value);
const string=(value:unknown,max:number)=>typeof value==='string'&&value.length<=max&&!/[\x00-\x1f\x7f]/.test(value);
const DRAMA_KEYS=['media_id','drama_id','episode','video_id','recommend_trace_id'];
/** Forward only the SDK snapshot and the event associated with this business route. */
export function trackingHeaders(source:Headers,path:string,method:string):Record<string,string>{
const headers:Record<string,string>={};
const sdkHeader=source.get('x-sdk-common')||'';
if(sdkHeader&&sdkHeader.length<=16384&&/^[a-z\d+/]+=*$/i.test(sdkHeader)){
try{
const fields:unknown=JSON.parse(Buffer.from(sdkHeader,'base64').toString('utf8'));
const validField=(key:string,value:unknown)=>key==='client_ts'?Number.isSafeInteger(value)&&Number(value)>0:string(value,COMMON_FIELD_LIMITS[key]);
if(object(fields)&&SDK_FIELD_KEYS.every(key=>!Object.hasOwn(fields,key)||validField(key,fields[key]))){
const common=Object.fromEntries(Object.entries(fields).filter(([key])=>SDK_FIELD_KEYS.includes(key as typeof SDK_FIELD_KEYS[number])));
headers['X-SDK-Common']=Buffer.from(JSON.stringify(common)).toString('base64');
const clean=(value:unknown)=>String(value??'').replace(/[^\x20-\x7e]|[;=]/g,' ').trim();
if(common.device_id||common.sid)headers['X-User-Agent']=[`DevID=${clean(common.device_id)}`,`DevType=${clean(common.device)||'OTHER'}`,`SysType=${common.device==='Android'?'h5_android':common.device==='iOS'?'h5_ios':'h5_pc'}`,`Ver=${clean(common.app_version)||'1.0.0'}`,'BuildID=web',`device_brand=${clean(common.device_brand)}`,`device_model=${clean(common.device_model)}`,`sid=${clean(common.sid)}`].join(';');
}
}catch{}
}
const event=SERVER_EVENTS[path],requestId=source.get('x-tracker-request-id')||'',packed=source.get('x-dc-request-context')||'';
if(method!=='POST'||!event||source.get('x-tracker-event-name')!==event||!(/^[a-f\d]{32}$/i.test(requestId))||!packed||packed.length>8192||!/^[a-z\d_-]+$/i.test(packed))return headers;
try{
const raw=Buffer.from(packed,'base64url');if(raw.length>6144)return headers;
const context=JSON.parse(raw.toString('utf8'));
if(!object(context)||context.v!==1||context.request_id!==requestId||context.event_name!==event||!object(context.common)||!object(context.drama))return headers;
const {common,drama}=context;
if(Object.keys(common).length!==COMMON_KEYS.length||!COMMON_KEYS.every(key=>Object.hasOwn(common,key))||Object.keys(drama).length!==DRAMA_KEYS.length||!DRAMA_KEYS.every(key=>Object.hasOwn(drama,key)))return headers;
if(!COMMON_KEYS.every(key=>key==='client_ts'?Number.isSafeInteger(common[key])&&Number(common[key])>0:string(common[key],COMMON_FIELD_LIMITS[key])))return headers;
if(common.app_id!==TRACKER_APP_ID||!common.device_id||!['','Android','iOS','PC','OTHER'].includes(String(common.device)))return headers;
if(common.uid&&!/^[a-z\d_]{4,32}$/i.test(String(common.uid)))return headers;
if(!/^[a-z\d_.-]{1,50}$/i.test(String(common.channel)))return headers;
if(!DRAMA_KEYS.every(key=>key==='episode'?Number.isSafeInteger(drama[key])&&Number(drama[key])>0:string(drama[key],key==='recommend_trace_id'?2048:512))||!drama.drama_id)return headers;
headers['X-Tracker-Request-Id']=requestId;headers['X-Tracker-Event-Name']=event;
headers['X-DC-Request-Context']=Buffer.from(JSON.stringify({v:1,request_id:requestId,event_name:event,common,drama})).toString('base64url');
}catch{}
return headers;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export interface AppItem { id: number; categoryId?: number; advitiseId?: string;
export interface AppCategory { id: number; code?: number; title: string; intro?: string; appList?: AppItem[] }
export interface AppData { category?: AppCategory; categoryList?: AppCategory[] }
export interface Package { id: number; name: string; subTitle?: string; packageType: number; price: number; originalPrice?: number; durationHours?: number; baseCoin?: number; giftCoin?: number; totalCoin?: number; durationDays?: number; payMethods?: {type:string; typeName:string}[] }
export interface Config { reportSdkConfig?: string; config?: Record<string, unknown>; newAddressInfo?: Record<string, unknown>; navPageList?: {line:number; url:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo | string; seoNavInfo?: Record<string,unknown> | string }
export interface Config { config?: Record<string, unknown>; newAddressInfo?: Record<string, unknown>; navPageList?: {line:number; url:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo | string; seoNavInfo?: Record<string,unknown> | string }
export interface FooterContact { name?: string; value?: string }
export interface FooterInfoResponse { footerInfo?: { brand?: {content?: string}; contactList?: FooterContact[] | null } }
export interface Envelope<T> { code: number; data: T; msg?: string; tip?: string }
-273
View File
@@ -1,273 +0,0 @@
import type { MemberInitialData, WalletInitialData } from "../page-data";
import type { Drama, Package, PageData, User } from "../api/types";
import { recordList, safeUrl } from "../api/protocol";
import type { SiteContext } from "./context";
import { promotionRow, card, closeButton, empty, escape, imageAttributes, pagination } from "./render";
import type { PageHref } from './render';
import { authFormPayload } from './auth';
const passwordEye=(revealed=false)=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>${revealed?'<path d="m3 3 18 18"/>':''}</svg>`;
export function bindAccountUI(app: SiteContext) {
const dialog=document.getElementById('member-auth-dialog') as HTMLDialogElement;
dialog.addEventListener('click',event=>{
const toggle=(event.target as Element).closest<HTMLButtonElement>('[data-auth-password-toggle]');
if(!toggle)return;
const input=dialog.querySelector<HTMLInputElement>('#'+toggle.dataset.authPasswordToggle)!;
const show=input.type==='password';input.type=show?'text':'password';
toggle.innerHTML=passwordEye(show);toggle.setAttribute('aria-label',show?'隐藏密码':'显示密码');
});
dialog.addEventListener('input',()=>{const error=dialog.querySelector('#auth-error');if(error)error.textContent='';});
dialog.addEventListener('submit',event=>{
event.preventDefault();const form=event.target as HTMLFormElement;
if(form.dataset.submitting||!form.reportValidity())return;
const mode=form.dataset.mode||'login';const error=form.querySelector('#auth-error')!;
let payload:Record<string,string>;
try{payload=authFormPayload(mode,Object.fromEntries(new FormData(form)));}
catch(reason){error.textContent=reason instanceof Error?reason.message:'请检查输入';return;}
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
const label=button.textContent;form.dataset.submitting='1';button.disabled=true;
button.textContent=mode==='register'?'绑定中…':mode==='resetPassword'?'提交中…':'登录中…';error.textContent='';
void app.api.post<User>('auth/'+mode,payload).then(async()=>{
form.querySelectorAll<HTMLInputElement>('[data-auth-password]').forEach(input=>input.value='');
if(mode==='resetPassword'){app.notify('密码已更新,请重新登录');if(form.isConnected)app.openAuth();return;}
if(form.isConnected)dialog.close();
app.notify(mode==='register'?'绑定成功':'登录成功');await app.route();
}).catch(reason=>{if(form.isConnected)error.textContent=reason instanceof Error?reason.message:'请求失败,请重试';})
.finally(()=>{delete form.dataset.submitting;button.disabled=false;button.textContent=label;});
});
dialog.addEventListener('close',()=>{dialog.querySelectorAll<HTMLInputElement>('[data-auth-password]').forEach(input=>input.value='');});
}
export function openAuth(mode='login') {
const switching=mode==='switch';
if(mode==='bind')mode='register';
if(!['login','register','resetPassword'].includes(mode))mode='login';
const dialog=document.getElementById('member-auth-dialog') as HTMLDialogElement;
dialog.classList.toggle('auth-bind-mode',mode==='register');
const menu=document.getElementById('profile-popover');if(menu)menu.hidden=true;
const heading=mode==='register'?'绑定账号':mode==='resetPassword'?'找回密码':switching?'切换账号':'账号登录';
const passwordField=(id:string,name:string,label:string,confirm=false)=>`<label class="auth-field" for="${id}">${label}</label><div class="auth-input-row auth-password-row${mode==='register'?' auth-icon-row':''}">${mode==='register'?'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>':''}<input id="${id}" name="${name}" type="password" data-auth-password autocomplete="${mode==='login'?'current-password':'new-password'}" minlength="${mode==='login'?4:8}" maxlength="64" placeholder="请输入${label}" required><button class="auth-password-toggle" type="button" data-auth-password-toggle="${id}" aria-label="显示${confirm?'确认':''}密码">${passwordEye()}</button></div>`;
dialog.innerHTML=`${closeButton(dialog.id)}<h2 id="member-auth-title">${heading}</h2><p class="auth-subtitle">${mode==='register'?'绑定后可保留当前游客的收藏与观看记录':mode==='resetPassword'?'通过账号和已绑定邮箱找回密码。':switching?'请输入其他账号和密码完成切换。':'登录后同步收藏、观看记录与已购剧集。'}</p><form data-mode="${mode}"><label class="auth-field" for="auth-account">${mode==='login'?'账号或邮箱':'账号'}</label><div class="auth-input-row${mode==='register'?' auth-icon-row':''}">${mode==='register'?'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></svg>':''}<input id="auth-account" name="account" autocomplete="username" required maxlength="${mode==='register'?20:254}" placeholder="${mode==='register'?'6 到 20 位字母或数字':'请输入账号'+(mode==='login'?'或邮箱':'')}"></div>${mode==='resetPassword'?'<label class="auth-field" for="auth-email">绑定邮箱</label><div class="auth-input-row"><input id="auth-email" name="email" type="email" autocomplete="email" required></div>':''}${passwordField('auth-password','password',mode==='resetPassword'?'新密码':'密码')}${mode!=='login'?passwordField('auth-password-confirm','passwordConfirm','确认密码',true):''}${mode==='register'?'<p class="auth-note">密码需为 8–64 位,同时包含字母和数字。</p><label class="auth-bind-agreement"><input name="agree" type="checkbox" required><span>我已年满 18 岁并同意 <a href="/info/user-agreement/">《用户协议》</a> 和 <a href="/info/privacy-policy/">《隐私政策》</a></span></label>':''}<p id="auth-error" class="auth-error" role="alert" aria-live="polite"></p><button class="auth-submit" type="submit">${heading}</button><p class="auth-footer"><button type="button" data-auth-mode="login">登录</button> · <button type="button" data-auth-mode="register">绑定账号</button> · <button type="button" data-auth-mode="resetPassword">找回密码</button></p></form>`;
if(!dialog.open)dialog.showModal();
dialog.querySelector<HTMLInputElement>('#auth-account')?.focus();
}
export function accountMenuMarkup(user:User|null|undefined):string {
const name=user?.nickName||user?.accountNo||'游客';
return `<div class="profile-popover-head"><img ${imageAttributes(user?.avatar,'/icon.svg')} width="46" height="46" alt="${escape(name)}的头像"><div><strong>${escape(name)}</strong><small>${user?'ID '+user.userId:'登录后同步个人数据'}</small></div></div><button data-auth-mode="switch">切换账号</button><button data-auth-mode="register">绑定账号</button><a href="/member/center/">个人中心 ›</a><a href="/member/favorites/">我的收藏 ›</a><a href="/member/likes/">我的点赞 ›</a><a href="/member/history/">观看历史 ›</a><a href="/member/purchased/">已购剧集 ›</a><a href="/info/deposit/">金币 ›</a><a href="/member/settings/">账号设置 ›</a>${user?'<button data-logout>退出登录</button>':''}`;
}
export function settingsMarkup(user:User):string {
const bound=user.isRegister||user.type===1;
return `<div class="member-settings"><a class="member-settings-back" href="/member/center/">‹ 返回个人中心</a>
<section class="member-settings-card"><h2>个人资料</h2><form id="profile-form">
<div class="member-avatar-editor"><button type="button" class="member-avatar-preview" data-avatar-choose aria-label="更换头像"><img id="member-avatar-preview-image" ${imageAttributes(user.avatar,'/icon.svg')} alt="当前头像"><span>编辑头像</span></button><div class="member-avatar-controls"><button type="button" data-avatar-choose>更换头像</button><p>支持 JPG、PNG、WebP,最大 5MB</p><p id="member-avatar-status" role="status"></p></div><input type="file" name="avatarFile" id="member-avatar-file" accept="image/png,image/jpeg,image/webp" hidden></div>
<label for="member-nickname-input">昵称</label><div class="member-nickname-editor"><input id="member-nickname-input" name="nickName" value="${escape(user.nickName)}" maxlength="30" required autocomplete="nickname" aria-describedby="member-nickname-hint"><button type="submit">保存修改</button></div><p id="member-nickname-hint">支持 1–30 个字符</p>
<label class="member-introduction-label" for="member-introduction">个人简介</label><textarea id="member-introduction" name="introduction" maxlength="200">${escape(user.introduction)}</textarea><p class="api-error" role="alert"></p>
</form></section>
<section class="member-settings-card"><h2>账号与安全</h2>
<button type="button" class="member-settings-action" data-auth-mode="register"><span><strong>账号绑定</strong><small>设置账号和密码,方便下次登录</small></span><span class="member-settings-trailing">${bound?'已绑定':'未绑定'} <span aria-hidden="true">›</span></span></button>
<button type="button" class="member-settings-action" data-auth-mode="switch"><span><strong>切换账号</strong><small>使用其他账号登录</small></span><span aria-hidden="true">›</span></button>
<details class="member-security-password"><summary class="member-settings-action"><span><strong>修改密码</strong><small>通过当前密码设置新密码</small></span><span aria-hidden="true">›</span></summary><form id="security-form"><label>旧密码<input name="oldPassword" type="password" autocomplete="current-password" required></label><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="6" maxlength="64" required></label><p class="api-error" role="alert"></p><button class="primary" type="submit">保存密码</button></form></details>
<button type="button" class="member-settings-action" data-logout><span><strong>退出登录</strong><small>退出当前账号</small></span><span aria-hidden="true">›</span></button>
</section></div>`;
}
export function memberMarkup(user:User,tab:string,data:PageData<Drama>={},home:SiteContext['home']={},pageNum=1,pageHref:PageHref=()=>'/member/history/'):string {
const active=tab==='center'?'history':tab;
const libraryPageHref:PageHref=tab==='center'?page=>page>1?`/member/history/page/${page}/`:'/member/history/':pageHref;
const tabs=[['history','观看历史'],['favorites','我的收藏'],['likes','我的点赞'],['purchased','已购剧集']];
const promotions=promotionRow(home?.advitise?.nineGridList);
const header=`${promotions?`<section class="member-advertisements" aria-label="广告推荐"><span class="member-ad-label">广告</span>${promotions}</section>`:''}<section class="member-summary"><div class="member-identity"><img ${imageAttributes(user.avatar,'/icon.svg')} alt="我的头像"><div><div class="member-name-row"><h2>${escape(user.nickName||user.accountNo||'游客')}</h2><a class="member-settings-link" href="/member/settings/">账号设置 <span aria-hidden="true">›</span></a></div><p>ID:${user.userId}</p></div></div><a class="member-wallet" href="/info/deposit/"><span>金币余额 <small>(金币)</small></span><strong><span class="coin-symbol" aria-hidden="true"><img class="coin-soft-star" src="/assets/4532d28d6a61c426.svg" alt="金币图标"></span>${(user.recharge||0).toLocaleString('zh-CN')}</strong><span class="member-wallet-link">金币充值 <span aria-hidden="true">↗</span></span></a></section>`;
const nav='<nav class="member-library-tabs" aria-label="我的短剧">'+tabs.map(([key,label])=>`<a href="/member/${key}/" ${key===active?'aria-current="page"':''}>${key===active?`<h2 id="member-library-title" class="member-library-title">${label}</h2>`:label}</a>`).join('')+'</nav>';
if(tab==='settings')return settingsMarkup(user);
const noRecords=active==='likes'?'<div class="member-library-empty" role="status"><p>暂无点赞的短剧</p><a href="/shorts/">去发现好剧</a></div>':empty(active==='history'?'暂无观看历史':active==='purchased'?'暂无已购剧集':'暂无收藏');
const content=`<section class="member-library" aria-labelledby="member-library-title">${nav}<div class="member-library-toolbar"><span role="status">共 ${data.totalCount||0} 部短剧</span></div><div class="member-library-cards" id="member-library-panel">${recordList<Drama>(data.list).map(item=>`<div class="member-library-item">${card(item)}</div>`).join('')||noRecords}</div>${pagination(data.totalCount||0,pageNum,libraryPageHref)}</section>`;
return `<div class="member-center">${header}${content}</div>`;
}
export async function renderMember(app:SiteContext,tab:string,pageNum:number,pageHref:PageHref,initial?:MemberInitialData,preserveMarkup=false) {
const signal=app.signal;
const user=initial?.user||await app.api.ensureSession();
if(!initial)await app.api.get<Record<string,number>>('user/center/data',{},signal);
if(signal.aborted)return;
const root=document.getElementById('info-content')!;
const active=tab==='center'?'history':tab;
let data=initial?.list;
if(tab!=='settings'&&!data){
const paths:Record<string,string>={favorites:'playlet/collect/list',history:'playlet/history/list',likes:'playlet/like/list',purchased:'playlet/bought/list'};
data=await app.api.get<PageData<Drama>>(paths[active],{pageNum,pageSize:24},signal);
}
if(signal.aborted)return;
if(data)app.updateSeo?.({totalCount:data.totalCount,pageSize:24});
if(!preserveMarkup)root.innerHTML=memberMarkup(user,tab,data,app.home,pageNum,pageHref);
if(tab!=='settings')return;
let previewUrl='';
const clearPreview=()=>{if(previewUrl)URL.revokeObjectURL(previewUrl);previewUrl='';};
signal.addEventListener('abort',clearPreview,{once:true});
const input=root.querySelector<HTMLInputElement>('#member-avatar-file');
const originalAvatar=root.querySelector('#member-avatar-preview-image')?.cloneNode(true);
const restoreAvatar=()=>{if(originalAvatar)root.querySelector('#member-avatar-preview-image')?.replaceWith(originalAvatar.cloneNode(true));};
root.addEventListener('click',event=>{if((event.target as Element).closest('[data-avatar-choose]'))input?.click();},{signal});
input?.addEventListener('change',()=>{
clearPreview();const file=input.files?.[0],status=root.querySelector('#member-avatar-status');
if(status)status.textContent='';
if(!file){restoreAvatar();return;}
if(file.size>5*1024*1024||!['image/png','image/jpeg','image/webp'].includes(file.type)){input.value='';restoreAvatar();if(status)status.textContent='请上传 5MB 以内的 PNG、JPEG 或 WebP 图片';return;}
previewUrl=URL.createObjectURL(file);const preview=root.querySelector<HTMLImageElement>('#member-avatar-preview-image');if(preview){const replacement=document.createElement('img');replacement.id=preview.id;replacement.alt='新头像预览';replacement.src=previewUrl;preview.replaceWith(replacement);}
if(status)status.textContent='点击“保存修改”保存新头像';
},{signal});
const bindForm=(selector:string,password=false)=>root.querySelector(selector)?.addEventListener('submit',event=>{
event.preventDefault();const form=event.target as HTMLFormElement;if(!form.reportValidity()||form.dataset.submitting)return;
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;button.disabled=true;
form.dataset.submitting='1';form.querySelector('.api-error')!.textContent='';
void (async()=>{
const data=new FormData(form);const payload:Record<string,unknown>=password?{oldPassword:data.get('oldPassword'),newPassword:data.get('newPassword')}:{nickName:data.get('nickName'),introduction:data.get('introduction')};
const file=data.get('avatarFile');
if(file instanceof File&&file.size){
if(file.size>5*1024*1024||!['image/png','image/jpeg','image/webp'].includes(file.type))throw new Error('请上传 5MB 以内的 PNG、JPEG 或 WebP 图片');
const upload=new FormData();upload.append('file',file);upload.append('watermark','0');
const result=await app.api.post<{sourceNameFs:string}>('upload/image',upload);if(!result.sourceNameFs)throw new Error('头像上传失败,请重试');payload.avatar=result.sourceNameFs;
}
if(signal.aborted)return;
await app.api.post('auth/profile',payload);await app.api.refreshUser();if(!signal.aborted){app.notify(password?'密码已更新':'资料已保存');clearPreview();if(!password)void app.route();}
form.querySelectorAll<HTMLInputElement>('input[type="password"]').forEach(input=>input.value='');
})().catch(error=>{if(!signal.aborted)form.querySelector('.api-error')!.textContent=error.message;}).finally(()=>{button.disabled=false;delete form.dataset.submitting;});
},{signal});
bindForm('#profile-form');bindForm('#security-form',true);
}
function paymentMethods(item:Package) {
return recordList<{type:string;typeName:string}>(item.payMethods).filter((method,index,methods)=>typeof method.type==='string'&&method.type.trim()&&methods.findIndex(other=>other.type===method.type)===index);
}
const count=(value:unknown):value is number=>typeof value==='number'&&Number.isFinite(value)&&value>=0;
const packagePrice=(item:Package)=>(item.price/100).toFixed(2).replace(/\.00$/,'');
function packageCoins(item:Package) {
const gift=count(item.giftCoin)?item.giftCoin:0;
const base=count(item.baseCoin)?item.baseCoin:count(item.totalCoin)&&item.totalCoin>=gift?item.totalCoin-gift:undefined;
const total=count(item.totalCoin)?item.totalCoin:base===undefined?undefined:base+gift;
return {base,gift,total};
}
export function paymentDialogMarkup(item:Package,vip:boolean):string {
const methods=paymentMethods(item);
const {base,gift,total}=packageCoins(item);
const number=(value:number)=>String(value);
const logos:Record<string,string>={alipay:'5652fcead17188e8',wechat:'2bead9ea5c435c98',usdt:'7848816d05aa8591'};
const logo=(method:{type:string;typeName:string})=>{
const type=method.type.toLowerCase();
const key=type.includes('alipay')?'alipay':/wechat|wxpay|weixin/.test(type)?'wechat':type.includes('usdt')?'usdt':'';
return key?`<img class="wallet-pay-logo" src="/assets/${logos[key]}.svg" width="36" height="36" alt="${escape(({alipay:"支付宝",wechat:"微信",usdt:"USDT"})[key])}支付图标" aria-hidden="true">`:'<span class="wallet-pay-logo wallet-pay-logo--other" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M3 10h18M7 15h3"/></svg></span>';
};
return `<button type="button" class="wallet-dialog-close" data-close="wallet-payment-dialog" aria-label="关闭支付弹窗"><svg class="dialog-close-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
<div class="wallet-payment-heading"><h2 id="wallet-payment-title" class="wallet-payment-title">确认支付</h2><span>${vip?'会员畅享':'金币充值'}</span></div>
<div class="wallet-order"><div class="wallet-order-copy"><strong>${escape(!vip&&base!==undefined?number(base)+' 金币':item.name)}</strong><span>${escape(item.subTitle||(vip?'支付成功后开通会员':'支付成功后即时到账'))}</span>${gift>0?`<small>赠送 ${number(gift)} 金币${total!==undefined?' · 合计 '+number(total)+' 金币':''}</small>`:''}</div><b>¥${packagePrice(item)}</b></div>
<form id="payment-form"><h3 class="wallet-method-title">选择支付方式</h3><input type="hidden" name="payMode" value="${escape(methods[0]?.type||'')}">
<div class="wallet-methods" role="group" aria-label="支付方式">${methods.map((method,index)=>`<button type="button" data-wallet-method="${escape(method.type)}" aria-pressed="${index===0}">${logo(method)}<span class="wallet-method-name">${escape(method.typeName||method.type)}</span><span class="wallet-method-check" aria-hidden="true">${index===0?'✓':''}</span></button>`).join('')||'<p class="wallet-payment-status" role="status">当前套餐暂无可用支付方式</p>'}</div>
<button class="wallet-submit wallet-payment-go" type="submit" ${methods.length?'':'disabled'}>去支付</button><p class="wallet-payment-note">点击支付即表示已确认订单金额与支付方式</p><p class="api-error wallet-payment-status" role="alert" aria-live="polite"></p>
</form>`;
}
export function walletMarkup(user:User|null,packages:WalletInitialData['packages'],transactions:WalletInitialData['transactions'],vip:boolean,pageNum:number,pageHref:PageHref):string {
const items=recordList<Package>(vip?packages.vipList:packages.coinList).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&count(item.price));
let selected=items[0];
const title=(item:Package)=>!vip&&packageCoins(item).base!==undefined?packageCoins(item).base+'金币':item.name;
const selectionText=(item:Package)=>`已选择 ${title(item)}${packageCoins(item).gift>0?' + 赠送'+packageCoins(item).gift+'金币':''} · 点击后选择支付方式`;
const coin=(className:string)=>`<span class="coin-symbol ${className}" aria-hidden="true"><img class="coin-soft-star" src="/assets/4532d28d6a61c426.svg" alt="金币图标" aria-hidden="true"></span>`;
const balance=count(user?.recharge)?user!.recharge.toLocaleString('zh-CN'):'0';
const transactionRows=recordList<Record<string,unknown>>(transactions.list).map(row=>{
const numeric=(value:unknown)=>typeof value==='number'&&Number.isFinite(value)?value:undefined;
const change=numeric(row.changeAmount);
const amount=change!==undefined&&change!==0?change:numeric(row.amount)??numeric(row.coin)??numeric(row.changeCoin)??change??0;
const expense=Number(row.flowDir)===-1||amount<0;
const name=row.title||row.tranTypeName||row.name||row.remark||row.typeName||'金币变动';
const time=String(row.createdAt??row.time??'');
const date=new Date(time);
const displayTime=time.replace(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}:\d{2}).*$/,'$1/$2/$3 $4');
return `<div class="wallet-transaction-row"><div><strong>${escape(name)}</strong>${time?`<time${Number.isFinite(date.getTime())?` datetime="${date.toISOString()}"`:''}>${escape(displayTime)}</time>`:''}</div><span class="wallet-transaction-amount ${expense?'expense':'income'}">${amount===0?'0':(expense?'−':'+')+Math.abs(amount).toLocaleString('zh-CN')}</span></div>`;
}).join('');
return `<div class="wallet-page">
<section class="wallet-balance-card" aria-label="金币余额与用途"><div class="wallet-balance-summary">${coin('wallet-balance-coin')}<div class="wallet-balance-copy"><span>当前余额</span><div class="wallet-balance-amount"><strong>${balance}</strong><b>金币</b></div><p>按剧集原定价按集解锁,解锁后永久有效</p></div></div>
<div class="wallet-usage"><h2>金币怎么用</h2>
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4"/></svg><div><strong>解锁单集内容</strong><p>按剧集标定价消耗金币</p></div></div>
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m13 2-9 12h7l-1 8 10-13h-8l1-7Z"/></svg><div><strong>奖励金币优先使用</strong><p>解锁剧集先自动优先扣除任务获得的奖励金币</p></div></div>
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="6"/><path d="m8 13-1 9 5-3 5 3-1-9"/></svg><div><strong>已解锁剧集不再扣费</strong><p>已解锁的剧集可随时回看,不会重复消耗金币</p></div></div>
</div></section>
${vip?'<nav class="channel-tabs" aria-label="充值分类"><a href="/info/deposit/">金币充值</a><a href="/info/deposit/vip/" aria-current="page">会员畅享</a><a href="/member/purchased/">已购剧集</a></nav>':''}
<h2 class="wallet-section-title">购买套餐</h2><section class="wallet-pack-panel" aria-label="购买套餐"><div class="wallet-pack-heading"><strong>${vip?'请选择会员套餐':'请选择充值金额'}</strong><span>${vip?'支付成功后开通会员':'按集解锁,自由使用'}</span></div>
<div class="wallet-pack-grid" role="group" aria-label="${vip?'会员':'金币'}套餐">${items.map((item,index)=>{
const {gift}=packageCoins(item);
const bonus=gift>0?`赠送 ${gift}金币`:item.subTitle||(vip&&item.durationDays?`${item.durationDays}天会员`: '');
return `<button type="button" class="wallet-package wallet-mobile-package" data-buy-package="${item.id}" aria-pressed="${item===selected}" aria-label="${escape(title(item)+(bonus?','+bonus:'')+',人民币'+packagePrice(item)+'元')}">${coin('wallet-package-coin')}<span class="wallet-mobile-coins">${escape(title(item))}</span><span class="wallet-mobile-bonus">${escape(bonus)}</span><span class="wallet-mobile-price"><small>¥</small><strong>${packagePrice(item)}</strong></span>${index===0?'<span class="wallet-double-badge"><img src="/assets/0f0ef061135a13f0.svg" width="37.0911" height="37.0911" alt="推荐套餐标识" aria-hidden="true"><span>推荐</span></span>':''}<img class="wallet-mobile-selected" src="/assets/2fdedce06a90a6a1.svg" width="24" height="24" alt="套餐选中标识" aria-hidden="true"></button>`;
}).join('')||`<div class="wallet-pack-empty">${empty('暂无可购买套餐')}</div>`}</div></section>
<div class="wallet-pay-area"><button type="button" class="wallet-submit" data-wallet-action="pay" ${selected?'':'disabled'}>${selected?'立即支付 ¥'+packagePrice(selected):'暂无可购买套餐'}</button><p id="wallet-selection" role="status">${selected?escape(selectionText(selected)):''}</p></div>
<section class="wallet-transactions"><h2 class="wallet-section-title">交易明细</h2><div class="wallet-transaction-list" aria-label="金币交易记录">${transactionRows||empty('error' in transactions?String(transactions.error):'暂无交易明细')}</div>${pagination(transactions.totalCount||0,pageNum,pageHref,10)}</section>
<p class="wallet-support">如遇充值问题请咨询 <a href="/info/feedback/">在线客服</a></p></div>`;
}
export async function renderWallet(app:SiteContext,vip:boolean,pageNum:number,pageHref:PageHref,initial?:WalletInitialData,preserveMarkup=false) {
const signal=app.signal;
const [packages,transactions]=initial?[initial.packages,initial.transactions]:await Promise.all([
app.api.get<{coinList?:Package[];vipList?:Package[]}>('recharge/package/list',{},signal),
app.api.get<PageData<Record<string,unknown>>>('coin/trans/list',{page:pageNum,pageSize:10,type:0},signal).catch(()=>({list:[],totalCount:0,error:'余额明细暂不可用,请稍后刷新'})),
]);
if(signal.aborted)return;
const items=recordList<Package>(vip?packages.vipList:packages.coinList).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&count(item.price));
app.updateSeo?.({totalCount:transactions.totalCount,pageSize:10});
let selected=items[0];
const title=(item:Package)=>!vip&&packageCoins(item).base!==undefined?packageCoins(item).base+'金币':item.name;
const selectionText=(item:Package)=>`已选择 ${title(item)}${packageCoins(item).gift>0?' + 赠送'+packageCoins(item).gift+'金币':''} · 点击后选择支付方式`;
const root=document.getElementById('info-content')!;
if(!preserveMarkup)root.innerHTML=walletMarkup(app.api.user,packages,transactions,vip,pageNum,pageHref);
const openPayment=(item:Package)=>{
const dialog=document.getElementById('wallet-payment-dialog') as HTMLDialogElement;
const methods=paymentMethods(item);
dialog.innerHTML=paymentDialogMarkup(item,vip);
if(!dialog.open)dialog.showModal();
const paymentForm=dialog.querySelector<HTMLFormElement>('form')!;
const choices=Array.from(paymentForm.querySelectorAll<HTMLButtonElement>('[data-wallet-method]'));
paymentForm.addEventListener('click',choiceEvent=>{
const choice=(choiceEvent.target as Element).closest<HTMLButtonElement>('[data-wallet-method]');
if(!choice||choice.disabled||!methods.some(method=>method.type===choice.dataset.walletMethod))return;
paymentForm.querySelector<HTMLInputElement>('[name="payMode"]')!.value=choice.dataset.walletMethod!;
choices.forEach(button=>{const selected=button===choice;button.setAttribute('aria-pressed',String(selected));button.querySelector('.wallet-method-check')!.textContent=selected?'✓':'';});
paymentForm.querySelector('.api-error')!.textContent='';
},{signal});
paymentForm.addEventListener('submit',submit=>{
submit.preventDefault();const form=submit.target as HTMLFormElement;if(!form.reportValidity()||form.dataset.submitting)return;
const mode=new FormData(form).get('payMode');if(!methods.some(method=>method.type===mode))return;
const confirm=form.querySelector<HTMLButtonElement>('[type="submit"]')!;confirm.disabled=true;form.dataset.submitting='1';choices.forEach(choice=>choice.disabled=true);
// Reserve a tab during the user gesture so an async order response is not popup-blocked.
const paymentWindow=window.open('about:blank','_blank');
if(paymentWindow){
paymentWindow.opener=null;
paymentWindow.document.title='正在创建支付订单';
paymentWindow.document.body.textContent='正在创建支付订单,请稍候…';
}
const closePending=()=>{if(paymentWindow&&!paymentWindow.closed)paymentWindow.close();};
signal.addEventListener('abort',closePending,{once:true});
void app.api.post<{payUrl?:string;tradeNo?:string}>(vip?'vip/pay':'coin/buy',{packageId:item.id,payMode:mode}).then(result=>{
if(signal.aborted||!form.isConnected){closePending();return;}
const url=safeUrl(result.payUrl);if(!url)throw new Error('支付服务未返回有效地址,请稍后重试');
form.innerHTML=`<p class="wallet-payment-status" role="status">订单 ${escape(result.tradeNo)} 已创建,请在支付页面完成付款。</p><a class="primary wallet-payment-link" href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">打开支付页面</a><button type="button" class="wallet-submit wallet-payment-go" data-payment-refresh>我已完成支付,刷新余额</button>`;
form.querySelector('[data-payment-refresh]')!.addEventListener('click',()=>{void app.api.refreshUser().then(()=>{dialog.close();app.notify('余额已刷新');void app.route();}).catch(error=>app.notify(error.message));},{signal});
signal.removeEventListener('abort',closePending);
if(paymentWindow&&!paymentWindow.closed)paymentWindow.location.replace(url);
else window.location.assign(url);
}).catch(error=>{closePending();if(signal.aborted||!form.isConnected)return;form.querySelector('.api-error')!.textContent=error.message;confirm.disabled=false;delete form.dataset.submitting;choices.forEach(choice=>choice.disabled=false);})
.finally(()=>signal.removeEventListener('abort',closePending));
},{signal});
};
root.addEventListener('click',event=>{
const button=(event.target as Element).closest<HTMLElement>('[data-buy-package],[data-wallet-action]');if(!button)return;
if(button.dataset.buyPackage){
const item=items.find(item=>item.id===Number(button.dataset.buyPackage));if(!item)return;
selected=item;
root.querySelectorAll<HTMLElement>('[data-buy-package]').forEach(card=>card.setAttribute('aria-pressed',String(Number(card.dataset.buyPackage)===item.id)));
const pay=root.querySelector<HTMLButtonElement>('[data-wallet-action="pay"]');if(pay)pay.textContent='立即支付 ¥'+packagePrice(item);
const status=root.querySelector('#wallet-selection');if(status)status.textContent=selectionText(item);
}else if(button.dataset.walletAction==='pay'&&selected){openPayment(selected);}
},{signal});
}
-270
View File
@@ -1,270 +0,0 @@
import type { Chapter, Config, DramaDetail, User } from '../api/types';
import { base64JSON, COMMON_KEYS, dramaContext, SDK_COMMON_KEYS, SDK_FIELD_KEYS, SERVER_EVENTS, TRACKER_APP_ID, trackingPage, trackingText, type Fields } from '../tracking';
import { authMetadata } from './auth';
interface WebSDK {
init(options:Record<string,unknown>):unknown;
track:Record<string,(payload:Fields)=>unknown>;
getCommonFields(keys?:string[]):Promise<Fields>;
setUid(uid:string):unknown;
setChannel(channel:string):unknown;
notifyPageLoad():void;
flush():Promise<unknown>;
}
declare global {interface Window {WebSDK?:WebSDK;tracker?:SiteTracker}}
const number=(value:unknown)=>Math.max(0,Math.round(Number(value)||0));
const clamp=(value:number)=>Math.min(100,Math.max(0,Math.round(value)));
const channelValue=(value:string)=>/^[a-z\d_.-]{1,50}$/i.test(value)&&!['self','natural','official','channel','0','-','unknown'].includes(value.toLowerCase())?value:'organic';
const uidValue=(user:User|null)=>{const uid=trackingText(user?.userId);return /^[a-z\d_]{4,32}$/i.test(uid)?uid:'';};
const waitLimit=<T>(promise:Promise<T>,fallback:T,ms=1500)=>new Promise<T>(resolve=>{
const timer=setTimeout(()=>resolve(fallback),ms);
promise.then(value=>{clearTimeout(timer);resolve(value);},()=>{clearTimeout(timer);resolve(fallback);});
});
/** SDK adapter: named v1.2.0 events only; business requests own server-side mutations. */
export class SiteTracker {
private sdkReady:Promise<void>;
private config:Config={};
private initialized=false;
private initializing=false;
private queue:{method:string;payload:Fields}[]=[];
private user:User|null=null;
private path='';
private page=trackingPage(location.pathname);
private referrer={key:'direct',name:'直接访问'};
private shown=new Set<string>();
private adShown=new Set<string>();
private popupClosed=new Set<string>();
private observed=new WeakSet<Element>();
private observer?:IntersectionObserver;
private diagnostics=new Set<string>();
private drama?:Fields;
private chapters:Chapter[]=[];
private isVideo=false;
private playback=new WeakMap<HTMLVideoElement,{started:number;elapsed:number;position:number;seekFrom?:number;failed?:boolean}>();
constructor(){
try{if(document.referrer){const url=new URL(document.referrer);this.referrer=url.origin===location.origin?trackingPage(url.pathname):{key:'external',name:url.host};}}catch{}
this.sdkReady=this.loadSDK().catch(()=>{});
window.addEventListener('site:route',()=>this.pageView());
window.addEventListener('pagehide',()=>{this.reportProgress();void this.flush();});
document.addEventListener('visibilitychange',()=>{if(document.hidden){this.reportProgress();void this.flush();}else this.observe();});
document.addEventListener('click',event=>this.click(event),true);
document.addEventListener('external-ad:track',event=>{const {element,eventType}=(event as CustomEvent).detail||{};this.externalAd(element,eventType);});
document.addEventListener('dialog-ad:track',event=>{const {element,eventType}=(event as CustomEvent).detail||{};this.advertising(element,eventType);});
document.addEventListener('tracker:refresh',()=>this.observe());
document.addEventListener('close',()=>this.observe(),true);
for(const event of ['play','playing','pause','waiting','timeupdate','seeking','seeked','ended'])document.addEventListener(event,event=>this.videoEvent(event),true);
if(typeof IntersectionObserver!=='undefined')this.observer=new IntersectionObserver(entries=>{
for(const entry of entries)if(entry.isIntersecting){
this.observer?.unobserve(entry.target);
if(this.visible(entry.target))this.impression(entry.target as HTMLElement);else this.observed.delete(entry.target);
}
},{threshold:0.1});
if(typeof MutationObserver!=='undefined')new MutationObserver(()=>this.observe()).observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['hidden','open','inert','aria-hidden']});
this.pageView();this.observe();
}
private loadSDK():Promise<void>{
if(window.WebSDK)return Promise.resolve();
return new Promise((resolve,reject)=>{
const script=document.createElement('script');script.src='/scripts/web-sdk-v1.2.0.js';script.async=true;
script.onload=()=>window.WebSDK?resolve():reject(new Error('sdk_unavailable'));
script.onerror=()=>{script.remove();reject(new Error('sdk_load_failed'));};
document.head.append(script);
});
}
private diagnose(method:string,result:unknown){
const value=result&&typeof result==='object'?result as Record<string,unknown>:{};
const reason=trackingText(value.reason)||'sdk_exception',key=method+'|'+reason;
if(this.diagnostics.has(key))return;
this.diagnostics.add(key);
const detail={method,reason,...(Array.isArray(value.missingFields)?{missingFields:value.missingFields}:{}),...(Array.isArray(value.typeErrorFields)?{typeErrorFields:value.typeErrorFields}:{})};
try{document.dispatchEvent(new CustomEvent('tracker:diagnostic',{detail}));}catch{}
}
configure(config:Config){
this.config=config;
if(this.initialized||this.initializing)return;
const encryptedConfig=trackingText(config.reportSdkConfig)||process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG||'';
if(!encryptedConfig)return;
this.initializing=true;
void this.sdkReady.then(async()=>{
if(!window.WebSDK){this.sdkReady=this.loadSDK();await this.sdkReady;}
if(!window.WebSDK)throw new Error('sdk_unavailable');
await window.WebSDK.init({appId:TRACKER_APP_ID,uid:uidValue(this.user),channel:channelValue(authMetadata().channel),encryptedConfig,appVersion:'1.0.0',disableAutoEvents:['app_page_view','page_click']});
this.initialized=true;
this.syncUser(this.user);
for(const event of this.queue.splice(0))this.invoke(event.method,event.payload);
}).catch(()=>this.diagnose('init',{reason:'sdk_initialization_failed'})).finally(()=>{this.initializing=false;});
}
private invoke(method:string,payload:Fields){
if(!this.initialized){
if(this.queue.length>=1000){this.diagnose(method,{reason:'pending_queue_full'});return;}
this.queue.push({method,payload:{...payload}});return;
}
try{
const track=window.WebSDK?.track[method];
if(typeof track!=='function'){this.diagnose(method,{reason:'unknown_event_method'});return;}
void Promise.resolve(track(payload)).then(result=>{
if(result&&typeof result==='object'&&'ok' in result&&result.ok===false)this.diagnose(method,result);
}).catch(()=>this.diagnose(method,{reason:'sdk_exception'}));
}catch{this.diagnose(method,{reason:'sdk_exception'});}
}
syncUser(user:User|null){
this.user=user;
try{if(this.initialized)window.WebSDK?.setUid(uidValue(user));}catch{}
}
pageView(){
const path=location.pathname+location.search;if(path===this.path)return;
if(this.path){this.reportProgress();this.referrer=this.page;try{window.WebSDK?.notifyPageLoad();}catch{}}
this.path=path;this.page=trackingPage(location.pathname);this.drama=undefined;this.chapters=[];
this.shown.clear();this.observed=new WeakSet();this.observer?.disconnect();
this.invoke('appPageView',{...this.pageFields(),user_type:this.user?.vipType?'vip':'normal',referrer_page_key:this.referrer.key,referrer_page_name:this.referrer.name,current_page_key:this.page.key,current_page_name:this.page.name,page_load_time:number(performance.getEntriesByType?.('navigation')[0]?.duration),recommend_trace_id:''});
this.observe();
}
resumePage(){
try{window.WebSDK?.notifyPageLoad();}catch{}
this.path='';this.pageView();
}
private pageFields():Fields{return {page_key:this.page.key,page_name:this.page.name};}
private visible(node:Element){
if(document.hidden||node.closest('[hidden],[inert],[aria-hidden="true"],dialog:not([open])')||!node.getClientRects().length)return false;
const modal=document.querySelector('dialog:modal');
return !modal||modal.contains(node);
}
private observe(){
document.querySelectorAll<HTMLElement>('[data-track-ad="external"],[data-track-ad="external-dialog"],[data-track-item]').forEach(node=>{
if(!this.visible(node)){
if(this.observed.has(node)){this.observer?.unobserve(node);this.observed.delete(node);}
return;
}
if(this.observed.has(node))return;
this.observed.add(node);if(this.observer)this.observer.observe(node);else this.impression(node);
});
}
private item(node:HTMLElement):Fields {
return {...this.pageFields(),item_id:node.dataset.itemId||'',item_type:node.dataset.itemType||'short_drama',item_name:node.dataset.itemName||'',parent_item_id:node.dataset.parentItemId||'0',recommend_trace_id:node.dataset.recommendTraceId||'',recommend_id:node.dataset.recommendId||'',recommend_trace_info:node.dataset.recommendTraceInfo||''};
}
private impression(node:HTMLElement){
if(node.hasAttribute('data-ad-clone'))return;
if(node.hasAttribute('data-track-ad')){this.externalAd(node,'show');return;}
const key='item|'+node.dataset.itemType+'|'+node.dataset.itemId+'|'+(node.dataset.parentItemId||'');
if(!node.dataset.itemId||this.shown.has(key))return;
this.shown.add(key);this.invoke('appPageitemExposure',{...this.item(node),referrer_page_key:this.referrer.key,referrer_page_name:this.referrer.name});
}
externalAd(node:HTMLElement,eventType:string){
if(!node||!['show','click'].includes(eventType)||!node.dataset.adId)return;
if(eventType==='show'&&!this.visible(node)){this.observed.delete(node);return;}
const payload={...this.pageFields(),ad_slot_key:node.dataset.adSlotKey||this.page.key+'_ad',ad_slot_name:node.dataset.adSlotName||this.page.name+'广告位',ad_id:node.dataset.adId,creative_id:node.dataset.creativeId||'',ad_type:node.dataset.adType||'banner'};
const key=this.adKey(node);
if(!this.adShown.has(key)){this.adShown.add(key);this.invoke('adImpression',payload);this.advertising(node,'show');}
if(eventType==='click'&&node.dataset.adClickable==='1'){this.invoke('adClick',payload);this.advertising(node,'click');}
}
private adKey(node:HTMLElement){return this.path+'|'+(node.dataset.adPlacement||'inline')+'|'+(node.dataset.adSlotKey||this.page.key+'_ad')+'|'+node.dataset.adId;}
advertising(node:HTMLElement,eventType:string){
if(!node?.dataset.adId||!['startup-ad','startup-grid'].includes(node.dataset.adPlacement||'')||!['show','click','close'].includes(eventType))return;
const key=this.adKey(node);
if(eventType==='close'){
if(!this.adShown.has(key)||this.popupClosed.has(key))return;
this.popupClosed.add(key);
}else if(eventType==='show')this.popupClosed.delete(key);
this.invoke('advertising',{event_type:eventType,advertising_key:node.dataset.adPlacement==='startup-grid'?'grid_ad':'dialog_ad',advertising_name:node.dataset.adSlotName||'弹窗广告',advertising_id:node.dataset.adId});
}
search(keyword:string,total:number,page:number){if(keyword&&page===1)this.invoke('keywordSearch',{keyword,search_result_count:total,search_content_type:'',search_trace_id:'',search_id:''});}
private click(event:MouseEvent){
const target=event.target instanceof Element?event.target:null;if(!target)return;
if(!target.closest('[data-close],.startup-close'))this.invoke('pageClick',{...this.pageFields(),click_page_x:number(event.clientX),click_page_y:number(event.clientY),click_x_percent:clamp(event.clientX/(innerWidth||1)*100),click_y_percent:clamp(event.clientY/(innerHeight||1)*100),screen_width:number(screen.width),screen_height:number(screen.height),recommend_trace_id:''});
const nav=target.closest<HTMLAnchorElement>('a[data-page],a[data-hg-page],a[data-mobile-route],a[data-info],a[data-track-navigation],a[data-app-download],.entry-routes a,.entry-channels a');
if(nav&&nav.hasAttribute('href'))this.invoke('navigation',{navigation_key:'nav_'+(nav.dataset.trackNavigation||nav.dataset.page||nav.dataset.hgPage||nav.dataset.mobileRoute||nav.dataset.info||(nav.hasAttribute('data-app-download')?'app_download':trackingPage(new URL(nav.href,location.href).pathname).key)).replace(/-/g,'_'),navigation_name:(nav.getAttribute('aria-label')||nav.textContent||'导航').trim().slice(0,255)});
const ad=target.closest<HTMLElement>('[data-track-ad="external"]');if(ad)this.externalAd(ad,'click');
const switcher=target.closest<HTMLElement>('[data-drama-switch]');
if(switcher)this.dramaStatusChange({id:Number(switcher.dataset.targetVideoId),currentEpisode:Number(switcher.dataset.targetEpisode)});
const item=target.closest<HTMLElement>('[data-track-item]');
if(item){
const items=Array.from(item.closest('.cards,.topic-recommendation-grid,.benefit-grid,.hero')?.querySelectorAll('[data-track-item]')||[]),position=items.indexOf(item)+1;
this.invoke('appPageitemClick',{...this.item(item),position:position||1});
const results=item.closest<HTMLElement>('[data-search-results]');
if(results)this.invoke('keywordClick',{keyword:results.dataset.searchKeyword||'',click_item_id:item.dataset.itemId||'',click_item_type_key:'short_drama',click_item_type_name:'短剧',click_position:(number(results.dataset.searchPage)-1)*number(results.dataset.searchPageSize)+(position||1),search_trace_id:''});
}
}
setDrama(drama:DramaDetail,chapter:Chapter|null|undefined,chapters:Chapter[],isVideo=false){
this.drama=dramaContext(drama,chapter);this.chapters=chapters;this.isVideo=isVideo;
if(isVideo)this.drama.video_id=String(drama.id);
if(this.drama.video_id)this.dramaEvent('ep_view');
}
private video(){return document.querySelector<HTMLVideoElement>('#drama-play-page:not([hidden]) .play-screen video');}
private main(video:HTMLVideoElement){return video===this.video()&&video.closest<HTMLElement>('[data-play-mode]')?.dataset.playMode!=='preview';}
private state(video:HTMLVideoElement){let state=this.playback.get(video);if(!state){state={started:0,elapsed:0,position:0};this.playback.set(video,state);}return state;}
private settle(video:HTMLVideoElement){const s=this.state(video);if(s.started){s.elapsed+=Math.max(0,performance.now()-s.started);s.started=0;}}
dramaEvent(behavior:string,video?:HTMLVideoElement,options:{forceProgress?:number;keepDuration?:boolean}={}){
if(!this.drama?.video_id)return;
const s=video?this.state(video):undefined;
const duration=number(video?.duration&&Number.isFinite(video.duration)?video.duration:this.drama.drama_duration);
const elapsed=s?s.elapsed+(s.started?Math.max(0,performance.now()-s.started):0):0;
const names:Record<string,string>={ep_view:'展示',ep_play:'播放',ep_pause:'暂停',ep_forward:'快进',ep_rewind:'快退',ep_complete:'播放完成',ep_share:'分享',ep_failed:'播放失败'};
const metrics={play_duration:number(elapsed/1000),play_progress:options.forceProgress!==undefined?clamp(options.forceProgress):behavior==='ep_complete'?100:duration?clamp((video?.currentTime||0)/duration*100):0,play_speed:number((video?.playbackRate||1)*100)};
if(this.isVideo){const d=this.drama;this.invoke('videoEvent',{...metrics,media_id:d.media_id,video_id:d.video_id,video_title:d.drama_title,video_type_id:d.drama_type_id,video_type_name:d.drama_type_name,video_content_type:'video',recommend_trace_id:d.recommend_trace_id,video_tag_key:d.drama_tag_key,video_tag_name:d.drama_tag_name,video_duration:duration,video_behavior_key:behavior.replace('ep_','video_'),video_behavior_name:behavior==='ep_view'?'视频展示':names[behavior]||behavior});}
else this.invoke('dramaEvent',{...this.drama,...metrics,drama_duration:duration,play_behavior_key:behavior,play_behavior_name:names[behavior]||behavior});
if(s&&!options.keepDuration){s.elapsed=0;if(s.started)s.started=performance.now();}
}
private videoEvent(event:Event){
const video=event.target;if(!(video instanceof HTMLVideoElement)||!this.main(video))return;
const s=this.state(video);
if(event.type==='play')this.dramaEvent('ep_play',video);
if(event.type==='playing'){s.failed=false;if(!s.started)s.started=performance.now();}
if(event.type==='waiting')this.settle(video);
if(event.type==='pause'){const played=s.started||s.elapsed;this.settle(video);if(played&&!video.ended&&!video.seeking)this.dramaEvent('ep_pause',video);}
if(event.type==='timeupdate'&&!video.seeking)s.position=video.currentTime;
if(event.type==='seeking'){this.settle(video);s.seekFrom??=s.position;}
if(event.type==='seeked'&&s.seekFrom!==undefined){const from=s.seekFrom;s.seekFrom=undefined;if(Math.abs(video.currentTime-from)>=0.5)this.dramaEvent(video.currentTime>from?'ep_forward':'ep_rewind',video);s.position=video.currentTime;if(!video.paused&&!video.ended)s.started=performance.now();}
if(event.type==='ended'){
this.settle(video);this.dramaEvent('ep_complete',video,{keepDuration:!this.isVideo});
if(!this.isVideo)this.dramaEvent('ep_play',video,{forceProgress:100});
}
}
playbackFailed(video:HTMLVideoElement){
if(!this.main(video)||!this.drama?.video_id)return;
const state=this.state(video);if(state.failed)return;
state.failed=true;this.settle(video);this.dramaEvent('ep_failed',video);
}
prepareDramaModeChange(){const video=this.video();if(video){this.reportProgress();this.playback.delete(video);}}
private reportProgress(){const video=this.video();if(video&&this.main(video)){const s=this.state(video);if(s.started||s.elapsed){this.settle(video);this.dramaEvent('ep_play',video);if(!video.paused&&!video.ended)s.started=performance.now();}}}
dramaStatusChange(chapter:Chapter,behavior?:string){
if(!this.drama||this.isVideo)return;
const episode=Math.max(1,chapter.currentEpisode||1),previous=Number(this.drama.episode);
const key=behavior||(episode===previous+1?'ep_next':episode===previous-1?'ep_previous':'ep_switch');
const names:Record<string,string>={ep_previous:'上一集',ep_next:'下一集',ep_switch:'切换剧集',ep_unlock:'解锁',ep_auto_play:'自动播放'};
if(!names[key]||(!behavior&&String(chapter.id)===this.drama.video_id))return;
this.invoke('dramaStatusChange',{media_id:this.drama.media_id,drama_id:this.drama.drama_id,drama_title:this.drama.drama_title,episode,video_id:String(chapter.id),pre_episode:previous,drama_behavior_key:key,drama_behavior_name:names[key]});
}
async getCommonFields(){await this.sdkReady;try{return await window.WebSDK?.getCommonFields()||{};}catch{return {};}}
async requestHeaders(path:string,method:string,body:unknown):Promise<Record<string,string>>{
// This request supplies the SDK configuration; it must not wait for that SDK to load first.
if(path==='config/info'&&!this.initialized)return {};
const input=body&&typeof body==='object'&&!(body instanceof FormData)?body as Record<string,unknown>:{};
const snapshot=this.drama?{...this.drama}:undefined,chapters=this.chapters.slice(),isVideo=this.isVideo;
return waitLimit((async()=>{
const fields=await this.getCommonFields(),headers:Record<string,string>={};
if(!SDK_COMMON_KEYS.some(key=>trackingText(fields[key])))return headers;
headers['X-SDK-Common']=base64JSON(Object.fromEntries(SDK_FIELD_KEYS.filter(key=>Object.hasOwn(fields,key)).map(key=>[key,fields[key]])));
const event=method==='POST'?SERVER_EVENTS[path]:'';
if(!event||fields.app_id!==TRACKER_APP_ID||!fields.device_id||path==='comment/send'&&![10,11].includes(Number(input.objectType)))return headers;
const chapterId=Number(input.chapterId||(Number(input.objectType)===11?input.objectId:0));
const id=trackingText(input.playletId||input.id||(Number(input.objectType)===10?input.objectId:0)||(!isVideo?snapshot?.drama_id:''));
if(!id||id==='0')return headers;
const same=id===snapshot?.drama_id&&!isVideo,chapter=same?chapters.find(ch=>ch.id===chapterId):undefined;
const drama={media_id:same?snapshot?.media_id||'':'',drama_id:id,episode:chapter?.currentEpisode||(same?number(snapshot?.episode):1)||1,video_id:chapterId?String(chapterId):same?snapshot?.video_id||'':'',recommend_trace_id:same?snapshot?.recommend_trace_id||'':''};
const requestId=crypto.randomUUID().replace(/-/g,'');
const common=Object.fromEntries(COMMON_KEYS.map(key=>[key,key==='client_ts'?Number(fields[key]):fields[key]??'']));
return {...headers,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':event,'X-DC-Request-Context':base64JSON({v:1,request_id:requestId,event_name:event,common,drama},true)};
})(),{});
}
async flush(){try{await Promise.resolve();if(this.initialized)await window.WebSDK?.flush();}catch{}}
}
export function startTracker(config?:Config){
if(typeof window==='undefined')return;
window.tracker??=new SiteTracker();if(config)window.tracker.configure(config);
return window.tracker;
}
+13 -48
View File
@@ -3,62 +3,46 @@ import type { User } from "../api/types";
import { authMetadata } from "./auth";
export type Query = Record<string, string | number | boolean | undefined>;
const PUBLIC = /^(?:config\/|navigation\/|theme\/|applist\/|recharge\/|translation\/|playlet\/home$|playlet\/tag\/options$)/;
const PUBLIC = /^(?:config\/|navigation\/|theme\/|applist\/|translation\/|playlet\/home$|playlet\/tag\/options$)/;
const PUBLIC_CONTENT = new Set(["playlet/all/list","playlet/hot/list","playlet/hot/rank/list","playlet/topic/list","playlet/topic/detail","playlet/tag/list","playlet/guess-like/list","playlet/hot-like"]);
/** Every visitor uses a guest session; an expired session is replaced by a new guest on the next request. */
export class ApiClient extends EventTarget {
user: User | null = null;
private session: Promise<User | null> | null = null;
private authTail: Promise<unknown> = Promise.resolve();
private version = 0;
private requiresLogin = false;
private prompted = false;
// Serialize responses which set session cookies so a slow guest response cannot replace a login.
// Serialize responses which set session cookies so a slow guest response cannot replace a newer one.
private authenticate<T>(work: () => Promise<T>): Promise<T> {
const job = this.authTail.then(work);
this.authTail = job.catch(() => {});
return job;
}
private promptLogin() {
if (!this.prompted) { this.prompted = true; this.dispatchEvent(new Event('expired')); }
}
async raw<T>(path: string, query: Query = {}, body?: unknown, signal?: AbortSignal): Promise<T> {
const version = this.version;
const params = new URLSearchParams();
Object.entries(query).forEach(([key,value])=>{if(value!==undefined && value!=="")params.set(key,String(value));});
const multipart = body instanceof FormData;
const method=body===undefined?'GET':'POST';
const tracking=typeof window==='undefined'?{}:await window.tracker?.requestHeaders(path,method,body)||{};
const response = await fetch(`/api/${path}${params.size?"?"+params:""}`, {
method, credentials:"same-origin", cache:"no-store", signal,
headers: {...tracking,...(body===undefined||multipart?{}:{"Content-Type":"application/json"})},
headers: body===undefined||multipart?{}:{"Content-Type":"application/json"},
body: body===undefined?undefined:multipart?body as FormData:JSON.stringify(body),
});
const result = decodeEnvelope<T>(await response.json());
if (!response.ok || result.code!==200) {
if ((EXPIRED_CODES.has(result.code) || response.status===401) && !/^auth\/(login|register|resetPassword|logout)$/.test(path) && version === this.version) {
const registered = response.headers.get('X-Auth-State') === 'login-required' || this.user?.isRegister || this.user?.type === 1;
this.setUser(null);
this.requiresLogin = !!registered;
if (registered) this.promptLogin();
}
if ((EXPIRED_CODES.has(result.code) || response.status===401) && version === this.version) this.setUser(null);
throw new ApiError(result.tip || result.msg || "请求失败,请稍后重试",result.code);
}
return result.data;
}
private restoreSession(automatic: boolean): Promise<User | null> {
private restoreSession(): Promise<User | null> {
if (this.user) return Promise.resolve(this.user);
if (this.requiresLogin) {
if (automatic) return Promise.resolve(null);
this.promptLogin();
return Promise.reject(new ApiError('请先登录账号',401));
}
if (!this.session) {
const job = this.authenticate(async () => {
const user = await this.raw<User | null>('auth/guest',{}, {...authMetadata(), automatic});
const user = await this.raw<User | null>('auth/guest',{}, authMetadata());
this.setUser(user);
if (!user) this.requiresLogin = true;
return user;
});
this.session = job;
@@ -66,51 +50,32 @@ export class ApiClient extends EventTarget {
}
return this.session;
}
initializeSession() { return this.restoreSession(true); }
initializeSession() { return this.restoreSession(); }
async ensureSession(): Promise<User> {
const user = await this.restoreSession(false);
if (!user) { this.promptLogin(); throw new ApiError('请先登录账号',401); }
const user = await this.restoreSession();
if (!user) throw new ApiError('游客会话暂不可用,请稍后重试',401);
return user;
}
setUser(user: User | null) {
this.version++;
this.user = user;
if(typeof window!=='undefined')window.tracker?.syncUser(user);
if (user) { this.requiresLogin = false; this.prompted = false; }
this.dispatchEvent(new Event('userchange'));
}
seedSession(user:User|null){this.requiresLogin=!user;this.setUser(user);}
seedSession(user:User|null){this.setUser(user);}
async revalidateSession(){
const version=this.version;
return this.authenticate(async()=>{
const user=await this.raw<User|null>('auth/guest',{}, {...authMetadata(),automatic:true});
const user=await this.raw<User|null>('auth/guest',{}, authMetadata());
if(version===this.version)this.seedSession(user);
return this.user;
});
}
reset() { this.requiresLogin = true; this.prompted = false; this.session = null; this.setUser(null); }
async get<T>(path: string, query: Query = {}, signal?: AbortSignal): Promise<T> {
if(!PUBLIC.test(path)&&!PUBLIC_CONTENT.has(path))await this.ensureSession();
return this.raw(path,query,undefined,signal);
}
async post<T>(path: string, body: unknown): Promise<T> {
if (['auth/login','auth/register','auth/logout'].includes(path)) {
if (path === 'auth/register' && !this.user && !this.requiresLogin) await this.ensureSession();
const payload = path === 'auth/logout' ? {} : {...authMetadata(), ...body as object};
return this.authenticate(async () => {
const data = await this.raw<T>(path,{},payload);
if (path === 'auth/logout') this.reset();
else this.setUser(data as User);
return data;
});
}
if(!/^auth\/(guest|resetPassword)$/.test(path))await this.ensureSession();
if(path!=='auth/guest')await this.ensureSession();
return this.raw(path,{},body);
}
async refreshUser() {
await this.ensureSession();
const version = this.version;
const user = await this.raw<User>('auth/getInfo');
if (version === this.version) this.setUser(user);
}
}
+13 -50
View File
@@ -1,11 +1,10 @@
import { navigationIsActive } from '../site-navigation';
import { ApiClient } from "./api";
import type { Config, Drama, FooterContact, FooterInfoResponse, HomeData, PageData } from "../api/types";
import type { Config, Drama, FooterInfoResponse, HomeData, PageData } from "../api/types";
import type { SiteContext } from "./context";
import { recordList, safeUrl } from "../api/protocol";
import { activeBanners, card, empty, escape, hero } from "./render";
import { configureSite, copyRoadAddress, downloadUrl } from "./config";
import { accountMenuMarkup, bindAccountUI, openAuth, renderMember, renderWallet } from "./account";
import { renderPlayer } from "./player";
import { contentPath, navigationSection, paginationPath, parseSitePath, searchPath, samePageAddress, topNavigationSection } from "../routing";
import { bindPaginationJumps, navigateTo, ROUTE_EVENT } from "./navigation";
@@ -15,9 +14,7 @@ import { processImage, startImageLoading } from "./images";
import { bindHeroSwipe } from "./hero-swipe";
import { bindPromotionMarquee } from './promo-marquee';
import { scrollToAppCategory } from "./apps";
import { renderFeedbackPage } from "./feedback";
import { positionSearchDiscovery, searchPicksMarkup, searchSuggestionsMarkup } from "./search";
import { infoContactSection } from "../info-pages";
import { buildPageHead, clientSeoEnvironment, resolveSeoEnvironment, type SeoBootstrap, type SeoContent, type SeoEnvironment } from '../seo';
import { applyPageHead } from './seo';
import type { PageBootstrap, SiteSharedData } from '../page-data';
@@ -45,7 +42,6 @@ export class Application implements SiteContext {
private searchSeq=0;
private initialized=false;
private startupDialogs?:StartupDialogs;
private footerContacts:FooterContact[]=[];
private seoEnvironment:SeoEnvironment;
private seoContent:SeoContent={};
private initialSeo?:SeoBootstrap;
@@ -56,20 +52,17 @@ export class Application implements SiteContext {
private syncHead(){applyPageHead(buildPageHead(location.pathname,location.search,this.seoEnvironment,this.seoContent));}
current(){return !this.signal.aborted;}
notify(message:string){const el=$('#toast');el.textContent=message;el.classList.remove('hidden');clearTimeout(this.toastTimer);this.toastTimer=setTimeout(()=>el.classList.add('hidden'),2600);}
openAuth(mode='login'){openAuth(mode);}
navigate(path:string){navigateTo(path);}
async init(){
if(this.initialized)return;this.initialized=true;
const session=this.initialSession||this.initialPage?.session;if(session)this.api.seedSession(session.user);
startImageLoading();
this.bindEvents();this.startupDialogs=startStartupDialogs();this.createAccountMenu();bindAccountUI(this);
this.bindEvents();this.startupDialogs=startStartupDialogs();
window.addEventListener('pagehide',event=>this.hidePage(event.persisted));
window.addEventListener('pageshow',event=>{if(event.persisted)this.restorePage();});
this.api.addEventListener('userchange',()=>this.updateAccount());
this.api.addEventListener('expired',()=>{this.updateAccount();this.openAuth();});
await this.initializeShared();
void this.api.initializeSession().then(()=>this.updateAccount()).catch(error=>this.notify(error.message));
void this.api.initializeSession().catch(()=>{});
this.startFloatingTools();
await this.route();
}
@@ -77,10 +70,7 @@ export class Application implements SiteContext {
private async initializeShared(){
const shared=this.initialShared;
if(shared){
this.footerContacts=recordList<FooterContact>(shared.footer.footerInfo?.contactList);
await this.applyConfig(shared.config);
// SDK configuration is fetched separately and is never serialized into page HTML.
if(shared.trackerConfigured)void this.api.get<Config>('config/info').then(config=>window.tracker?.configure(config)).catch(()=>{});
}else{
this.api.get<Config>('config/info').then(config=>this.applyConfig(config)).catch(error=>this.notify(error.message));
void this.loadFooter();
@@ -91,12 +81,7 @@ export class Application implements SiteContext {
this.config=config;this.startupDialogs?.setAdvertisements(config.advitise?.dialogList||[],config.advitise?.nineGridList||[]);
this.seoEnvironment=clientSeoEnvironment(this.seoEnvironment,config);
if(this.routeKey)this.syncHead();
window.tracker?.configure(config);
this.refreshInfoContacts();await configureSite(config);
}
private refreshInfoContacts(){
const target=document.querySelector<HTMLElement>('#info-content [data-site-contacts]');
if(target)target.outerHTML=infoContactSection(this.config,this.footerContacts);
await configureSite(config);
}
private disposePage(){
@@ -105,14 +90,13 @@ export class Application implements SiteContext {
}
private hidePage(persisted:boolean){
const route=parseSitePath(location.pathname,location.search);
const interactive=route?.group==='detail'||route?.group==='play'||route?.group==='member'||route?.group==='info'&&['deposit','settings','user-center','feedback'].includes(route.key);
const interactive=route?.group==='detail'||route?.group==='play';
if(!persisted||interactive||document.querySelector('dialog[open]')){this.disposePage();return;}
this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.disposeHeroFade?.();
}
private restorePage(){
if(this.signal.aborted){window.location.reload();return;}
this.restartHeroAutoplay();
window.tracker?.resumePage();
// Restore the document immediately, then refresh the header identity using current cookies.
void this.api.revalidateSession().catch(()=>{});
}
@@ -181,14 +165,9 @@ export class Application implements SiteContext {
if(!page||!samePageAddress(page.path,this.routeKey))return;
const pageHref=(value:number)=>paginationPath(this.routeKey,value);
if(page.panel==='player'&&page.player){
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true,page:route.page||1,commentSort:new URLSearchParams(location.search).get('commentSort')==='1'?1:2,pageHref:(value,sort)=>paginationPath(contentPath(route.group==='play'?'play':'detail',page.player!.detail,route.group==='play'?Number(route.value):undefined)+location.search,value,{commentSort:sort===2?null:sort})});
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true});
}else if(page.panel==='search'&&page.search){
$('#query').setAttribute('value',page.search.keyword);
window.tracker?.search(page.search.keyword,page.search.total,page.search.page);
}else if(page.panel==='info'){
if(page.info?.member)await renderMember(this,page.info.key,route.page||1,pageHref,page.info.member,true);
else if(page.info?.wallet)await renderWallet(this,route.value==='vip',route.page||1,pageHref,page.info.wallet,true);
else if(page.info?.feedback)renderFeedbackPage(this,true);
}
}
@@ -198,14 +177,15 @@ export class Application implements SiteContext {
document.addEventListener('click',event=>{void (async()=>{
const el=(event.target as Element).closest<HTMLElement>('button,a');if(!el)return;
if(el.dataset.close){(document.getElementById(el.dataset.close) as HTMLDialogElement)?.close();return;}
if(el.dataset.authMode){this.openAuth(el.dataset.authMode);return;}
if(el.dataset.memberRoute){this.navigate('/member/'+el.dataset.memberRoute+'/');return;}
if(el.hasAttribute('data-logout')){await this.api.post('auth/logout',{});this.navigate('/');this.notify('已退出登录');return;}
if(el.dataset.sort){const sort=Number(el.dataset.sort);this.navigate(paginationPath(location.pathname+location.search,1,{sort:sort===1?null:sort}));return;}
if(el.dataset.appCategory){scrollToAppCategory($('#channel-content'),el.dataset.appCategory);return;}
if(el.dataset.feature!==undefined||el.dataset.heroStep){this.selectHero(el.dataset.feature!==undefined?Number(el.dataset.feature):this.feature+Number(el.dataset.heroStep));this.restartHeroAutoplay();return;}
if(el.dataset.feature!==undefined||el.dataset.heroStep){
const index=el.dataset.feature!==undefined?Number(el.dataset.feature):this.feature+Number(el.dataset.heroStep);
// Banner list links are real detail links; the first click only brings that title to the stage.
if(el.tagName==='A'&&index===this.feature)return;
event.preventDefault();this.selectHero(index);this.restartHeroAutoplay();return;
}
if(el.dataset.apiAction==='retry'){window.location.reload();return;}
if(el.dataset.accountAction==='coins'){this.navigate('/info/deposit/');return;}
if(el.dataset.roadCopy){await copyRoadAddress(el);return;}
if(el.hasAttribute('data-app-download')){
if(!downloadUrl(this.config)){event.preventDefault();return;}
@@ -214,7 +194,6 @@ export class Application implements SiteContext {
}
if(el.id==='app-copy-link'){const url=downloadUrl(this.config);if(!url)throw new Error('暂未配置下载地址');await navigator.clipboard.writeText(url);$('#app-copy-status').textContent='链接已复制';return;}
if(el.id==='app-download-close'){($<HTMLDialogElement>('#app-download-dialog')).close();return;}
if(el.id==='floating-history'){this.navigate('/member/history/');return;}
if(el.id==='back-to-top'){window.scrollTo({top:0,behavior:'smooth'});return;}
if(el.id==='floating-app-close'){$('#page-tool-download').style.display='none';return;}
if(el.id==='search-panel-close'){this.closeSearchDiscovery();return;}
@@ -238,22 +217,7 @@ export class Application implements SiteContext {
const url=downloadUrl(this.config);if(!url)return;
const dialog=$<HTMLDialogElement>('#app-download-dialog');if(!dialog.open)dialog.showModal();
const qr=await createDownloadQr(url);
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt='91短剧 App 下载二维码';}
}
private createAccountMenu(){
const panel=document.createElement('div');panel.id='profile-popover';panel.hidden=true;panel.setAttribute('aria-label','账户菜单');document.body.append(panel);
const entry=$('#account-entry');entry.addEventListener('click',()=>{this.updateAccount();const rect=entry.getBoundingClientRect();panel.style.right=Math.max(12,innerWidth-rect.right)+'px';panel.style.top=rect.bottom+12+'px';panel.hidden=!panel.hidden;entry.setAttribute('aria-expanded',String(!panel.hidden));});
document.addEventListener('click',event=>{if(!entry.contains(event.target as Node)&&!panel.contains(event.target as Node)){panel.hidden=true;entry.setAttribute('aria-expanded','false');}});
window.addEventListener('scroll',()=>{panel.hidden=true;},{passive:true});
this.updateAccount();
}
private updateAccount(){
const user=this.api.user;const name=user?.nickName||user?.accountNo||'游客';
$('#account-entry strong').textContent=name;$('#account-status').textContent=user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录';
const avatar=$<HTMLImageElement>('#account-entry img');void processImage(safeUrl(user?.avatar)||'/icon.svg',avatar,'img','/icon.svg');
$('.coin-value').textContent=user?String(user.recharge||0):'—';$('.coin-entry').setAttribute('aria-label',user?`金币余额 ${user.recharge||0}`:'金币余额,登录后查看');
const panel=document.getElementById('profile-popover');if(!panel)return;
panel.innerHTML=accountMenuMarkup(user);
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt='黄果吃瓜 App 下载二维码';}
}
private async loadDiscovery(){
@@ -290,7 +254,6 @@ export class Application implements SiteContext {
try{
const data=await this.api.get<FooterInfoResponse>('config/footer/info');
const footer=data?.footerInfo;if(!footer)return;
this.footerContacts=recordList<FooterContact>(footer.contactList);this.refreshInfoContacts();
if(footer.brand?.content)$('.hg-footer-original-slogan').textContent=footer.brand.content;
}catch{}
}
+1 -5
View File
@@ -40,13 +40,9 @@ export async function loadAppCategories(api:Pick<ApiClient,'get'>,signal:AbortSi
export function renderAppDirectory(categories:AppCategory[]):string{
const visible=categories.map(category=>({...category,appList:appItems(category.appList)})).filter(category=>category.appList.length);
if(!visible.length)return empty('暂无福利应用');
return `<div class="benefits-directory"><nav class="channel-tabs" aria-label="福利应用分类">${visible.map((category,index)=>`<button type="button" data-app-category="${category.id}" aria-controls="apps-${category.id}" aria-pressed="${index===0}">${escape(category.title)}</button>`).join('')}</nav>${visible.map(category=>`<section class="benefit-section" id="apps-${category.id}" aria-labelledby="apps-title-${category.id}"><header class="benefit-heading"><div><h2 id="apps-title-${category.id}">${escape(category.title)}</h2>${category.intro?`<p>${escape(category.intro)}</p>`:''}</div></header><div class="benefit-grid" style="--compact-app-columns:${Math.max(1,Math.ceil(Math.min(category.appList.length,20)/2))}">${category.appList.map(app=>`<a class="benefit-card" ${appTracking(app,category.id)} href="${escape(safeUrl(app.url)||'/apps/')}" target="_blank" rel="${safeUrl(app.url)?"sponsored nofollow noopener noreferrer":"noopener noreferrer"}"><span class="benefit-icon"><img ${imageAttributes(app.logo)} alt="${escape(app.name.trim())}应用图标" loading="lazy"></span><span class="benefit-copy"><strong>${escape(app.name)}</strong><small>${escape(app.intro||app.description)}</small></span></a>`).join('')}</div></section>`).join('')}</div>`;
return `<div class="benefits-directory"><nav class="channel-tabs" aria-label="福利应用分类">${visible.map((category,index)=>`<button type="button" data-app-category="${category.id}" aria-controls="apps-${category.id}" aria-pressed="${index===0}">${escape(category.title)}</button>`).join('')}</nav>${visible.map(category=>`<section class="benefit-section" id="apps-${category.id}" aria-labelledby="apps-title-${category.id}"><header class="benefit-heading"><div><h2 id="apps-title-${category.id}">${escape(category.title)}</h2>${category.intro?`<p>${escape(category.intro)}</p>`:''}</div></header><div class="benefit-grid" style="--compact-app-columns:${Math.max(1,Math.ceil(Math.min(category.appList.length,20)/2))}">${category.appList.map(app=>`<a class="benefit-card"${app.advitiseId?' data-ad':''} href="${escape(safeUrl(app.url)||'/apps/')}" target="_blank" rel="${safeUrl(app.url)?"sponsored nofollow noopener noreferrer":"noopener noreferrer"}"><span class="benefit-icon"><img ${imageAttributes(app.logo)} alt="${escape(app.name.trim())}应用图标" loading="lazy"></span><span class="benefit-copy"><strong>${escape(app.name)}</strong><small>${escape(app.intro||app.description)}</small></span></a>`).join('')}</div></section>`).join('')}</div>`;
}
function appTracking(app:AppItem,categoryId:number):string{
if(app.advitiseId)return `data-track-ad="external" data-ad-id="${escape(app.advitiseId)}" data-ad-clickable="${safeUrl(app.url)?1:0}" data-ad-slot-key="${escape(app.location||'apps_'+categoryId)}" data-ad-slot-name="福利应用" data-ad-type="icon"`;
return app.id?`data-track-item data-item-id="${escape(app.id)}" data-item-name="${escape(app.name)}" data-item-type="app" data-parent-item-id="${escape(categoryId)}"`:'';
}
export function scrollToAppCategory(root:HTMLElement,id:string){
if(!/^\d+$/.test(id))return;
-22
View File
@@ -29,25 +29,3 @@ export function authMetadata() {
return {devID, deviceId:devID, channel:firstChannel, ...(inviteCode ? {agentInviteCode:inviteCode} : {})};
}
export function authFormPayload(mode: string, values: Record<string, FormDataEntryValue>): Record<string, string> {
const account = String(values.account || '').trim();
const password = String(values.password || '');
if (mode === 'register') {
if (!/^[A-Za-z0-9]{6,20}$/.test(account)) throw new Error('账号需为 6 到 20 位字母或数字');
if (password.length < 8 || password.length > 64 || !/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) throw new Error('密码需为 8–64 位,且同时包含字母与数字');
if (password !== values.passwordConfirm) throw new Error('两次输入的密码不一致');
if (!values.agree) throw new Error('请先同意用户协议与隐私政策');
return {accountNo:account, password};
}
if (mode === 'resetPassword') {
const email = String(values.email || '').trim();
if (!account || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('请填写账号和有效的绑定邮箱');
if (password.length < 8 || password.length > 64 || !/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) throw new Error('密码需为 8–64 位,且同时包含字母与数字');
if (password !== values.passwordConfirm) throw new Error('两次输入的密码不一致');
return {accountNo:account, email, newPassword:password};
}
// Accept legacy 4-character passwords and the longer accounts/passwords accepted by binding.
if (!(account.includes('@') ? /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account) : account.length >= 4 && account.length <= 20)) throw new Error('请输入 4–20 位账号或有效邮箱');
if (password.length < 4 || password.length > 64) throw new Error('密码需为 4–64 位');
return {account, password};
}
+7 -17
View File
@@ -1,27 +1,21 @@
import { recordList, safeUrl } from "../api/protocol";
import { safeUrl } from "../api/protocol";
import type { Config } from "../api/types";
import { escape } from "./render";
import { downloadUrl } from "../app-download";
import { configureDownloadLinks } from "./download";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_URL } from "../site";
export { downloadUrl };
export function configureSite(config: Config) {
const settings = {...config.newAddressInfo,...config.config};
const download = downloadUrl(config);
configureDownloadLinks(config);
const input = document.querySelector<HTMLInputElement>("#app-platform-link");
if(input)input.value=download;
document.querySelector(".app-download-demo")?.remove();
const caption=document.getElementById("app-platform-caption");if(caption)caption.textContent=download?"扫码下载安装 91短剧 App":"暂未配置 App 下载地址";
const channels: Record<string,unknown> = {"官方推特":settings.twitterAddr,"官方TG群":settings.tgGroup,"官方联系":settings.telegram,"Telegram":settings.telegram,"官方社群":settings.tgGroup,"X":settings.twitterAddr};
document.querySelectorAll<HTMLAnchorElement>('a[target="_blank"]:not([data-track-ad])').forEach(link=>{
const label=link.getAttribute('aria-label')||link.textContent||"";
for(const [key,url] of Object.entries(channels))if(label.includes(key)&&safeUrl(url)){link.href=safeUrl(url);link.rel='sponsored nofollow noopener noreferrer';if(label.startsWith('官方联系:'))link.textContent='官方联系:'+safeUrl(url);break;}
});
const caption=document.getElementById("app-platform-caption");if(caption)caption.textContent=download?"扫码下载安装 黄果吃瓜 App":"暂未配置 App 下载地址";
}
export function homeRoad(config: Config, pageUrl = '/home-road/'): string {
const values={...config.newAddressInfo,...config.config};
const icons={rocket:'3651a2618990bf95',link:'ba17d56edfb047e2',shield:'d001d1cc6c2f0ce6',help:'d59d2d7390d88f08',devices:'d4ebee2aa88ba9a2',iphone:'a0c6451fadcb7738',android:'8b1153736cc5dca1',desktop:'11bae9823678ebdc',email:'2a8580c42090f36c',tg:'9a5b6e9db9de76b8',contact:'228a0af1662081ac',twitter:'72f702ca215f597d'};
type Icon=keyof typeof icons;
const iconLabels:Record<Icon,string>={rocket:'备用访问地址',link:'永久访问入口',shield:'官方联系',help:'访问帮助',devices:'App 下载与设备',iphone:'iPhone 设备',android:'Android 设备',desktop:'电脑设备',email:'联系邮箱',tg:'Telegram 官方交流群',contact:'官方联系',twitter:'X 官方社区'};
@@ -30,17 +24,13 @@ export function homeRoad(config: Config, pageUrl = '/home-road/'): string {
const external=(url:string,label:string)=>`<a href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">${escape(label)} <span aria-hidden="true">↗</span></a>`;
const copy=(value:string,label='复制地址')=>`<button type="button" data-road-copy="${escape(value)}">${label}</button>`;
const display=(url:string)=>{const parsed=new URL(url);return parsed.host+(parsed.pathname==='/'?'':parsed.pathname.replace(/\/$/,''));};
const configured=[values.newUrlAddr,values.newUrlAddr1,values.newUrlAddr2].map(value=>safeUrl(value)).filter(Boolean);
const navigation=recordList<{line?:number;url?:unknown}>(config.navPageList).sort((a,b)=>(a.line||0)-(b.line||0)).map(item=>safeUrl(item.url)).filter(Boolean);
const backups=[...new Set(configured.length?configured:navigation)].slice(0,3);
const forever=safeUrl(values.foreverUrlAddr),primary=backups[0]||forever;
// Displayed site addresses are always this site's own domain, never configured mirrors.
const backups=[SITE_URL];
const forever=SITE_URL,primary=SITE_URL;
const permanent=[['永久入口',forever],['主站入口',primary],['备用中转',backups[1]]].filter((row,index,rows)=>row[1]&&rows.findIndex(other=>other[1]===row[1])===index);
const contacts:string[]=[];
const contact=(key:Icon,title:string,description:string,url:string,app=false)=>`<a class="return-contact-card" href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer"${app?' data-app-download data-app-download-entry':''}><span class="return-contact-icon">${icon(key,28)}</span><span><strong>${title}</strong><small>${description}</small></span><span class="return-contact-arrow" aria-hidden="true">↗</span></a>`;
const email=[values.emailAddr,values.email].find(value=>typeof value==='string'&&/^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/.test(value.trim()));
if(typeof email==='string')contacts.push(`<button type="button" class="return-contact-card" data-road-copy="${escape(email.trim())}"><span class="return-contact-icon">${icon('email',28)}</span><span><strong>联系邮箱</strong><small>复制邮箱地址</small></span><span class="return-contact-arrow" aria-hidden="true">↗</span></button>`);
const channels:[Icon,string,string,unknown][]=[['tg','官方交流群','查看更新与入口通知',values.tgGroup],['contact','官方联系','联系官方团队',values.telegram],['twitter','X 社区','关注公告与动态',values.twitterAddr]];
for(const [key,title,description,value] of channels){const url=safeUrl(value);if(url)contacts.push(contact(key,title,description,url));}
contacts.push(contact('tg','官方TG',OFFICIAL_TG_HANDLE,OFFICIAL_TG_URL));
const app=downloadUrl(config);if(app)contacts.push(contact('devices','App 下载','下载客户端,随时追剧',app,true));
return `<div class="return-home-directory">
<section class="return-hero"><div class="return-hero-copy"><span class="return-eyebrow">保存入口 · 随时回来</span><h2>把回家的路,留在身边</h2><p>收藏常用入口,访问不畅时换一条路线。</p></div><div class="return-actions">${primary?external(primary,'进入主站'):''}${copy(safeUrl(pageUrl)||'/home-road/','复制本页地址')}</div></section>
-1
View File
@@ -11,6 +11,5 @@ export interface SiteContext {
current(): boolean;
route(): Promise<void>;
navigate(path: string): void;
openAuth(mode?: string): void;
updateSeo?(content:SeoContent):void;
}
+47 -30
View File
@@ -1,14 +1,14 @@
import { contentPath } from '../routing';
import type { Chapter, Config, DramaDetail } from "../api/types";
import { formatCount, recordList, safeUrl } from "../api/protocol";
import { contentPath, contentSegment } from '../routing';
import type { Chapter, DramaDetail } from "../api/types";
import { formatCount } from "../api/protocol";
import { escape, imageAttributes, title } from "./render";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_ORIGIN, SITE_URL } from '../site';
// Original HTML action icons, kept as SVG paths so active colors follow the stylesheet.
const actionPaths = {
"save": "M857.6 327.68l-104.494545-34.210909a178.036364 178.036364 0 0 1-90.763637-66.56l-64-89.367273a104.96 104.96 0 0 0-23.272727-23.272727 106.123636 106.123636 0 0 0-148.48 23.272727l-64.930909 89.367273a179.665455 179.665455 0 0 1-90.763637 66.792727l-104.494545 33.978182a106.821818 106.821818 0 0 0-53.061818 162.909091l93.090909 128.930909a35.84 35.84 0 0 1 6.981818 21.643636v158.952728a109.381818 109.381818 0 0 0 5.12 33.512727 105.658182 105.658182 0 0 0 133.818182 67.956364l128-39.33091a107.985455 107.985455 0 0 1 64.232727 0l127.534546 39.33091a106.123636 106.123636 0 0 0 139.636363-101.469091v-158.254546a35.84 35.84 0 0 1 6.981818-21.643636l93.09091-128.930909a104.494545 104.494545 0 0 0 15.127272-29.556364 106.821818 106.821818 0 0 0-69.352727-134.050909z m-192 83.549091a34.909091 34.909091 0 0 1-48.174545 10.938182l-46.545455-29.090909a34.909091 34.909091 0 0 1 37.003636-59.112728l46.545455 29.090909a34.909091 34.909091 0 0 1 11.170909 48.174546z",
"like": "M855.738182 198.283636A252.043636 252.043636 0 0 0 670.254545 116.363636a225.28 225.28 0 0 0-160.814545 63.069091 248.32 248.32 0 0 0-152.901818-53.294545A251.578182 251.578182 0 0 0 171.287273 209.454545 287.883636 287.883636 0 0 0 162.909091 588.567273l256 275.083636a122.181818 122.181818 0 0 0 90.065454 39.563636 121.716364 121.716364 0 0 0 89.832728-39.563636l256-272.989091 2.792727-3.723636a290.443636 290.443636 0 0 0-1.861818-388.654546z",
"share": "M910.661818 117.992727a114.734545 114.734545 0 0 0-113.803636-16.756363L134.050909 363.752727a138.472727 138.472727 0 0 0-15.592727 250.181818l97.978182 54.225455 1.861818 162.909091a87.272727 87.272727 0 0 0 137.309091 69.818182L482.210909 814.545455l180.363636 99.374545a138.472727 138.472727 0 0 0 203.869091-101.236364L954.181818 224.814545a113.803636 113.803636 0 0 0-43.52-106.821818z",
"feedback": "M505.949091 38.4a465.454545 465.454545 0 1 0 465.454545 465.454545 465.454545 465.454545 0 0 0-465.454545-465.454545z m0 721.454545a46.545455 46.545455 0 1 1 46.545454-46.545454 46.545455 46.545455 0 0 1-46.545454 46.545454z m43.287273-188.276363a11.636364 11.636364 0 0 0-8.378182 11.170909v25.832727a34.909091 34.909091 0 0 1-69.818182 0V581.818182a81.454545 81.454545 0 0 1 58.88-78.196364 87.04 87.04 0 0 0 60.741818-102.865454A86.807273 86.807273 0 0 0 433.803636 372.363636a90.298182 90.298182 0 0 0-11.403636 24.436364 34.909091 34.909091 0 0 1-67.025455-19.549091 162.909091 162.909091 0 0 1 20.712728-43.985454 156.625455 156.625455 0 1 1 173.149091 238.312727z",
"phone": "M756.363636 69.818182h-488.727272A128.232727 128.232727 0 0 0 139.636364 197.818182v628.363636a128.232727 128.232727 0 0 0 128 128h488.727272a128 128 0 0 0 128-128v-628.363636A128 128 0 0 0 756.363636 69.818182z m-139.636363 698.181818h-209.454546a34.909091 34.909091 0 0 1 0-69.818182h209.454546a34.909091 34.909091 0 0 1 0 69.818182z"
} as const;
@@ -16,36 +16,53 @@ export function detailActionIcon(kind: keyof typeof actionPaths): string {
return `<svg class="play-action-icon" viewBox="0 0 1024 1024" aria-hidden="true"><path d="${actionPaths[kind]}"/></svg>`;
}
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, actions: string): string {
const genres = (Array.isArray(drama.tagNames) ? drama.tagNames : Array.isArray(drama.themeNames) ? drama.themeNames : []).filter(tag => typeof tag === 'string');
const badge = drama.isFeatured ? '<span class="series-badge series-badge--hot">爆剧</span>' : drama.isNew ? '<span class="series-badge series-badge--new">新剧</span>' : '';
return `<div class="series-overview">
<img class="series-poster" ${imageAttributes(drama.cover || drama.coverImg)} width="640" height="914" alt="${escape(title(drama))}封面">
<div class="series-info">
<div class="series-title-row"><h1 id="play-detail-title">${escape(title(drama))}</h1><span class="series-heat"><img class="series-heat-icon" src="/assets/9df0afe903e8d542.svg" width="16" height="16" alt="剧集热度图标" aria-hidden="true">${formatCount(drama.heatValue)}热度</span></div>
<div class="series-tags" aria-label="剧集标签">${badge}<span class="series-stat" data-engage-count="collect" aria-live="polite">${formatCount(drama.countCollect)}收藏</span><span class="series-stat" data-engage-count="like" aria-live="polite">${formatCount(drama.countLike)}点赞</span></div>
${genres.length ? `<p class="series-genres">${escape(genres.join(' · '))}</p>` : ''}
<div class="series-description-wrap"><p class="series-description" id="series-description-text">${escape(drama.desc || drama.description)}</p><button class="series-description-toggle" type="button" aria-expanded="false" aria-controls="series-description-text" hidden>展开</button></div>
<div class="series-action-bar"><div class="series-actions">
${first ? `<a class="primary detail-play" href="${contentPath('play',drama,first.id)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg><span>立即播放</span></a>` : '<span class="series-unavailable">暂无可用剧集</span>'}
<a class="series-download" data-app-download-entry data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer" hidden><svg class="series-download-phone" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4M11 19h2"/></svg><span>下载 App</span></a>
</div>${actions}</div>
</div>
</div>`;
/** Genre and tag names as internal links when the API supplies their identities. */
export function genreLinks(drama: DramaDetail): string {
const useTags = Array.isArray(drama.tagNames);
const names = (useTags ? drama.tagNames : Array.isArray(drama.themeNames) ? drama.themeNames : []) || [];
return names.map((name, i) => {
if (typeof name !== 'string' || !name.trim()) return '';
const identity = {id:(useTags ? drama.tagIds : drama.themeIds)?.[i], slug:(useTags ? drama.tagSlugs : drama.themeSlugs)?.[i]};
return contentSegment(identity) ? `<a href="${contentPath(useTags ? 'tag' : 'topics', identity)}">${escape(name)}</a>` : `<span>${escape(name)}</span>`;
}).filter(Boolean).join('');
}
export function officialLinks(config: Config): string {
const settings = {...config.newAddressInfo,...config.config};
const primary = safeUrl(settings.newUrlAddr) || recordList<{url?:unknown}>(config.navPageList).map(item => safeUrl(item.url)).find(Boolean) || '';
const permanent = safeUrl(settings.foreverUrlAddr);
const group = safeUrl(settings.tgGroup);
const contact = safeUrl(settings.telegram);
/** Labelled facts in a description list so the episode count, genres and engagement are explicit page content. */
export function detailFacts(drama: DramaDetail, episodes: number, counts: {countCollect?: number; countLike?: number} = drama): string {
const genres = genreLinks(drama);
return `<dl class="hg-facts">${genres ? `<div><dt>类型</dt><dd class="hg-facts-links">${genres}</dd></div>` : ''}<div><dt>集数</dt><dd>全${episodes}集</dd></div><div><dt>热度</dt><dd>${formatCount(drama.heatValue)}</dd></div><div><dt>收藏</dt><dd data-engage-count="collect" aria-live="polite">${formatCount(counts.countCollect)}收藏</dd></div><div><dt>点赞</dt><dd data-engage-count="like" aria-live="polite">${formatCount(counts.countLike)}点赞</dd></div></dl>`;
}
export function synopsisMarkup(drama: DramaDetail, heading = '剧情简介'): string {
const text = drama.desc || drama.description;
return `<section class="hg-synopsis" aria-labelledby="hg-synopsis-title"><h2 id="hg-synopsis-title">${escape(heading)}</h2><div class="series-description-wrap"><p class="series-description" id="series-description-text">${text ? escape(text) : '暂无简介'}</p><button class="series-description-toggle" type="button" aria-expanded="false" aria-controls="series-description-text" hidden>展开</button></div></section>`;
}
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, actions: string, episodes = drama.totalEpisode || 0): string {
const name = title(drama);
const badge = drama.isFeatured ? '<span class="hg-detail-badge hg-detail-badge--hot">爆剧</span>' : drama.isNew ? '<span class="hg-detail-badge hg-detail-badge--new">新剧</span>' : '';
return `<div class="hg-detail">
<div class="hg-detail-poster"><img class="series-poster" ${imageAttributes(drama.cover || drama.coverImg)} width="640" height="914" alt="《${escape(name)}》海报">${badge}</div>
<div class="hg-detail-head">
<h1 id="play-detail-title">${escape(name)}</h1>
<p class="hg-detail-lead">《${escape(name)}》全${episodes}集在线观看</p>
${detailFacts(drama, episodes)}
<div class="hg-detail-actions">
${first ? `<a class="primary detail-play" href="${contentPath('play',drama,first.id)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg><span>播放第1集</span></a>` : '<span class="series-unavailable">暂无可用剧集</span>'}
<a class="series-download" data-app-download-entry data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer" hidden><svg class="series-download-phone" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4M11 19h2"/></svg><span>下载 App</span></a>
${actions}
</div>
</div>
</div>
${synopsisMarkup(drama, `《${name}》剧情简介`)}`;
}
/** Fixed site address and contact; configured mirrors and social links are not shown. */
export function officialLinks(): string {
const external = (url: string, label = url) => `<a href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">${escape(label)}</a>`;
return `<section class="play-official" aria-label="官方链接">
${primary ? `<div class="play-official-line play-official-inline"><span>官方入口:</span>${external(primary)}</div>` : ''}
${permanent ? `<div class="play-official-line play-official-inline"><span>永久地址:</span>${external(permanent)}</div>` : ''}
${group ? `<div class="play-official-line">${external(group,'点击加入91短剧官方 Telegram 群')}</div>` : ''}
<div class="play-official-line play-official-inline"><span>官方客服:</span>${contact ? external(contact,'立即联系') : '<a href="/info/feedback/">立即联系</a>'}</div>
<div class="play-official-line play-official-inline"><span>官方地址:</span>${external(SITE_URL,SITE_ORIGIN)}</div>
<div class="play-official-line play-official-inline"><span>官方TG:</span>${external(OFFICIAL_TG_URL,OFFICIAL_TG_HANDLE)}</div>
</section>`;
}
-274
View File
@@ -1,274 +0,0 @@
import type { SiteContext } from "./context";
import { escape } from "./render";
export function feedbackPayload(data:FormData,screenshots:string[]=[]) {
const value=(key:string)=>String(data.get(key)||'').trim();
const description=value('content');
if(description.length<10||description.length>500)throw new Error('请填写 10–500 字的问题描述');
const labels:Record<string,string>={category:'问题类型',frequency:'发生频率',province:'所在地区',city:'城市',carrier:'运营商',network:'网络类型',device:'设备类型',model:'设备型号',version:'App 版本'};
const details=Object.entries(labels).filter(([key])=>value(key)).map(([key,label])=>`${label}:${value(key)}`).join('\n');
const category=value('category');
const type=category==='登录异常'?4:category==='其他问题'?5:2;
const remaining=500-description.length;
const context=remaining>2&&details?'\n\n'+details.slice(0,remaining-2):'';
return {type,content:description+context,contact:value('contact'),screenshots};
}
export function renderFeedbackPage(app:SiteContext,preserveMarkup=false) {
const root=document.getElementById('info-content')!;if(!preserveMarkup)root.innerHTML=feedbackMarkup();
const form=root.querySelector<HTMLFormElement>('#feedback-form')!,signal=app.signal;
const input=form.querySelector<HTMLInputElement>('#feedback-files')!,list=form.querySelector('#feedback-file-list')!,error=form.querySelector('#feedback-file-error')!,status=form.querySelector('#feedback-status')!;
let files:File[]=[],previews:string[]=[];
const uploaded=new Map<File,string>();
const clearPreviews=()=>{previews.forEach(url=>URL.revokeObjectURL(url));previews=[];};
const renderFiles=()=>{
clearPreviews();
list.innerHTML=files.map((file,index)=>{const url=URL.createObjectURL(file);previews.push(url);return `<div class="feedback-attachment"><img src="${url}" alt="${escape(file.name || "反馈图片")}预览"><span>${escape(file.name)}</span><button type="button" data-remove-feedback-file="${index}" aria-label="移除${escape(file.name)}">×</button></div>`;}).join('');
};
signal.addEventListener('abort',clearPreviews,{once:true});
input.addEventListener('change',()=>{
if(form.dataset.submitting)return;
const selected=Array.from(input.files||[]);
const invalid=selected.length>3?'最多上传 3 张图片':selected.some(file=>file.size>5*1024*1024||!['image/jpeg','image/png','image/webp'].includes(file.type))?'请上传 5MB 以内的 JPG、PNG 或 WebP 图片':'';
error.textContent=invalid;
if(invalid){input.value='';return;}
files=selected;renderFiles();
},{signal});
list.addEventListener('click',event=>{
const button=(event.target as Element).closest<HTMLElement>('[data-remove-feedback-file]');if(!button||form.dataset.submitting)return;
files.splice(Number(button.dataset.removeFeedbackFile),1);input.value='';renderFiles();
},{signal});
const content=form.querySelector<HTMLTextAreaElement>('[name="content"]')!;
content.addEventListener('input',()=>{form.querySelector('#feedback-count')!.textContent=content.value.length+' / 500';},{signal});
form.addEventListener('submit',event=>{
event.preventDefault();if(form.dataset.submitting||!form.reportValidity())return;
const data=new FormData(form),attachments=[...files];
try{feedbackPayload(data);}catch(reason){status.textContent=reason instanceof Error?reason.message:'请检查反馈内容';return;}
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
button.disabled=true;input.disabled=true;form.dataset.submitting='1';status.textContent='提交中…';
void (async()=>{
const screenshots:string[]=[];
for(const file of attachments){
if(signal.aborted)return;
let path=uploaded.get(file);
if(!path){const body=new FormData();body.append('file',file);body.append('watermark','0');const result=await app.api.post<{sourceNameFs?:string}>('upload/image',body);if(!result.sourceNameFs)throw new Error('图片上传失败,请重试');path=result.sourceNameFs;uploaded.set(file,path);}
screenshots.push(path);
}
if(signal.aborted)return;
await app.api.post('feedback/add',feedbackPayload(data,screenshots));
if(signal.aborted)return;
form.reset();files=[];uploaded.clear();renderFiles();error.textContent='';form.querySelector('#feedback-count')!.textContent='0 / 500';status.textContent='反馈已提交,感谢您的反馈!';
})().catch(reason=>{if(!signal.aborted)status.textContent=reason instanceof Error?reason.message:'反馈提交失败,请重试';}).finally(()=>{button.disabled=false;input.disabled=false;delete form.dataset.submitting;});
},{signal});
}
export const feedbackMarkup=()=>`<form id="feedback-form" class="feedback-form feedback-reference">
<fieldset class="feedback-part">
<legend>问题类型 <em>*</em>
</legend>
<div class="feedback-options">
<label class="feedback-option">
<input type="radio" name="category" value="播放卡顿 / 缓冲" required>
<span>播放卡顿 / 缓冲</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="无法播放" required>
<span>无法播放</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="画质模糊" required>
<span>画质模糊</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="音画不同步" required>
<span>音画不同步</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="加载失败" required>
<span>加载失败</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="闪退 / 崩溃" required>
<span>闪退 / 崩溃</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="登录异常" required>
<span>登录异常</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="其他问题" required>
<span>其他问题</span>
</label>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>问题发生频率 <em>*</em>
</legend>
<div class="feedback-options">
<label class="feedback-option">
<input type="radio" name="frequency" value="每次都会" required>
<span>每次都会</span>
</label>
<label class="feedback-option">
<input type="radio" name="frequency" value="经常出现" required>
<span>经常出现</span>
</label>
<label class="feedback-option">
<input type="radio" name="frequency" value="偶尔出现" required>
<span>偶尔出现</span>
</label>
<label class="feedback-option">
<input type="radio" name="frequency" value="仅出现一次" required>
<span>仅出现一次</span>
</label>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>网络环境信息 <em>*</em>
</legend>
<div class="feedback-panel">
<label>所在地址</label>
<div class="feedback-address">
<select name="province" aria-label="所在省份" required>
<option value="">所在省份</option>
<option>北京</option>
<option>天津</option>
<option>河北</option>
<option>山西</option>
<option>内蒙古</option>
<option>辽宁</option>
<option>吉林</option>
<option>黑龙江</option>
<option>上海</option>
<option>江苏</option>
<option>浙江</option>
<option>安徽</option>
<option>福建</option>
<option>江西</option>
<option>山东</option>
<option>河南</option>
<option>湖北</option>
<option>湖南</option>
<option>广东</option>
<option>广西</option>
<option>海南</option>
<option>重庆</option>
<option>四川</option>
<option>贵州</option>
<option>云南</option>
<option>西藏</option>
<option>陕西</option>
<option>甘肃</option>
<option>青海</option>
<option>宁夏</option>
<option>新疆</option>
<option>香港</option>
<option>澳门</option>
<option>台湾</option>
<option>海外</option>
</select>
<input name="city" maxlength="100" aria-label="城市(选填)" placeholder="城市(选填)">
</div>
<label>运营商<select name="carrier" required>
<option value="">选择运营商</option>
<option>中国移动</option>
<option>中国联通</option>
<option>中国电信</option>
<option>中国广电</option>
<option>其他</option>
</select>
</label>
<span class="feedback-field-label">网络类型</span>
<div class="feedback-options">
<label class="feedback-option">
<input type="radio" name="network" value="Wi-Fi" required>
<span>Wi-Fi</span>
</label>
<label class="feedback-option">
<input type="radio" name="network" value="5G" required>
<span>5G</span>
</label>
<label class="feedback-option">
<input type="radio" name="network" value="4G" required>
<span>4G</span>
</label>
<label class="feedback-option">
<input type="radio" name="network" value="其他" required>
<span>其他</span>
</label>
</div>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>设备信息 <em>*</em>
</legend>
<div class="feedback-panel">
<span class="feedback-field-label">设备类型</span>
<div class="feedback-options">
<label class="feedback-option">
<input type="radio" name="device" value="iPhone" required>
<span>iPhone</span>
</label>
<label class="feedback-option">
<input type="radio" name="device" value="Android" required>
<span>Android</span>
</label>
<label class="feedback-option">
<input type="radio" name="device" value="iPad" required>
<span>iPad</span>
</label>
<label class="feedback-option">
<input type="radio" name="device" value="电脑 / 其他" required>
<span>电脑 / 其他</span>
</label>
</div>
<div class="feedback-address">
<label>设备型号(选填)<input name="model" maxlength="100" placeholder="例如:iPhone 15、小米 14">
</label>
<label>App 版本号(选填)<input name="version" maxlength="100" placeholder="例如:v1.2.1">
</label>
</div>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>问题描述 <em>*</em>
</legend>
<div class="feedback-description-wrap">
<textarea name="content" aria-label="问题描述" minlength="10" maxlength="500" required placeholder="请详细描述您遇到的问题,包括观看的内容、具体表现和发生时间(至少 10 字)">
</textarea>
<span id="feedback-count">0 / 500</span>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>上传图片</legend>
<div class="feedback-uploads">
<label class="feedback-upload-box">
<input type="file" id="feedback-files" accept="image/jpeg,image/png,image/webp" multiple>
<span class="feedback-upload-plus" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14"/>
</svg>
</span>
<strong>上传图片</strong>
<small>支持 JPG / PNG / WebP,最多 3 张</small>
</label>
<div id="feedback-file-list">
</div>
</div>
<p class="feedback-file-note">每张图片不超过 5MB</p>
<p id="feedback-file-error" role="alert">
</p>
<input type="hidden" name="attachments" id="feedback-attachments">
</fieldset>
<fieldset class="feedback-part">
<legend>联系方式 <em>*</em>
</legend>
<input name="contact" aria-label="联系方式" required maxlength="120" placeholder="请输入 Email / Telegram / X 联系方式">
</fieldset>
<div class="feedback-submit-area">
<button type="submit" class="feedback-submit-button">提交反馈</button>
<p>提交后请留意您填写的联系方式。</p>
<span class="feedback-status" id="feedback-status" role="status">
</span>
</div>
</form>`;
+1 -1
View File
@@ -9,7 +9,7 @@ export function navigateTo(path: string) {
window.location.assign(target);
}
/** Delegate so comment and account pagination inserted after hydration also works. */
/** Delegate so pagination inserted after hydration also works. */
export function bindPaginationJumps(root: Document = document, navigate: (path: string) => void = navigateTo) {
root.addEventListener('submit', event => {
const form = event.target;
+4 -24
View File
@@ -3,13 +3,9 @@ import type { Chapter } from '../api/types';
import { safeUrl } from '../api/protocol';
export type PlayMode = 'main' | 'preview';
export type PlaybackAccess = {allowed: boolean; gate: 'coins' | 'vip' | 'unavailable' | ''; source: string};
export type PlaybackAccess = {allowed: boolean; gate: 'unavailable' | ''; source: string};
export type PlayerActions = {toggle: () => Promise<void>; retry: () => Promise<void>};
export function chapterRequiresPayment(chapter: Chapter): boolean {
return !!(chapter.needUnlock || chapter.needVip || chapter.payType === 1 || chapter.payType === 2);
}
export function playbackUrl(value: unknown): string {
if (typeof value !== 'string') return '';
const raw = value.trim();
@@ -21,32 +17,16 @@ export function playbackUrl(value: unknown): string {
return '/h5/m3u8/' + raw.replace(/^\/?h5\/m3u8\//, '').replace(/^\/+/, '');
}
/** Every episode is free: any chapter the API supplies a playable source for may be watched; payment flags are ignored. */
export function chapterAccess(chapter: Chapter | null | undefined): PlaybackAccess {
if (!chapter) return {allowed: false, gate: 'unavailable', source: ''};
const source = playbackUrl(chapter.videoUrl);
// Same precedence as PlayletChapterInfoResp.AllowWatch in the reference service.
const allowed = chapter.canWatch === true || (!chapter.needUnlock && !chapter.needVip && (chapter.payType ?? 0) === 0 && !!source);
if (allowed) return {allowed: true, gate: '', source};
const gate = chapter.needUnlock || chapter.payType === 1 ? 'coins' : chapter.needVip || chapter.payType === 2 ? 'vip' : 'unavailable';
return {allowed: false, gate, source: ''};
}
// List entries do not necessarily contain a video URL. Follow the reference
// PlayletChapterData.AllowWatch/NeedCoinGate/NeedVipGate precedence.
export function chapterListGate(chapter: Chapter): 'coins' | 'vip' | '' {
if (chapter.canWatch === true || !chapter.needUnlock && !chapter.needVip && (chapter.isUnlocked || (chapter.payType ?? 0) === 0)) return '';
if (chapter.needUnlock || chapter.payType === 1) return 'coins';
if (chapter.needVip || chapter.payType === 2) return 'vip';
return '';
const source = playbackUrl(chapter?.videoUrl);
return source ? {allowed: true, gate: '', source} : {allowed: false, gate: 'unavailable', source: ''};
}
export function initialPlayMode(access: PlaybackAccess, preview: unknown): PlayMode {
return (!access.allowed || !access.source) && !!playbackUrl(preview) ? 'preview' : 'main';
}
export function chapterUnlockCost(chapter: Chapter | null): number {
return chapter?.costCoins && chapter.costCoins > 0 ? chapter.costCoins : chapter?.singleCoins && chapter.singleCoins > 0 ? chapter.singleCoins : 0;
}
const INLINE_ATTRS: Record<string, string> = {
playsinline: 'true', 'webkit-playsinline': 'true', 'x5-playsinline': 'true',
+29 -49
View File
@@ -1,55 +1,48 @@
import { contentPath, contentSegment } from '../routing';
import type { Chapter, Comment, Config, Drama, DramaDetail, HomeData, PageData, User } from '../api/types';
import { formatCount, recordList } from '../api/protocol';
import { ads, card, empty, escape, imageAttributes, pagination, promotionRow, title } from './render';
import { detailActionIcon, officialLinks, overviewMarkup } from './detail';
import { chapterAccess, chapterListGate, chapterRequiresPayment, initialPlayMode, INLINE_VIDEO_ATTRIBUTES, playbackUrl } from './playback';
import { contentPath } from '../routing';
import type { Chapter, Drama, DramaDetail, HomeData } from '../api/types';
import { recordList } from '../api/protocol';
import { ads, empty, escape, imageAttributes, promotionRow, title, videoItem } from './render';
import { detailActionIcon, detailFacts, officialLinks, overviewMarkup, synopsisMarkup } from './detail';
import { chapterAccess, initialPlayMode, INLINE_VIDEO_ATTRIBUTES, playbackUrl } from './playback';
export interface PlayerViewOptions {
chapter?:Chapter|null; isVideo?:boolean; page?:number; commentSort?:number;
comments?:PageData<Comment>; config?:Config; home?:HomeData; user?:User|null;
pageHref?:(page:number,sort:number)=>string;
chapter?:Chapter|null; isVideo?:boolean; home?:HomeData;
}
export function orderedChapters(value:unknown):Chapter[] {
return recordList<Chapter>(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).sort((a,b)=>(a.currentEpisode||0)-(b.currentEpisode||0));
}
/** Recommended titles replace comments: crawlable internal links with full context for each title. */
export function recommendationsMarkup(raw:DramaDetail):string {
const related=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==raw.id).slice(0,12);
return `<section class="hg-recommend" aria-labelledby="hg-recommend-title"><div class="play-section-heading"><h2 id="hg-recommend-title">推荐视频</h2><span>看了《${escape(title(raw))}》的人还在看</span></div>${related.length?`<div class="hg-recommend-list">${related.map(item=>videoItem(item)).join('')}</div>`:empty('暂无推荐视频')}</section>`;
}
export function playerMarkup(raw:DramaDetail,ordered:Chapter[],options:PlayerViewOptions={}):string {
const {chapter=null,isVideo=false,comments,config={},home={},user}=options;
const {chapter=null,isVideo=false,home={}}=options;
const id=raw.id,overview=!chapter&&!isVideo,first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
const displayTitle=title(raw),heading=chapter?`${displayTitle} - 第${chapter.currentEpisode||1}集`:displayTitle,actionState=chapter||raw;
const commentPage=options.page||1,commentSort=options.commentSort===1?1:2;
const basePath=contentPath(isVideo?'video':chapter?'play':'detail',raw,chapter?.id);
const pageHref=options.pageHref||((page:number,sort:number)=>`${basePath}${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`);
const displayTitle=title(raw),episodes=raw.totalEpisode||ordered.length,actionState=chapter||raw;
const heading=chapter?`${displayTitle} 第${chapter.currentEpisode||1}集`:displayTitle;
const access=isVideo?{allowed:!!playbackUrl(raw.video?.url),gate:playbackUrl(raw.video?.url)?'':'unavailable',source:playbackUrl(raw.video?.url)} as const:chapterAccess(chapter);
const previewSource=isVideo?'':playbackUrl(raw.preview),mode=initialPlayMode(access,raw.preview);
const modeTabs=previewSource?`<div class="channel-tabs play-mode-tabs" role="group" aria-label="播放内容"><button type="button" data-play-mode="main" aria-pressed="${mode==='main'}">正片</button><button type="button" data-play-mode="preview" aria-pressed="${mode==='preview'}">免费预告</button></div>`:'';
const initialEpisodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter.id))/45):0;
const actions=()=>`<div class="play-detail-actions"><button type="button" data-engage="like" aria-pressed="${actionState.isLike===1}">${detailActionIcon('like')}<span>${actionState.isLike===1?'已点赞':'点赞'}</span></button><button type="button" data-engage="collect" aria-pressed="${actionState.isCollect===1}">${detailActionIcon('save')}<span>${actionState.isCollect===1?'已收藏':'收藏'}</span></button><button type="button" data-share>${detailActionIcon('share')}<span>分享</span></button><a href="/info/feedback/">${detailActionIcon('feedback')}<span>反馈</span></a></div>`;
const genres=(Array.isArray(raw.tagNames)?raw.tagNames:Array.isArray(raw.themeNames)?raw.themeNames:[]).filter(tag=>typeof tag==='string');
const useTags=Array.isArray(raw.tagNames);
const tags=genres.map((tag,i)=>{
const identity={id:(useTags?raw.tagIds:raw.themeIds)?.[i],slug:(useTags?raw.tagSlugs:raw.themeSlugs)?.[i]};
return contentSegment(identity)?`<a href="${contentPath(useTags?'tag':'topics',identity)}">${escape(tag)}</a>`:`<span>${escape(tag)}</span>`;
}).join('');
const related=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==id).slice(0,6);
const screen=()=>`${modeTabs}<div class="play-screen${chapter&&chapterRequiresPayment(chapter)?' play-paywalled-content':''}" data-play-mode="${mode}"><video ${INLINE_VIDEO_ATTRIBUTES} preload="metadata" ${imageAttributes(raw.cover||raw.coverImg,undefined,'poster')} aria-label="${escape(displayTitle)}"></video><div class="play-screen-overlay"><button type="button" aria-label="播放">▶</button><p class="play-source-status" role="status">正在准备视频…</p></div><div class="play-screen-controls"><button data-play-action="next" ${!chapter||ordered.at(-1)?.id===chapter.id?'disabled':''}>下一集</button></div></div>`;
const actions=()=>`<div class="play-detail-actions"><button type="button" data-engage="like" aria-pressed="${actionState.isLike===1}">${detailActionIcon('like')}<span>${actionState.isLike===1?'已点赞':'点赞'}</span></button><button type="button" data-engage="collect" aria-pressed="${actionState.isCollect===1}">${detailActionIcon('save')}<span>${actionState.isCollect===1?'已收藏':'收藏'}</span></button><button type="button" data-share>${detailActionIcon('share')}<span>分享</span></button></div>`;
const screen=()=>`${modeTabs}<div class="play-screen" data-play-mode="${mode}"><video ${INLINE_VIDEO_ATTRIBUTES} preload="metadata" ${imageAttributes(raw.cover||raw.coverImg,undefined,'poster')} aria-label="${escape(heading)}"></video><div class="play-screen-overlay"><button type="button" aria-label="播放">▶</button><p class="play-source-status" role="status">正在准备视频…</p></div><div class="play-screen-controls"><button data-play-action="next" ${!chapter||ordered.at(-1)?.id===chapter.id?'disabled':''}>下一集</button></div></div>`;
const applicationAds=promotionRow(raw.advitise?.nineGridList||home.advitise?.nineGridList);
const bannerAds=ads(raw.advitise?.detailbannerList,'play-banner-ad');
const watchInfo=()=>`<section class="hg-watch-info">${chapter?`<span class="play-episode-caption">正在观看 第 ${chapter.currentEpisode||1} 集 / 共 ${episodes} 集</span>`:''}<h1 id="play-detail-title">${escape(heading)}</h1>${detailFacts(raw,episodes,actionState)}${actions()}</section>${synopsisMarkup(raw,`《${displayTitle}》剧情简介`)}`;
return `
<nav class="page-breadcrumb" aria-label="面包屑导航"><a href="/">首页</a><span>/</span><a href="/${isVideo?'series':'shorts'}/">${isVideo?'剧集':'短剧'}</a><span>/</span>${overview||isVideo?`<span aria-current="page">${escape(displayTitle)}</span>`:`<a href="${contentPath('detail',raw)}">${escape(displayTitle)}</a><span>/</span><span aria-current="page">第${chapter?.currentEpisode||1}集</span>`}</nav>
<div class="play-detail-layout${related.length?'':' play-detail-layout--single'}"><div class="play-detail-main">
${overview?overviewMarkup(raw,first,actions()):screen()}
<nav class="page-breadcrumb" aria-label="面包屑导航"><a href="/">首页</a><span>/</span><a href="/shorts/">短剧</a><span>/</span>${overview||isVideo?`<span aria-current="page">${escape(displayTitle)}</span>`:`<a href="${contentPath('detail',raw)}">${escape(displayTitle)}</a><span>/</span><span aria-current="page">第${chapter?.currentEpisode||1}集</span>`}</nav>
<div class="play-detail-layout play-detail-layout--single hg-watch${overview?' hg-watch--overview':''}"><div class="play-detail-main">
${overview?overviewMarkup(raw,first,actions(),episodes):screen()+watchInfo()}
${applicationAds?`<section class="play-advertisements" aria-label="推荐应用广告">${applicationAds}</section>`:''}
${overview?'':`<section class="play-detail-info">${chapter?`<span class="play-episode-caption">正在观看 第 ${chapter.currentEpisode||1} 集</span>`:''}<h1 id="play-detail-title">${escape(heading)}</h1>${tags?`<div class="play-detail-tags"><span>分类标签:</span>${tags}</div>`:''}<div class="play-detail-meta">${!isVideo?`<span>集数:全${raw.totalEpisode||ordered.length}集</span>`:''}<span>${formatCount(raw.heatValue)}热度</span><span data-engage-count="collect" aria-live="polite">${formatCount(actionState.countCollect)}收藏</span></div><p class="play-detail-description">简介:${escape(raw.desc||raw.description||'')}</p>${actions()}</section>`}
${!isVideo?`<section class="play-episodes-section"><div class="play-section-heading"><h2>剧集</h2><span>全${raw.totalEpisode||ordered.length}集</span></div><div class="play-episode-pages" role="group" aria-label="集数分段">${episodeTabs(ordered,45,initialEpisodePage)}</div><div class="play-episode-grid" id="play-episode-list">${episodeItems(id,ordered,chapter,45,initialEpisodePage,raw.slug)}</div></section>`:''}
${!isVideo?`<section class="play-episodes-section" aria-labelledby="hg-episodes-title"><div class="play-section-heading"><h2 id="hg-episodes-title">《${escape(displayTitle)}》全集列表</h2><span>全${episodes}集</span></div><div class="play-episode-pages" role="group" aria-label="集数分段">${episodeTabs(ordered,45,initialEpisodePage)}</div><div class="play-episode-grid" id="play-episode-list">${episodeItems(id,ordered,chapter,45,initialEpisodePage,raw.slug,displayTitle)}</div></section>`:''}
${bannerAds?`<section class="play-banner-ads" aria-label="推广广告">${bannerAds}</section>`:''}
${officialLinks(config)}
<section class="play-comments"><div class="play-section-heading"><h2 id="play-comments-title">评论${comments?` (${comments.totalCount||0})`:""}</h2><div class="play-sort" role="group" aria-label="评论排序"><button type="button" data-comment-sort="1" aria-pressed="${commentSort===1}">热门</button><button type="button" data-comment-sort="2" aria-pressed="${commentSort===2}">最新</button></div></div>
<form class="play-comment-form"><img class="play-compose-avatar" ${imageAttributes(user?.avatar,'/icon.svg')} alt="我的头像"><div class="play-compose-body"><div id="reply-context"></div><textarea name="comment" aria-label="评论内容" maxlength="500" placeholder="说说你对这部剧的看法…" required></textarea><div class="play-comment-footer"><span><span id="play-comment-count">0</span> / 500</span><button type="submit">发表评论</button></div><p class="api-error" id="comment-error" role="alert"></p></div></form><div class="play-comment-list">${comments?commentListMarkup(comments,commentPage,commentSort,pageHref):empty('暂无评论')}</div>
</section>
</div>${related.length?`<aside class="play-related"><h2>热门短剧</h2><div class="play-related-list">${related.map(item=>card(item)).join('')}</div></aside>`:''}</div>`;
${recommendationsMarkup(raw)}
${officialLinks()}
</div></div>`;
}
export function episodeTabs(ordered:Chapter[],size:number,episodePage:number):string {
@@ -57,23 +50,10 @@ export function episodeTabs(ordered:Chapter[],size:number,episodePage:number):st
return pages?`<div class="episode-page-track">${Array.from({length:pages},(_,i)=>`<button type="button" data-episode-page="${i}" aria-pressed="${episodePage===i}" aria-controls="play-episode-list">${i*size+1}–${Math.min((i+1)*size,ordered.length)}</button>`).join('')}</div>`:'';
}
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,size:number,episodePage:number,slug?:string):string {
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,size:number,episodePage:number,slug?:string,name=''):string {
return ordered.slice(episodePage*size,(episodePage+1)*size).map((item,index)=>{
const number=item.currentEpisode||episodePage*size+index+1;
const gate=chapterListGate(item),locked=!!gate;
const content=`<span>${number}</span>${locked?'<span class="episode-lock-corner" aria-hidden="true">'+(gate==='coins'?'<span class="coin-symbol"><img class="coin-soft-star" src="/assets/4532d28d6a61c426.svg" alt="金币解锁图标"></span>':'<span class="episode-vip-mark">VIP</span>')+'</span>':''}${item.id===chapter?.id?'<span class="episode-playing" aria-hidden="true"><i></i><i></i><i></i></span>':''}`;
if(locked)return `<button type="button" class="play-episode-locked" data-locked-chapter="${item.id}" aria-label="第 ${number} 集,${gate==='coins'?'金币解锁':'会员观看'}">${content}</button>`;
return `<a href="${contentPath('play',{id,slug},item.id)}" data-drama-switch data-target-episode="${number}" data-target-video-id="${item.id}" ${item.id===chapter?.id?'aria-current="true"':''} aria-label="播放第 ${number} 集">${content}</a>`;
const content=`<span>${number}</span>${item.id===chapter?.id?'<span class="episode-playing" aria-hidden="true"><i></i><i></i><i></i></span>':''}`;
return `<a href="${contentPath('play',{id,slug},item.id)}" data-drama-switch data-target-episode="${number}" data-target-video-id="${item.id}" ${item.id===chapter?.id?'aria-current="true"':''} aria-label="播放第 ${number} 集"${name?` title="${escape(`《${name}》第${number}集`)}"`:''}>${content}</a>`;
}).join('')||empty('暂无剧集');
}
export function commentMarkup(item: Comment, child=false): string {
const children=recordList<Comment>(item.children);
const date=item.createdAt?new Date(item.createdAt):null;
const timestamp=date&&Number.isFinite(date.getTime())?`<time datetime="${date.toISOString()}">${escape(date.toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'}))}</time>`:'';
return `<article class="play-comment-item${child?' play-comment-child':''}"><img ${imageAttributes(item.userAvatar,'/icon.svg')} alt="${escape(item.userName||'用户')}的头像" loading="lazy"><div class="play-comment-body"><strong>${escape(item.userName||'用户')}</strong><p>${escape(item.text)}</p><div class="play-comment-meta">${timestamp}<button type="button" data-comment-like="${item.id}" data-comment-count="${typeof item.countLike==='number'&&Number.isFinite(item.countLike)?Math.max(0,item.countLike):0}" aria-pressed="${item.isLike===1}" aria-label="点赞 ${escape(item.userName||'用户')} 的评论"><span data-request-label>${item.isLike===1?'♥':'♡'} ${formatCount(item.countLike)}</span></button><button type="button" data-reply="${item.id}" data-parent="${item.parentsId||item.id}" data-reply-name="${escape(item.userName)}">回复</button></div>${children.length?`<div class="play-comment-replies" aria-label="评论回复">${children.map(comment=>commentMarkup(comment,true)).join('')}</div>`:''}</div></article>`;
}
export function commentListMarkup(data:PageData<Comment>,page:number,sort:number,href:(page:number,sort:number)=>string):string {
return (typeof data.error==='string'?empty(data.error,true):recordList<Comment>(data.list).map(item=>commentMarkup(item)).join('')||empty('暂无评论,来说说你的看法吧'))+pagination(data.totalCount||0,page,value=>href(value,sort),10);
}
+15 -66
View File
@@ -1,20 +1,18 @@
import { contentPath } from '../routing';
import type { Chapter, Comment, Drama, DramaDetail, PageData } from "../api/types";
import type { Chapter, Drama, DramaDetail, PageData } from "../api/types";
import type { SiteContext } from "./context";
import { empty, escape } from "./render";
import { configureDownloadLinks } from "./download";
import { bindSeriesDescription } from "./detail";
import { bindChapterUnlock } from "./unlock";
import { chapterAccess, chapterUnlockCost, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
import { chapterAccess, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
import { dramaSeoContent } from '../seo';
import type { PlayerInitialData } from '../page-data';
import { commentListMarkup, episodeItems, episodeTabs, orderedChapters, playerMarkup } from './player-markup';
import { episodeItems, episodeTabs, orderedChapters, playerMarkup } from './player-markup';
import { formatCount } from '../api/protocol';
import { applyEngagementSuccess, beginButtonRequest, type EngagementKind } from './request-feedback';
declare global { interface Window { installPlayerControls?: (screen: HTMLElement, episode?: number, actions?: PlayerActions)=>void } }
interface PlayerPagination {page?:number;commentSort?:number;pageHref?:(page:number,sort:number)=>string;initial?:PlayerInitialData;preserveMarkup?:boolean}
interface PlayerPagination {initial?:PlayerInitialData;preserveMarkup?:boolean}
export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,isVideo=false,paging:PlayerPagination={}):Promise<()=>void> {
if(isVideo)throw new Error('当前接口暂未提供剧集内容');
@@ -25,7 +23,7 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
if(!raw?.id||raw.id!==id)throw new Error('该剧集不存在或已下架');
const [chapters,related]=await Promise.all([
paging.initial?Promise.resolve(paging.initial.chapters):app.api.get<{list?:Chapter[]}>('playlet/chapter/list',{playletId:id},signal).then(data=>data?.list).catch(error=>{if(signal.aborted)throw error;return raw.playletChapterInfo;}),
paging.initial?Promise.resolve(undefined):app.api.get<PageData<Drama>>('playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:6},signal).catch(error=>{if(signal.aborted)throw error;return undefined;}),
paging.initial?Promise.resolve(undefined):app.api.get<PageData<Drama>>('playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:12},signal).catch(error=>{if(signal.aborted)throw error;return undefined;}),
]);
if(!paging.initial)raw.relatedRecommendData=related?.list||[];
if(signal.aborted)return ()=>{};
@@ -39,17 +37,13 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
}
if(signal.aborted)return ()=>{};
const overview=!chapterId&&!isVideo;
const unlockCost=chapterUnlockCost(chapter);
const source=isVideo?playbackUrl(raw.video?.url):'';
const access=isVideo?{allowed:!!source,gate:source?'':'unavailable',source} as const:chapterAccess(chapter);
const previewSource=isVideo?'':playbackUrl(raw.preview);
let mode:PlayMode=initialPlayMode(access,raw.preview);
page.classList.toggle('is-overview',overview);
const commentPage=paging.page||1,commentSort=paging.commentSort===1?1:2;
const basePath=contentPath(isVideo?'video':chapterId?'play':'detail',raw,chapterId);
const pageHref=paging.pageHref||((page:number,sort:number)=>`${basePath}${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`);
app.updateSeo?.({...dramaSeoContent(raw,chapter||undefined),failed:false,unavailable:isVideo&&!source});
const objectId=isVideo?id:chapter?.id||id, objectType=isVideo?20:chapter?11:10;
const objectId=isVideo?id:chapter?.id||id;
const actionPrefix=isVideo?'video':chapter?'playlet/chapter':'playlet';
const actionState=chapter||raw;
const pendingEngagements=new Set<EngagementKind>();
@@ -62,9 +56,8 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
const value=kind==='like'?actionState.countLike:actionState.countCollect;
page.querySelectorAll<HTMLElement>(`[data-engage-count="${kind}"]`).forEach(node=>{node.textContent=`${formatCount(value)}${kind==='like'?'点赞':'收藏'}`;});
}
if(!paging.preserveMarkup)page.innerHTML=playerMarkup(raw,ordered,{chapter,isVideo,page:commentPage,commentSort,pageHref,config:app.config,home:app.home,user:app.api.user,comments:paging.initial?.comments});
if(!paging.preserveMarkup)page.innerHTML=playerMarkup(raw,ordered,{chapter,isVideo,home:app.home});
configureDownloadLinks(app.config, page);
window.tracker?.setDrama({...raw,playletChapterInfo:ordered},chapter||first,ordered,isVideo);
const disposeDescription=bindSeriesDescription(page,signal);
let episodeSize=matchMedia('(max-width:760px)').matches?15:45;
let episodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter!.id))/episodeSize):0;
@@ -82,53 +75,32 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
if(tab.left<bounds.left)track.scrollLeft+=tab.left-bounds.left;
else if(tab.right>bounds.right)track.scrollLeft+=tab.right-bounds.right;
}
grid.innerHTML=episodeItems(id,ordered,chapter,size,episodePage,raw.slug);
grid.innerHTML=episodeItems(id,ordered,chapter,size,episodePage,raw.slug,raw.title||raw.playName||'');
}
renderEpisodes();
window.addEventListener('resize',renderEpisodes,{signal});
let commentSeq=0,replyParent=0,replyId=0;
let initialComments=paging.initial?.comments;
async function comments(){
const seq=++commentSeq;
const list=page.querySelector<HTMLElement>('.play-comment-list')!;
try{
const existing=initialComments;initialComments=undefined;
const data=existing || await app.api.get<PageData<Comment>>('comment/list',{objectId,objectType,pageNum:commentPage,pageSize:10,sortOrder:commentSort},signal);
if(signal.aborted||seq!==commentSeq)return;
app.updateSeo?.({totalCount:data.totalCount,pageSize:10});
page.querySelector('#play-comments-title')!.textContent=`评论 (${data.totalCount||0})`;
if(!(existing&&paging.preserveMarkup))list.innerHTML=commentListMarkup(data,commentPage,commentSort,pageHref);
}catch(error){if(!signal.aborted&&seq===commentSeq)list.innerHTML=empty(error instanceof Error?error.message:'评论加载失败');}
}
void comments();
const video=page.querySelector<HTMLVideoElement>('video');
const chapterUnlock=bindChapterUnlock(app,id,chapter,raw.slug);
let media:MediaPlayback|undefined;
let reported=false;
if(video){
const box=page.querySelector<HTMLElement>('.play-screen')!;
const status=page.querySelector<HTMLElement>('.play-source-status')!;
media=new MediaPlayback(video,{signal,status:message=>{status.textContent=message;},error:()=>{window.tracker?.playbackFailed(video);box.dispatchEvent(new Event('playback:error'));}});
const toggle=async()=>{if(mode==='main'&&!access.allowed){chapterUnlock.openCurrent();return;}await media?.toggle();};
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(mode==='main'&&!access.allowed){chapterUnlock.openCurrent();return;}window.tracker?.prepareDramaModeChange();await media?.retry();}});
media=new MediaPlayback(video,{signal,status:message=>{status.textContent=message;},error:()=>{box.dispatchEvent(new Event('playback:error'));}});
const toggle=async()=>{if(mode==='main'&&!access.allowed)return;await media?.toggle();};
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(mode==='main'&&!access.allowed)return;await media?.retry();}});
const position=ordered.findIndex(item=>item.id===chapter?.id);
const previous=box.querySelector<HTMLButtonElement>('[data-play-action="previous"]');
const next=box.querySelector<HTMLButtonElement>('[data-play-action="next"]');
if(previous)previous.disabled=position<=0;if(next)next.disabled=position<0||position>=ordered.length-1;
const unlock=document.createElement('button');unlock.type='button';unlock.className='primary';unlock.dataset.showUnlock='';
unlock.textContent=access.gate==='vip'?'查看会员套餐':`使用 ${unlockCost} 金币解锁`;
box.querySelector('.play-screen-overlay')!.append(unlock);
function setMode(nextMode:PlayMode){
window.tracker?.prepareDramaModeChange();
mode=nextMode;box.dataset.playMode=mode;
page.querySelectorAll<HTMLButtonElement>('[data-play-mode]').forEach(button=>{if(button.tagName==='BUTTON')button.setAttribute('aria-pressed',String(button.dataset.playMode===mode));});
const blocked=mode==='main'&&!access.allowed;
unlock.hidden=!blocked||!['coins','vip'].includes(access.gate);
box.querySelector('.ui-empty-state')?.remove();
const center=box.querySelector<HTMLButtonElement>('[data-media="toggle"]');if(center)center.hidden=false;
status.hidden=false;
void media!.setSource(mode==='preview'?previewSource:access.allowed?access.source:'');
if(blocked)status.textContent=access.gate==='vip'?'开通会员后可观看本集':access.gate==='coins'?'本集需要金币解锁':'本集暂不可观看';
if(blocked)status.textContent='本集视频暂未开放,请稍后再试';
else if(!(mode==='preview'?previewSource:access.source))status.textContent='该剧集暂未提供视频源';
else if(mode==='preview')status.textContent='免费预告 · 点击播放';
}
@@ -143,11 +115,9 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
}
page.addEventListener('click',event=>{void (async()=>{
const element=(event.target as Element).closest<HTMLButtonElement>('button');if(!element||element.disabled)return;
if(element.dataset.lockedChapter){const selected=ordered.find(item=>item.id===Number(element.dataset.lockedChapter));if(selected)await chapterUnlock.openChapter(selected);return;}
if(element.dataset.episodePage){episodePage=Number(element.dataset.episodePage);renderEpisodes();}
if(element.dataset.episodePage==='0'){episodePage=0;renderEpisodes();}
if(element.hasAttribute('data-show-unlock'))chapterUnlock.openCurrent();
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next){window.tracker?.dramaStatusChange(next);app.navigate(contentPath('play',raw,next.id));}}
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next){app.navigate(contentPath('play',raw,next.id));}}
if(element.dataset.engage){
const kind=element.dataset.engage;if(kind!=='like'&&kind!=='collect'||pendingEngagements.has(kind))return;
const active=kind==='like'?actionState.isLike===1:actionState.isCollect===1;
@@ -162,28 +132,7 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
}finally{pendingEngagements.delete(kind);finish.forEach(done=>done());}
return;
}
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted){window.tracker?.dramaEvent('ep_share');app.notify('页面链接已复制');}}finally{finish();}return;}
if(element.dataset.commentSort){const sort=Number(element.dataset.commentSort);if(sort===1||sort===2)app.navigate(pageHref(1,sort));return;}
if(element.dataset.reply){replyId=Number(element.dataset.reply);replyParent=Number(element.dataset.parent);page.querySelector('#reply-context')!.innerHTML=`<div class="play-reply-context"><span>回复 @${escape(element.dataset.replyName)}</span><button type="button" data-cancel-reply>取消回复</button></div>`;page.querySelector<HTMLTextAreaElement>('textarea')!.focus();}
if(element.hasAttribute('data-cancel-reply')){replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';}
if(element.dataset.commentLike){
const active=element.getAttribute('aria-pressed')==='true',state={isLike:active?1:0,countLike:Number(element.dataset.commentCount)||0};
const finish=beginButtonRequest(element);
try{
const result=await app.api.post<unknown>('comment/like/set',{commentId:Number(element.dataset.commentLike),isLike:!active});
if(signal.aborted)return;
applyEngagementSuccess(state,'like',active,result);
element.setAttribute('aria-pressed',String(state.isLike===1));element.dataset.commentCount=String(state.countLike);
const label=element.querySelector('[data-request-label]')||element;label.textContent=`${state.isLike===1?'♥':'♡'} ${formatCount(state.countLike)}`;
}finally{finish();}
}
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted)app.notify('页面链接已复制');}finally{finish();}return;}
})().catch(error=>app.notify(error instanceof Error?error.message:'操作失败'));},{signal});
page.querySelector('textarea[name="comment"]')?.addEventListener('input',event=>{const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent=String((event.target as HTMLTextAreaElement).value.length);},{signal});
page.querySelector('form')!.addEventListener('submit',event=>{
event.preventDefault();const form=event.target as HTMLFormElement;const input=form.elements.namedItem('comment') as HTMLTextAreaElement;const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
if(button.disabled||!input.value.trim())return;
const finish=beginButtonRequest(button,'发送中');
void app.api.post('comment/send',{objectId,objectType,text:input.value.trim(),parentsId:replyParent,replyCommentId:replyId}).then(async()=>{if(signal.aborted)return;input.value='';const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent='0';replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';page.querySelector('#comment-error')!.textContent='';if(commentPage>1){app.navigate(pageHref(1,commentSort));return;}await comments();}).catch(error=>{if(!signal.aborted)page.querySelector('#comment-error')!.textContent=error.message;}).finally(finish);
},{signal});
return ()=>{disposeDescription();chapterUnlock.dispose();media?.dispose();};
return ()=>{disposeDescription();media?.dispose();};
}
+23 -10
View File
@@ -9,7 +9,6 @@ export function imageAttributes(value: unknown, fallback = PLACEHOLDER, target:
return `${target}="${escape(fallback)}"${source ? ` data-${target}="${escape(source)}" data-fallback="${escape(fallback)}"` : ''}`;
}
export const title = (drama: Drama): string => drama.title || drama.playName || "未命名剧集";
export const itemAttributes=(item:Drama,video=false)=>`data-track-item data-item-id="${escape(item.id)}" data-item-type="${video?'video':'short_drama'}" data-item-name="${escape(title(item))}" data-recommend-trace-id="${escape(item.recommendTraceId)}" data-recommend-id="${escape(item.recommendId)}" data-recommend-trace-info="${escape(item.recommendTraceInfo)}"`;
export function activeBanners(home: HomeData | null | undefined): Drama[] {
const items=recordList<Drama>(home?.homeBannerData);
return (items.some(item=>item.showOnBanner)?items.filter(item=>item.showOnBanner):items).slice(0,6);
@@ -33,36 +32,50 @@ export function highlightSearchTitle(text:string,term:string):string {
return result+escape(text.slice(cursor));
}
const genreNames=(drama:Drama):string[]=>(Array.isArray(drama.tagNames)?drama.tagNames:Array.isArray(drama.themeNames)?drama.themeNames:[]).filter((tag):tag is string=>typeof tag==='string'&&!!tag.trim());
const episodeLabel=(drama:Drama):string=>drama.totalEpisode?`全${drama.totalEpisode}集`:'';
const badgeMarkup=(drama:Drama,prefix:string):string=>drama.isFeatured?`<span class="${prefix}-badge ${prefix}-badge--hot">爆剧</span>`:drama.isNew?`<span class="${prefix}-badge ${prefix}-badge--new">新剧</span>`:'';
/** Poster card: the whole tile is one descriptive link, with visible title, genres, synopsis and heat for crawlers. */
export function card(drama: Drama, video = false, options: {rank?:number;keyword?:string;headingLevel?:2|3} = {}): string {
if (!drama || typeof drama !== 'object' || Array.isArray(drama)) return '';
const tags = (Array.isArray(drama.tagNames) ? drama.tagNames : Array.isArray(drama.themeNames) ? drama.themeNames : []).filter(tag => typeof tag === 'string');
const badge = drama.isFeatured ? '<span class="cover-corner-badge cover-corner-badge--hot">爆剧</span>' : drama.isNew ? '<span class="cover-corner-badge cover-corner-badge--new">新剧</span>' : "";
const poster=`<img class="poster-source" ${imageAttributes(drama.cover || drama.coverImg || drama.horizontalCover)} alt="${escape(title(drama))}封面" loading="lazy" decoding="async">`;
const tags=genreNames(drama),episodes=video?'':episodeLabel(drama);
const rank=typeof options.rank==='number'&&Number.isSafeInteger(options.rank)&&options.rank>0?options.rank:undefined;
const text=title(drama),keyword=options.keyword?.trim();
const heading=keyword?highlightSearchTitle(text,keyword):escape(text);
const headingTag=options.headingLevel===2?'h2':'h3';
return `<a class="card drama-data-card" ${itemAttributes(drama,video)} href="${contentPath(video?"video":"detail",drama)}" aria-label="查看${escape(text)}"><span class="cover">${rank?`<span class="ranking-poster-frame">${poster}</span>`:poster}${badge}${!video&&drama.totalEpisode?`<span class="drama-episodes">${drama.totalEpisode}集</span>`:""}${rank?`<span class="rank-home-badge" data-rank="${rank}" aria-label="第 ${rank} 名">${String(rank).padStart(2,'0')}</span>`:''}</span><${headingTag}>${heading}</${headingTag}><span class="drama-description">${escape(drama.desc || drama.description || "")}</span><span class="drama-meta"><span class="drama-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join("")}</span><span class="drama-heat"><svg class="drama-heart-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-9-5.7-9-12a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.3-9 12-9 12Z"/></svg>${formatCount(drama.countLike ?? drama.heatValue ?? 0)}</span></span></a>`;
const description=drama.desc||drama.description||'';
return `<article class="hg-card${rank?' hg-card--ranked':''}"><a class="hg-card-link" href="${contentPath(video?"video":"detail",drama)}" title="${escape(`《${text}》${episodes?episodes+' ':''}在线观看`)}"><span class="hg-card-cover"><img class="poster-source" ${imageAttributes(drama.cover || drama.coverImg || drama.horizontalCover)} alt="《${escape(text)}》海报" loading="lazy" decoding="async">${badgeMarkup(drama,'hg-card')}${episodes?`<span class="hg-card-eps">${episodes}</span>`:''}${rank?`<span class="hg-card-rank" data-rank="${rank}" aria-label="第 ${rank} 名">${String(rank).padStart(2,'0')}</span>`:''}</span><span class="hg-card-body"><${headingTag} class="hg-card-title">${heading}</${headingTag}>${tags.length?`<span class="hg-card-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join('')}</span>`:''}${description?`<span class="hg-card-desc">${escape(description)}</span>`:''}<span class="hg-card-heat"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-9-5.7-9-12a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.3-9 12-9 12Z"/></svg>${formatCount(drama.countLike ?? drama.heatValue ?? 0)}</span></span></a></article>`;
}
/** Horizontal recommendation row: thumbnail plus a linked heading, episode count, genres and synopsis. */
export function videoItem(drama: Drama, headingLevel:2|3=3): string {
if (!drama || typeof drama !== 'object' || Array.isArray(drama)) return '';
const text=title(drama),href=contentPath('detail',drama),tags=genreNames(drama),episodes=episodeLabel(drama),headingTag=headingLevel===2?'h2':'h3';
const description=drama.desc||drama.description||'';
return `<article class="hg-vitem"><a class="hg-vitem-thumb" href="${href}" tabindex="-1" aria-hidden="true"><img ${imageAttributes(drama.cover || drama.coverImg || drama.horizontalCover)} alt="《${escape(text)}》剧照" loading="lazy" decoding="async">${episodes?`<span>${episodes}</span>`:''}</a><div class="hg-vitem-body"><${headingTag} class="hg-vitem-title"><a href="${href}" title="${escape(`《${text}》在线观看`)}">${escape(text)}</a></${headingTag}><p class="hg-vitem-meta">${[...tags.slice(0,2).map(escape),episodes,`${formatCount(drama.heatValue ?? drama.countLike ?? 0)}热度`].filter(Boolean).map(value=>`<span>${value}</span>`).join('')}</p>${description?`<p class="hg-vitem-desc">${escape(description)}</p>`:''}</div></article>`;
}
/** Featured stage plus a numbered, crawlable list of every banner; list links switch slides before navigating. */
export function hero(value: unknown, selected = 0): string {
const items = recordList<Drama>(value);
if (!items.length) return empty("暂无推荐剧集");
const current = items[selected] || items[0];
return `<img class="hero-art fade" ${imageAttributes(current.banner || current.horizontalCover || current.cover)} alt="${escape(title(current))}推荐海报" fetchpriority="high"><div class="hero-content fade"><div class="eyebrow"><b>本周主推</b> THE SPOTLIGHT</div><h2>${escape(title(current))}</h2><div class="english"></div><p>${escape(current.desc || current.description)}</p><div class="meta hero-rating"><span>${formatCount(current.heatValue || current.countBrowse)}热度</span><span>${escape(current.themeNames?.[0] || current.tagNames?.[0])}</span><span>全${current.totalEpisode || 0}集</span></div><div class="actions"><a class="primary" ${itemAttributes(current)} href="${contentPath('detail',current)}"><svg class="hero-rounded-play" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M6 3.8C4.9 3.15 3.5 3.95 3.5 5.25v9.5c0 1.3 1.4 2.1 2.5 1.45l8.1-4.75c1.1-.65 1.1-2.25 0-2.9L6 3.8Z"/></svg><span>立即观看</span></a></div></div><div class="hero-previews"><button class="hero-preview-arrow prev" data-hero-step="-1" aria-label="上一部">‹</button>${items.map((item,index)=>`<button class="hero-preview ${index===selected?"active":""}" data-feature="${index}" aria-label="主推${escape(title(item))}" aria-pressed="${index===selected}"><img ${imageAttributes(item.cover || item.horizontalCover)} alt="${escape(title(item))}轮播缩略图" decoding="async"></button>`).join("")}<button class="hero-preview-arrow next" data-hero-step="1" aria-label="下一部">›</button></div><div class="hero-mobile-pagination" role="group" aria-label="轮播切换">${items.map((_,i)=>`<button data-feature="${i}" aria-label="切换到第 ${i+1} 张轮播" aria-pressed="${i===selected}"><span></span></button>`).join("")}<span class="hero-mobile-count">${String(selected+1).padStart(2,"0")} / ${String(items.length).padStart(2,"0")}</span></div><div class="hero-bottom"><div class="dots">${items.map((_,i)=>`<i class="${i===selected?"on":""}"></i>`).join("")}</div><span>${String(selected+1).padStart(2,"0")} / ${String(items.length).padStart(2,"0")}</span></div>`;
const index=items[selected]?selected:0,current=items[index],text=title(current),href=contentPath('detail',current);
const meta=[genreNames(current)[0],episodeLabel(current),`${formatCount(current.heatValue || current.countBrowse)}热度`].filter(Boolean);
return `<div class="hg-hero-stage"><img class="hero-art fade" ${imageAttributes(current.banner || current.horizontalCover || current.cover)} alt="《${escape(text)}》推荐海报" fetchpriority="high"><div class="hg-hero-copy fade"><span class="hg-hero-label">今日推荐 · NO.${String(index+1).padStart(2,'0')}</span><h2 class="hg-hero-title"><a href="${href}">${escape(text)}</a></h2><p class="hg-hero-meta">${meta.map(item=>`<span>${escape(item)}</span>`).join('')}</p>${current.desc||current.description?`<p class="hg-hero-desc">${escape(current.desc || current.description)}</p>`:''}<div class="hg-hero-actions"><a class="primary" href="${href}"><svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M6 3.8C4.9 3.15 3.5 3.95 3.5 5.25v9.5c0 1.3 1.4 2.1 2.5 1.45l8.1-4.75c1.1-.65 1.1-2.25 0-2.9L6 3.8Z"/></svg><span>立即观看</span></a></div></div></div><ol class="hg-hero-list" aria-label="今日推荐剧集">${items.map((item,i)=>`<li><a href="${contentPath('detail',item)}" data-feature="${i}"${i===index?' aria-current="true"':''}><span class="hg-hero-num">${String(i+1).padStart(2,'0')}</span><img ${imageAttributes(item.cover || item.horizontalCover)} alt="《${escape(title(item))}》缩略图" loading="lazy" decoding="async"><span class="hg-hero-item"><strong>${escape(title(item))}</strong>${episodeLabel(item)?`<small>${episodeLabel(item)}</small>`:''}</span></a></li>`).join("")}</ol>`;
}
export function topicCard(topic: Topic, theme = false, headingLevel:2|3=3): string {
if (!topic || typeof topic !== 'object' || Array.isArray(topic)) return '';
const headingTag=headingLevel===2?'h2':'h3';
return `<a class="topic-recommendation-card" data-track-item data-item-id="${escape(topic.id)}" data-item-type="topic" data-item-name="${escape(topic.name||'精选专题')}" href="${contentPath('topics',topic)}"><img ${imageAttributes(topic.icon || topic.covers?.[0] || topic.list?.[0]?.cover)} alt="${escape(String(topic.name || "").trim() || (theme ? "题材短剧" : "精选专题"))}封面" loading="lazy"><div class="topic-recommendation-copy"><small>${topic.playletCount || 0}部短剧</small><${headingTag}>${escape(topic.name)}</${headingTag}></div></a>`;
return `<a class="topic-recommendation-card" href="${contentPath('topics',topic)}"><img ${imageAttributes(topic.icon || topic.covers?.[0] || topic.list?.[0]?.cover)} alt="${escape(String(topic.name || "").trim() || (theme ? "题材短剧" : "精选专题"))}封面" loading="lazy"><div class="topic-recommendation-copy"><small>${topic.playletCount || 0}部短剧</small><${headingTag}>${escape(topic.name)}</${headingTag}></div></a>`;
}
export function ads(items: unknown = [], className = "promo-app"): string {
const tile=className==='promo-tile';
return recordList<Advertisement>(items).filter(item=>safeUrl(item.images)).map(item=>{
const label=escape(String(item.title || "").trim() || "推广广告");
const image=`<img ${imageAttributes(item.images)} alt="${label}" loading="lazy"${tile?' width="132" height="132" decoding="async"':''}>`;
return `<a class="${escape(className)}" data-track-ad="external" data-ad-id="${escape(item.advertiseCode||item.id)}" data-ad-clickable="${safeUrl(item.link)?1:0}" data-ad-slot-key="${escape(item.advertiseLocationCode||String(item.location||(tile?'promo-app':className)))}" data-ad-slot-name="${escape(item.advertiseLocationName||"推广广告")}" data-ad-type="${className==="promo-app"||tile?"icon":"banner"}" data-creative-id="" href="${escape(safeUrl(item.link)||"/apps/")}" target="_blank" rel="sponsored nofollow noopener noreferrer">${tile?`<span class="promo-tile-image">${image}</span><strong>${escape(item.title)}</strong>`:`${image}<span>${escape(item.title)}</span>`}</a>`;
return `<a class="${escape(className)}" data-ad href="${escape(safeUrl(item.link)||"/apps/")}" target="_blank" rel="sponsored nofollow noopener noreferrer">${tile?`<span class="promo-tile-image">${image}</span><strong>${escape(item.title)}</strong>`:`${image}<span>${escape(item.title)}</span>`}</a>`;
}).join("");
}
@@ -82,7 +95,7 @@ export function renderHome(home: HomeData | null | undefined, error?: string): s
const banners = activeBanners(home);
const blocks = recordList<HomeBlock>(home?.homeConfigData).filter(block=>block.status&&[1,3].includes(block.type));
const announcement = recordList<{content?: string; title?: string}>(home?.announcements).map(item=>item.content||item.title).filter(Boolean).join(" · ");
return `<section class="hero-grid"><div class="hero" id="hero">${hero(banners)}</div></section><section class="home-promotions" aria-label="公告与推荐应用">${announcement?`<div class="announcement-bar"><span class="announcement-label" aria-hidden="true">◈</span><div class="announcement-viewport" tabindex="0" aria-label="${escape(announcement)}"><div class="announcement-track" aria-hidden="true"><span>${escape(announcement)} · </span><span>${escape(announcement)} · </span></div></div></div>`:""}${promotionRow(home?.advitise?.nineGridList)}</section><div id="home-blocks">${error?empty(error,true):blocks.map(blockSection).join("")||empty()}</div><section class="topic-section popular-searches"><div class="section-head"><h2>大家都在搜</h2></div><nav class="popular-search-tags" id="popular-search-tags" aria-label="大家都在搜关键词">${recordList<{keyword:string}>(home?.hotSearch).map((item,index)=>`<a class="popular-search-tag popular-search-tag--${index%5}" href="${escape(searchPath(item.keyword||''))}">${escape(item.keyword)}</a>`).join('')}</nav></section><section class="bottom-app-prompt" data-app-download-entry hidden><div class="bottom-app-copy"><div class="bottom-app-eyebrow">91短剧 App</div><h2>在 App 上继续追剧,离线缓存随时看</h2><p>竖屏沉浸播放 · 离线缓存 · 弹幕互动</p></div><a class="bottom-app-download" data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer">点击下载</a></section>`;
return `<section class="hero-grid"><div class="hero" id="hero">${hero(banners)}</div></section><section class="home-promotions" aria-label="公告与推荐应用">${announcement?`<div class="announcement-bar"><span class="announcement-label" aria-hidden="true">◈</span><div class="announcement-viewport" tabindex="0" aria-label="${escape(announcement)}"><div class="announcement-track" aria-hidden="true"><span>${escape(announcement)} · </span><span>${escape(announcement)} · </span></div></div></div>`:""}${promotionRow(home?.advitise?.nineGridList)}</section><div id="home-blocks">${error?empty(error,true):blocks.map(blockSection).join("")||empty()}</div><section class="topic-section popular-searches"><div class="section-head"><h2>大家都在搜</h2></div><nav class="popular-search-tags" id="popular-search-tags" aria-label="大家都在搜关键词">${recordList<{keyword:string}>(home?.hotSearch).map((item,index)=>`<a class="popular-search-tag popular-search-tag--${index%5}" href="${escape(searchPath(item.keyword||''))}">${escape(item.keyword)}</a>`).join('')}</nav></section><section class="bottom-app-prompt" data-app-download-entry hidden><div class="bottom-app-copy"><div class="bottom-app-eyebrow">黄果吃瓜 App</div><h2>在 App 上继续追剧,离线缓存随时看</h2><p>竖屏沉浸播放 · 离线缓存 · 弹幕互动</p></div><a class="bottom-app-download" data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer">点击下载</a></section>`;
}
export type PageHref=(page:number)=>string;
+2 -2
View File
@@ -2,7 +2,7 @@ import { contentPath, searchPath } from '../routing';
import type { Drama, PageData } from '../api/types';
import { recordList } from '../api/protocol';
import type { ApiClient } from './api';
import { card, escape, imageAttributes, itemAttributes, pagination, title, type PageHref } from './render';
import { card, escape, imageAttributes, pagination, title, type PageHref } from './render';
export const SEARCH_PAGE_SIZE=25;
@@ -21,7 +21,7 @@ export function searchPicksMarkup(value:unknown):string {
return recordList<Drama>(value).slice(0,3).map(item=>{
const name=title(item);
const badge=item.isFeatured?'<span class="cover-corner-badge cover-corner-badge--hot">爆剧</span>':item.isNew?'<span class="cover-corner-badge cover-corner-badge--new">新剧</span>':'';
return `<a class="search-pick" ${itemAttributes(item)} href="${contentPath('detail',item)}" aria-label="查看${escape(name)}"><span class="search-pick-cover"><img class="poster-source" ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(name)}封面" width="160" height="240" loading="lazy" decoding="async">${badge}${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><span class="search-pick-body"><strong>${escape(name)}</strong><small>${escape(item.desc||item.description||'')}</small></span></a>`;
return `<a class="search-pick" href="${contentPath('detail',item)}" aria-label="查看${escape(name)}"><span class="search-pick-cover"><img class="poster-source" ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(name)}封面" width="160" height="240" loading="lazy" decoding="async">${badge}${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><span class="search-pick-body"><strong>${escape(name)}</strong><small>${escape(item.desc||item.description||'')}</small></span></a>`;
}).join('');
}
+3 -29
View File
@@ -205,15 +205,6 @@ export function startStartupDialogs(): StartupDialogs {
const media = ad.querySelector<HTMLAnchorElement>('.startup-ad-media')!;
const loading = ad.querySelector<HTMLElement>('.startup-ad-loading')!;
const close = ad.querySelector<HTMLButtonElement>('.startup-close')!;
function track(eventType: string, element: HTMLElement = media) {
try {
if (eventType === 'close') {
if (window.tracker?.advertising) window.tracker.advertising(element, eventType);
else document.dispatchEvent(new CustomEvent('dialog-ad:track', {detail: {element, eventType}}));
} else if (window.tracker?.externalAd) window.tracker.externalAd(element, eventType);
else document.dispatchEvent(new CustomEvent('external-ad:track', {detail: {element, eventType}}));
} catch { /* Tracking does not interrupt display or dismissal. */ }
}
const view: StartupView = {
showAge() {if (!age.open) age.showModal();},
closeAge() {
@@ -236,19 +227,12 @@ export function startStartupDialogs(): StartupDialogs {
image.alt = image.title = item.title?.trim() || '推荐广告';
media.setAttribute('aria-label', item.title || '查看广告');
if (item.link) media.href = item.link; else media.removeAttribute('href');
media.dataset.adId = item.advertiseCode || item.id || item.images;
media.dataset.adClickable = item.link ? '1' : '0';
media.dataset.adLocation = String(item.location || '');
media.dataset.adSlotKey = item.advertiseLocationCode || String(item.location || '');
media.dataset.adSlotName = item.advertiseLocationName || '弹窗广告位';
media.dataset.adPlacement = 'startup-ad';
media.hidden = false; loading.hidden = true; close.disabled = false;
ad.removeAttribute('aria-busy');
ad.setAttribute('aria-label', item.title || '广告推荐');
if (!ad.open) ad.showModal();
track('show');
},
hideAdvertisement() {if (ad.open) {if (!media.hidden) track('close'); ad.close();} media.hidden = true; loading.hidden = true; ad.removeAttribute('aria-busy');},
hideAdvertisement() {if (ad.open) ad.close(); media.hidden = true; loading.hidden = true; ad.removeAttribute('aria-busy');},
async loadGrid(items, signal) {
const links = new Map<Advertisement, HTMLAnchorElement>();
const loaded = await Promise.all(items.map(async item => {
@@ -258,14 +242,6 @@ export function startStartupDialogs(): StartupDialogs {
link.rel = 'sponsored nofollow noopener noreferrer';
link.setAttribute('aria-label', item.title || '查看推荐应用');
if (item.link) link.href = item.link;
link.dataset.trackAd = 'external';
link.dataset.adPlacement = 'startup-grid';
link.dataset.adType = 'icon';
link.dataset.adId = item.advertiseCode || item.id || item.images;
link.dataset.adClickable = item.link ? '1' : '0';
link.dataset.adLocation = String(item.location || '');
link.dataset.adSlotKey = item.advertiseLocationCode || String(item.location || 'nineGridList');
link.dataset.adSlotName = item.advertiseLocationName || '宫格弹窗广告位';
const icon = document.createElement('img');
icon.width = icon.height = 80;
icon.alt = item.title || '推荐应用';
@@ -287,9 +263,8 @@ export function startStartupDialogs(): StartupDialogs {
promotions.replaceChildren(...items.map(item => gridLinks.get(item)!));
promotions.scrollTop = 0;
if (!grid.open) grid.showModal();
document.dispatchEvent(new Event('tracker:refresh'));
},
hideGrid() {if (grid.open) {promotions.querySelectorAll<HTMLElement>('.startup-promotion').forEach(link => track('close', link)); grid.close();}},
hideGrid() {if (grid.open) grid.close();},
};
const storage: SessionStore = {
getItem: key => window.sessionStorage.getItem(key),
@@ -300,7 +275,7 @@ export function startStartupDialogs(): StartupDialogs {
age.addEventListener('cancel', event => event.preventDefault());
age.querySelector('.startup-age-confirm')!.addEventListener('click', () => startup.confirmAge());
age.querySelectorAll('.startup-age-leave,.startup-close').forEach(button => button.addEventListener('click', () => location.assign('https://www.google.com')));
function nextAdvertisement() {if (ad.open && !media.hidden) track('close'); startup.nextAdvertisement();}
function nextAdvertisement() {startup.nextAdvertisement();}
close.addEventListener('click', nextAdvertisement);
ad.addEventListener('cancel', event => {event.preventDefault(); nextAdvertisement();});
grid.querySelector('.startup-close')!.addEventListener('click', () => startup.dismissGrid());
@@ -314,6 +289,5 @@ export function startStartupDialogs(): StartupDialogs {
dialog.addEventListener('pointerdown', () => dialog.classList.remove('startup-keyboard-focus'));
dialog.addEventListener('close', () => dialog.classList.remove('startup-keyboard-focus'));
});
media.addEventListener('click', () => {if (ad.open && !media.hidden && media.hasAttribute('href')) track('click');});
return startup;
}
-122
View File
@@ -1,122 +0,0 @@
import { contentPath } from '../routing';
import type { Chapter } from '../api/types';
import { ApiError, EXPIRED_CODES } from '../api/protocol';
import type { SiteContext } from './context';
import { chapterAccess, chapterUnlockCost } from './playback';
export function bindChapterUnlock(app: SiteContext, playletId: number, currentChapter: Chapter | null, slug?:string) {
const signal = app.signal;
const dialog = document.getElementById('play-episode-lock') as HTMLDialogElement;
const pay = dialog.querySelector<HTMLButtonElement>('.unlock-pay')!;
const close = dialog.querySelector<HTMLButtonElement>('.unlock-close')!;
const error = dialog.querySelector<HTMLElement>('#episode-lock-error')!;
const description = dialog.querySelector<HTMLElement>('#episode-lock-description')!;
let selected: Chapter | null = null;
let lookup = 0;
let unlocking = false;
const showError = (message = '') => { error.textContent = message; error.hidden = !message; };
const play = (chapter: Chapter) => app.navigate(contentPath('play',{id:playletId,slug},chapter.id));
const recharge = () => { dialog.close(); app.navigate('/info/deposit/'); };
const balance = (chapter: Chapter) => chapter.balance ?? app.api.user?.recharge ?? 0;
const failed = (reason: unknown) => {
if (reason instanceof ApiError && (reason.code === 401 || EXPIRED_CODES.has(reason.code))) {
dialog.close(); app.openAuth(); return;
}
showError(reason instanceof Error ? reason.message : '读取章节失败,请重试');
};
function present(chapter: Chapter) {
if (signal.aborted) return;
const access = chapterAccess(chapter);
if (access.allowed) { dialog.close(); play(chapter); return; }
if (access.gate === 'vip') { dialog.close(); app.navigate('/info/deposit/vip/'); return; }
if (access.gate !== 'coins') { selected = null; pay.disabled = true; showError('本集暂不可解锁,请稍后重试'); return; }
selected = chapter;
const cost = chapterUnlockCost(chapter), coins = balance(chapter);
description.textContent = `第 ${chapter.currentEpisode || 1} 集 · 解锁后可永久观看本集内容。`;
dialog.querySelector('#episode-lock-balance')!.textContent = `${coins} 金币`;
dialog.querySelector('.unlock-cost')!.textContent = `${cost} 金币`;
pay.textContent = coins < cost ? '金币不足,去充值' : cost > 0 ? `支付 ${cost} 金币并播放` : '解锁并播放';
pay.setAttribute('aria-label',coins < cost ? `金币不足,充值后解锁第 ${chapter.currentEpisode || 1} 集` : `确认解锁第 ${chapter.currentEpisode || 1} 集,消耗 ${cost} 金币`);
pay.disabled = unlocking;
close.disabled = unlocking;
showError();
if (!dialog.open) dialog.showModal();
}
async function openChapter(summary: Chapter) {
if (signal.aborted || unlocking) return;
if (dialog.open && dialog.dataset.chapterId === String(summary.id)) return;
const sequence = ++lookup;
selected = null;
dialog.dataset.chapterId = String(summary.id);
description.textContent = `正在读取第 ${summary.currentEpisode || 1} 集解锁信息…`;
dialog.querySelector('#episode-lock-balance')!.textContent = '— 金币';
dialog.querySelector('.unlock-cost')!.textContent = '— 金币';
pay.textContent = '读取中…'; pay.disabled = true; close.disabled = false;
showError();
if (!dialog.open) dialog.showModal();
try {
const chapter = await app.api.get<Chapter>('playlet/chapter/detail',{playletId,chapterId:summary.id},signal);
if (signal.aborted || sequence !== lookup || !dialog.open) return;
if (!chapter || chapter.id !== summary.id || chapter.playletId && chapter.playletId !== playletId) throw new Error('章节信息不匹配,请重新选择');
present(chapter);
} catch (reason) { if (!signal.aborted && sequence === lookup && dialog.open) failed(reason); }
}
function openCurrent() {
if (!currentChapter || signal.aborted || unlocking || chapterAccess(currentChapter).allowed) return;
lookup++;
dialog.dataset.chapterId = String(currentChapter.id);
present(currentChapter);
}
close.addEventListener('click',() => { if (!unlocking) dialog.close(); },{signal});
dialog.addEventListener('cancel',event => { if (unlocking) event.preventDefault(); },{signal});
dialog.addEventListener('close',() => { lookup++; selected = null; },{signal});
dialog.addEventListener('click',event => {
if (event.target !== dialog || unlocking) return;
const bounds = dialog.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dialog.close();
},{signal});
dialog.querySelector('.unlock-recharge')?.addEventListener('click',event => { if (unlocking) event.preventDefault(); else dialog.close(); },{signal});
pay.addEventListener('click',() => { void (async () => {
if (signal.aborted || unlocking || pay.disabled || !dialog.open || !selected) return;
const chapter = selected;
if (balance(chapter) < chapterUnlockCost(chapter)) { recharge(); return; }
const confirmText = pay.textContent;
unlocking = true; pay.disabled = true; close.disabled = true; pay.textContent = '解锁中…';
dialog.setAttribute('aria-busy','true'); showError();
try {
const result = await app.api.post<{balance?:number}>('playlet/chapter/unlock',{chapterId:chapter.id,playletId});
if (signal.aborted) return;
if (app.api.user && typeof result?.balance === 'number' && Number.isFinite(result.balance) && result.balance >= 0) app.api.setUser({...app.api.user,recharge:result.balance});
await app.api.refreshUser().catch(() => {});
if (signal.aborted) return;
dialog.close(); app.notify('解锁成功');
window.tracker?.dramaStatusChange(chapter,'ep_unlock');
if (chapter.id === currentChapter?.id) await app.route();
else play(chapter);
} catch (reason) {
if (signal.aborted) return;
if ((reason instanceof ApiError && reason.code === 400) || (reason instanceof Error && /余额.*不足|金币.*不足/.test(reason.message))) { recharge(); return; }
failed(reason);
} finally {
unlocking = false;
if (!signal.aborted) {
dialog.removeAttribute('aria-busy'); close.disabled = false;
pay.disabled = !selected || !dialog.open;
if (selected && dialog.open) pay.textContent = confirmText;
}
}
})(); },{signal});
const dispose = () => {
lookup++; selected = null;
dialog.removeAttribute('aria-busy'); close.disabled = false; pay.disabled = true;
if (dialog.open) dialog.close();
};
signal.addEventListener('abort',dispose,{once:true});
return {openChapter,openCurrent,dispose};
}
+16 -57
View File
@@ -1,15 +1,13 @@
import type { Config, FooterContact } from "./api/types";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "./site";
/** Original HTML copy and markup; the about page's contacts use site configuration. */
/** Original HTML copy and markup; the only contact is the official Telegram account. */
export interface InfoPageDefinition {key:string;title:string;html:string}
const EMPTY_CONTACT_SECTION='<section class="about-section" data-site-contacts hidden></section>';
export const INFO_PAGES:Readonly<Record<string,InfoPageDefinition>>={
"about-us": {key:"about-us",title:"关于我们",html:`
<section class="about-section">
<h2>品牌介绍</h2>
<p>91短剧为喜爱短剧的观众汇集多种题材与精选作品,让找剧、追剧和观看变得轻松便捷。</p>
<p>黄果吃瓜为喜爱短剧的观众汇集多种题材与精选作品,让找剧、追剧和观看变得轻松便捷。</p>
<p>我们相信故事能够带来共鸣,也希望让更多创作者的作品与喜欢它们的观众相遇。</p>
</section>
<section class="about-section">
@@ -17,12 +15,12 @@ export const INFO_PAGES:Readonly<Record<string,InfoPageDefinition>>={
<p>围绕内容发现与观看体验,持续完善搜索、收藏、追剧记录及多端体验,让每一次打开都能更快找到想看的故事。</p>
<p>品牌主张:好故事,值得被看见。</p>
</section>
${EMPTY_CONTACT_SECTION}
${infoContactSection()}
`},
"user-agreement": {key:"user-agreement",title:"用户协议",html:`
<section class="about-section">
<h2>使用协议</h2>
<p>使用条款 欢迎您光临【91短剧】(以下简称本网站),本网站由「91短剧」(以下简称91短剧)经营。</p>
<p>使用条款 欢迎您光临【黄果吃瓜】(以下简称本网站),本网站由「黄果吃瓜」(以下简称黄果吃瓜)经营。</p>
<p>本网站重视每一位使用者所享有的服务,特此说明本网站的使用政策,以保障您的权益。</p>
<p>请您仔细阅读本使用条款的内容:</p>
</section>
@@ -120,7 +118,7 @@ ${EMPTY_CONTACT_SECTION}
`},
"privacy-policy": {key:"privacy-policy",title:"隐私政策",html:`
<section class="about-section">
<p>91短剧(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
<p>黄果吃瓜(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
</section>
<section class="about-section">
<h2>一、本公司使用您个人资料的目的</h2>
@@ -184,12 +182,8 @@ ${EMPTY_CONTACT_SECTION}
<p>收藏保存在当前浏览器中,没有接入账号同步。更换浏览器、设备或清除网站数据后,原来的收藏可能不可见。</p>
</details>
<details>
<summary>VIP 和金币是否可以购买?</summary>
<p>这些标识用于展示剧集类型。目前没有充值、订阅或购买功能。</p>
</details>
<details>
<summary>如何反馈问题?</summary>
<p>前往<a href="/info/feedback/">意见反馈</a>,填写问题类型、设备和详细描述,再将反馈保存到本地。当前版本没有线上提交功能。</p>
<summary>观看短剧需要付费吗?</summary>
<p>不需要。本站全部短剧和视频均可免费观看,无需充值、购买或开通会员。</p>
</details>
`},
"2257": {key:"2257",title:"2257 合规声明",html:`
@@ -218,7 +212,7 @@ ${EMPTY_CONTACT_SECTION}
<span class="compliance-number" aria-hidden="true">04</span>
<div>
<h2>合规说明</h2>
<p>如发现疑似违规内容,请通过<a href="/info/feedback/">意见反馈</a>提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
<p>如发现疑似违规内容,请通过官方TG ${OFFICIAL_TG_HANDLE} 提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
</div>
</section>
`},
@@ -247,54 +241,19 @@ ${EMPTY_CONTACT_SECTION}
<section class="compliance-section">
<span class="compliance-number" aria-hidden="true">04</span>
<div>
<h2>联系邮箱</h2>
<p>版权通知及反通知可发送至 <a href="mailto:huangguoaiai@gmail.com" rel="sponsored nofollow noopener noreferrer">huangguoaiai@gmail.com</a>,请在邮件中一并提供核查所需的资料。</p>
<h2>联系方式</h2>
<p>版权通知及反通知请通过官方TG <a href="${OFFICIAL_TG_URL}" target="_blank" rel="sponsored nofollow noopener noreferrer">${OFFICIAL_TG_HANDLE}</a> 提交,并一并提供核查所需的资料。</p>
</div>
</section>
`},
};
const contactText=(value:unknown)=>typeof value==='string'?value.trim().replace(/立即(?:联系|加入)/g,'').replace(/^[\s::]+|[\s::]+$/g,''):'';
const escapeContact=(value:string)=>value.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]!));
function contactValue(value:unknown):{text:string;href:string}|undefined {
const text=contactText(value);
if(!text||/[\x00-\x1f\x7f]/.test(text)||/mailto:|[^/\s@]+@[^/\s@]+\./i.test(text))return;
if(/^tel:\+?[\d() .-]+$/i.test(text))return {text:text.slice(4),href:text};
const url=text.startsWith('//')?'https:'+text:/^[\w.-]+\.[a-z]{2,}(?:[/?#]|$)/i.test(text)?'https://'+text:text;
try{
const parsed=new URL(url);
if(['https:','http:'].includes(parsed.protocol)&&!parsed.username&&!parsed.password)return {text,href:parsed.href};
return;
}catch{
if(/^[\w-]+:/i.test(text))return;
return {text,href:''};
}
/** The only contact shown, matching the other 黄果 sites. */
export function infoContactSection():string {
return `<section class="about-section" data-site-contacts><h2>联系我们</h2><p>官方TG:<a href="${OFFICIAL_TG_URL}" target="_blank" rel="sponsored nofollow noopener noreferrer">${OFFICIAL_TG_HANDLE}</a></p></section>`;
}
export function infoContactSection(config:Config={},contacts:FooterContact[]|null=[]):string {
const settings=config.config,addresses=config.newAddressInfo;
const telegram=(...values:unknown[])=>values.map(value=>{
const text=contactText(value);
return contactValue(/^@[a-z\d_]{4,}$/i.test(text)?'https://t.me/'+text.slice(1):text);
}).find(value=>value?.href&&/^https?:/.test(value.href));
const business=telegram(settings?.telegram,settings?.telegramAdd,addresses?.telegram,addresses?.telegramAdd);
const group=telegram(settings?.tgGroup,addresses?.tgGroup);
const rows:{name:string;text:string;href:string}[]=[];
if(business)rows.push({name:'商务合作',...business});
if(group)rows.push({name:'用户反馈',...group});
for(const contact of Array.isArray(contacts)?contacts:[]){
if(!contact||typeof contact!=='object')continue;
const name=contactText(contact.name);if(!name)continue;
if(/商务|商户|合作|反馈|客服|support|官方群|telegram|tg群|邮箱|邮件|e-?mail/i.test(name))continue;
const value=contactValue(contact.value);
if(value)rows.push({name,...value});
}
if(!rows.length)return EMPTY_CONTACT_SECTION;
return `<section class="about-section" data-site-contacts><h2>联系我们</h2>${rows.map(({name,text,href})=>`<p>${escapeContact(name)}:${href?`<a href="${escapeContact(href)}"${/^https?:/i.test(href)?' target="_blank" rel="sponsored nofollow noopener noreferrer"':''}>${escapeContact(text)}</a>`:escapeContact(text)}</p>`).join('')}</section>`;
}
export function getInfoPage(key:string,config:Config={},contacts:FooterContact[]|null=[]):InfoPageDefinition|undefined {
export function getInfoPage(key:string):InfoPageDefinition|undefined {
const page=Object.hasOwn(INFO_PAGES,key)?INFO_PAGES[key]:undefined;
return page&&key==='about-us'?{...page,html:page.html.replace(EMPTY_CONTACT_SECTION,infoContactSection(config,contacts))}:page;
return page;
}
+9 -25
View File
@@ -1,5 +1,5 @@
import type { Config } from './api/types';
import { recordList, safeUrl } from './api/protocol';
import { NAVIGATION_SITE_NAME, OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_URL } from './site';
const object=(value:unknown):Record<string,unknown>=>{
if(typeof value==='string'){try{return object(JSON.parse(value));}catch{return {};}}
@@ -7,33 +7,17 @@ const object=(value:unknown):Record<string,unknown>=>{
};
const text=(value:unknown)=>typeof value==='string'?value.replace(/<[^>]*>/g,'').replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim():'';
const firstText=(...values:unknown[])=>values.map(text).find(Boolean)||'';
const telegramUrl=(value:unknown)=>safeUrl(typeof value==='string'&&/^@[a-z\d_]{4,}$/i.test(value.trim())?'https://t.me/'+value.trim().slice(1):value);
const lineLabel=(line:number)=>'线路'+(['','一','二','三','四','五','六','七','八','九','十'][line]||line);
/** Navigation uses only configured destinations; missing values never link to a demo site. */
/** The entry, permanent address and contact channel are fixed; only the description comes from configuration. */
export function navigationPageData(config:Config) {
const settings=object(config.config),addresses=object(config.newAddressInfo);
const seo=object(config.seoInfo),navigationSeo=object(config.seoNavInfo);
const url=(key:string)=>[settings[key],addresses[key]].map(value=>safeUrl(value)).find(Boolean)||'';
const siteName=firstText(navigationSeo.dhSiteName,navigationSeo.siteName,settings.dhSiteName,seo.webSiteName,settings.webSiteName)||'91短剧';
const seen=new Set<number>();
let routes=recordList<{line?:unknown;url?:unknown}>(config.navPageList).flatMap(item=>{
const line=Number(item.line),href=safeUrl(item.url);
if(!Number.isSafeInteger(line)||line<1||!href||seen.has(line))return [];
seen.add(line);return [{line,href,label:lineLabel(line)}];
}).sort((left,right)=>left.line-right.line);
if(!routes.length){
routes=[1,2,3].flatMap((line,index)=>{
const href=url('dhNewUrlAddr'+line)||url(['newUrlAddr','newUrlAddr1','newUrlAddr2'][index]);
return href?[{line,href,label:lineLabel(line)}]:[];
});
}
const settings=object(config.config);
const navigationSeo=object(config.seoNavInfo);
const siteName=NAVIGATION_SITE_NAME;
const routes=[{line:1,href:SITE_URL,label:'线路一'}];
return {
siteName,
description:firstText(navigationSeo.dhDescription,navigationSeo.description,settings.dhDescription)||'全网最新最火短剧,91为你呈现',
routes,permanent:url('foreverUrlAddr'),
telegram:[settings.telegram,settings.telegramAdd,addresses.telegram,addresses.telegramAdd].map(telegramUrl).find(Boolean)||'',
tgGroup:[settings.tgGroup,addresses.tgGroup].map(telegramUrl).find(Boolean)||'',
twitter:url('twitterAddr'),
description:firstText(navigationSeo.dhDescription,navigationSeo.description,settings.dhDescription)||'全网最新最火短剧,黄果吃瓜为你呈现',
routes,permanent:SITE_URL,
telegram:OFFICIAL_TG_URL,telegramHandle:OFFICIAL_TG_HANDLE,
};
}
+3 -7
View File
@@ -1,15 +1,11 @@
import type { Chapter, Comment, Config, Drama, DramaDetail, FooterInfoResponse, Package, PageData, User } from './api/types';
import type { Chapter, Config, DramaDetail, FooterInfoResponse, User } from './api/types';
export interface SiteSharedData {config:Config;footer:FooterInfoResponse;trackerConfigured:boolean}
export interface MemberInitialData {user:User;list?:PageData<Drama>}
export interface WalletInitialData {user:User;packages:{coinList?:Package[];vipList?:Package[]};transactions:PageData<Record<string,unknown>>}
export interface SiteSharedData {config:Config;footer:FooterInfoResponse}
export interface PlayerInitialData {
detail:DramaDetail;
chapters:Chapter[];
chapter:Chapter|null;
comments?:PageData<Comment>;
}
/** Only the first route can consume this snapshot; later navigation loads fresh data. */
@@ -20,7 +16,7 @@ export interface PageBootstrap {
subtitle?:string;
player?:PlayerInitialData;
search?:{keyword:string;total:number;page:number;pageSize:number};
info?:{key:string;member?:MemberInitialData;wallet?:WalletInitialData;feedback?:boolean};
info?:{key:string};
session?:{user:User|null;state:string};
}
+4 -12
View File
@@ -1,4 +1,4 @@
import type { Advertisement, Advertisements, Chapter, Comment, DramaDetail, PageData } from './api/types';
import type { Advertisement, Advertisements, Chapter, DramaDetail } from './api/types';
import type { PlayerInitialData } from './page-data';
import { recordList } from './api/protocol';
import { normalizeSeo } from './seo';
@@ -41,17 +41,9 @@ function publicChapter(data:Chapter):Chapter {
return result;
}
function publicComment(data:Comment,depth=0):Comment {
const result=fields(data,['text','userName','userAvatar','createdAt'],['id','isLike','countLike','parentsId']) as unknown as Comment;
if(depth<4)result.children=recordList<Comment>(data.children).map(item=>publicComment(item,depth+1));
return result;
}
/** Explicit DTO: no auth tokens, SDK configuration, or arbitrary upstream properties. */
export function playerInitialData(raw:DramaDetail,chapters:Chapter[],chapter:Chapter|null,comments?:PageData<Comment>):PlayerInitialData {
export function playerInitialData(raw:DramaDetail,chapters:Chapter[],chapter:Chapter|null):PlayerInitialData {
const detail=publicDrama(raw);
detail.relatedRecommendData=recordList<DramaDetail>(raw.relatedRecommendData).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).slice(0,6).map(publicDrama);
return {detail,chapters:chapters.map(publicChapter),chapter:chapter?publicChapter(chapter):null,
...(comments?{comments:{list:recordList<Comment>(comments.list).map(item=>publicComment(item)),...(typeof comments.error==='string'?{error:comments.error.slice(0,300)}:{}),totalCount:typeof comments.totalCount==='number'?comments.totalCount:0}}:{}),
};
detail.relatedRecommendData=recordList<DramaDetail>(raw.relatedRecommendData).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).slice(0,12).map(publicDrama);
return {detail,chapters:chapters.map(publicChapter),chapter:chapter?publicChapter(chapter):null};
}
+2 -8
View File
@@ -16,8 +16,7 @@ export function getRankPage(slug='hot') {
}
const channels = new Set(['shorts', 'topics', 'rank', 'apps', 'home-road']);
const members = new Set(['center', 'favorites', 'history', 'likes', 'purchased', 'settings']);
const information = new Set(['about-us', 'user-agreement', 'privacy-policy', 'help-center', '2257', 'dmca', 'feedback', 'user-center', 'settings', 'deposit']);
const information = new Set(['about-us', 'user-agreement', 'privacy-policy', 'help-center', '2257', 'dmca']);
const id = (value?: string) => value !== undefined && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value)>0;
export interface ContentIdentity { id?:number; slug?:unknown }
@@ -62,18 +61,13 @@ function baseRouteFromSegments(parts: string[]): SiteRoute | null {
if (group === 'detail' && parts.length === 2 && contentKey(key)) return {group, key};
if (group === 'play' && parts.length === 3 && contentKey(key) && id(value)) return {group, key, value};
if (group === 'search' && parts.length === 2 && key.trim()) return {group, key};
if (group === 'member' && parts.length === 2 && members.has(key)) return {group, key};
if (group === 'info' && parts.length === 2 && information.has(key)) return {group, key};
if (group === 'info' && key === 'deposit' && value === 'vip' && parts.length === 3) return {group, key, value};
return null;
}
export function supportsPagination(route:SiteRoute):boolean {
if(route.group==='page')return ['shorts','topics','rank','theme','tag','config'].includes(route.key);
if(route.group==='member')return ['favorites','history','likes','purchased'].includes(route.key);
if(route.group==='info')return route.key==='deposit';
if(route.group==='search')return true;
return ['detail','play'].includes(route.group);
return route.group==='search';
}
export function routeFromSegments(parts:string[]):SiteRoute|null {
+10 -50
View File
@@ -4,27 +4,23 @@ import type { Metadata } from 'next';
import { randomUUID } from 'node:crypto';
import { isIP } from 'node:net';
import { getHeaderNavigation, getHome, getServerApiBaseUrl, upstream } from './api/server';
import type { Config, DramaDetail, Chapter, Comment, Drama, FooterInfoResponse, NavigationCategory, PageData, User } from './api/types';
import type { Config, DramaDetail, Chapter, Drama, FooterInfoResponse, NavigationCategory, PageData, User } from './api/types';
import { ApiError, EXPIRED_CODES, recordList } from './api/protocol';
import { contentPath, contentQuery, matchesContentKey, parseSitePath } from './routing';
import { getInfoPage } from './info-pages';
import { buildPageHead, dramaSeoContent, homeSeoContent, infoSeoContent, listSeoContent, resolveSeoEnvironment, type PageHead, type SeoBootstrap, type SeoContent } from './seo';
import { SeoSnapshotStore } from './seo-snapshot.server';
import type { PageBootstrap, PageView, WalletInitialData } from './page-data';
import type { PageBootstrap, PageView } from './page-data';
import type { ApiClient } from './client/api';
import { playerInitialData } from './player-data';
import { loadChannel, channelView } from './client/channel';
import { orderedChapters, playerMarkup } from './client/player-markup';
import { loadSearchResults, searchErrorMarkup, searchResultsMarkup } from './client/search';
import { paginationPath } from './routing';
import { empty } from './client/render';
import { loadAppCategories, renderAppDirectory } from './client/apps';
import { homeRoad } from './client/config';
import { feedbackMarkup } from './client/feedback';
import { memberMarkup, walletMarkup } from './client/account';
import { publicUser } from './api/public-user';
import { SSR_SESSION_HEADER, SSR_USER_HEADER } from './api/page-session';
import { accountList, accountPackages, accountTransactions } from './account-data';
import { publicSiteData } from './site-shared-data';
const snapshotStores=new Map<string,SeoSnapshotStore>();
@@ -84,7 +80,7 @@ const getReadIdentity=cache(async ()=>{
const userAgent=requestHeaders.get('user-agent')||'91-drama-web/1.0';
const token=jar.get('drama_session')?.value||'';
const ip=(requestHeaders.get('x-real-ip')||requestHeaders.get('x-forwarded-for')?.split(',')[0]||'').trim();
return {token,device,userAgent,clientIp:isIP(ip)?ip:undefined,registered:jar.get('drama_registered')?.value==='1',prepared:requestHeaders.has(SSR_SESSION_HEADER),channel:(jar.get('drama_channel')?.value||process.env.API_CHANNEL||'').slice(0,100)};
return {token,device,userAgent,clientIp:isIP(ip)?ip:undefined,prepared:requestHeaders.has(SSR_SESSION_HEADER),channel:(jar.get('drama_channel')?.value||process.env.API_CHANNEL||'').slice(0,100)};
});
const getReadGuest=cache(async ()=>{
const identity=await getReadIdentity();
@@ -98,9 +94,9 @@ async function contentRequest<T>(endpoint:string,query:Record<string,string|numb
let result;
try{result=await upstream<T>(path,{...identity,timeoutMs:5000});}
catch(error){
if(!protectedRead||error===null||typeof error!=='object'||!('code' in error)||!([401,403].includes(Number(error.code))||EXPIRED_CODES.has(Number(error.code)))||('registered' in identity&&identity.registered)||('prepared' in identity&&identity.prepared))throw error;
if(!protectedRead||error===null||typeof error!=='object'||!('code' in error)||!([401,403].includes(Number(error.code))||EXPIRED_CODES.has(Number(error.code)))||('prepared' in identity&&identity.prepared))throw error;
}
if(protectedRead&&(!result||([401,403].includes(result.code)||EXPIRED_CODES.has(result.code)))&&(!('registered' in identity)||!identity.registered)&&!('prepared' in identity&&identity.prepared)){
if(protectedRead&&(!result||([401,403].includes(result.code)||EXPIRED_CODES.has(result.code)))&&!('prepared' in identity&&identity.prepared)){
result=await upstream<T>(path,{...await getReadGuest(),timeoutMs:5000});
}
if(result?.code!==200)throw new ApiError('内容暂不可用',result?.code||502);
@@ -124,34 +120,8 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
const query:Record<string,string|number>={pageNum:route.page||1,pageSize:24};
try{
if(!route.group){const home=await getHome();return {...homeSeoContent(home.data),failed:!!home.error};}
if(route.group==='member'||route.group==='info'&&['deposit','user-center','settings'].includes(route.key)){
const wallet=route.key==='deposit',tab=route.group==='member'?route.key:route.key==='settings'?'settings':'center';
const title=wallet?(route.value==='vip'?'会员畅享':'金币充值'):({center:'个人中心',history:'观看历史',favorites:'我的收藏',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置'} as Record<string,string>)[tab];
const session=await preparedSession(),pageHref=(page:number)=>paginationPath(address,page);
if(!session.user){
const html=session.state==='login-required'?empty('请先登录账号')+'<button type="button" class="primary" data-auth-mode="login">登录账号</button>':empty('账号服务暂不可用,请稍后重试',true);
return {view:infoView(address,route.key,title,html,{session}),failed:session.state!=='login-required'};
}
try{
if(wallet){
const [packages,transactions]=await Promise.all([
contentRequest<WalletInitialData['packages']>('recharge/package/list',{}),
contentRequest<WalletInitialData['transactions']>('coin/trans/list',{page:route.page||1,pageSize:10,type:0},true).catch(()=>({list:[],totalCount:0,error:true})),
]);
const initial:WalletInitialData={user:session.user,packages:accountPackages(packages),transactions:accountTransactions(transactions)};
return {...(transactions.error?{failed:(route.page||1)>1}:{totalCount:initial.transactions.totalCount}),pageSize:10,view:infoView(address,'deposit',title,walletMarkup(session.user,initial.packages,initial.transactions,route.value==='vip',route.page||1,pageHref),{session,info:{key:'deposit',wallet:initial}})};
}
const active=tab==='center'?'history':tab,paths:Record<string,string>={history:'playlet/history/list',favorites:'playlet/collect/list',likes:'playlet/like/list',purchased:'playlet/bought/list'};
const list=tab==='settings'?undefined:accountList(await contentRequest<PageData<Drama>>(paths[active],query,true));
const initial={user:session.user,list};
return {totalCount:list?.totalCount,pageSize:24,view:infoView(address,tab,title,memberMarkup(session.user,tab,list,{},route.page||1,pageHref),{session,info:{key:tab,member:initial}})};
}catch{return {failed:true,view:infoView(address,route.key,title,empty('页面暂不可用,请稍后重试',true),{session})};}
}
if(route.group==='info'){
if(route.key==='feedback')return {view:infoView(address,'feedback','意见反馈',feedbackMarkup(),{info:{key:'feedback',feedback:true}})};
const configuration=await getSeoConfig();
const footer=route.key==='about-us'?await getFooterInfo():undefined;
const info=getInfoPage(route.key,configuration.config,footer?.footerInfo?.contactList||[]);
const info=getInfoPage(route.key);
return info?{...infoSeoContent(info),view:infoView(address,route.key,info.title,info.html)}:{};
}
if(route.group==='search'){
@@ -162,7 +132,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
return {totalCount:total,pageSize,view:{panel:'search',title:'搜索结果',html:searchResultsMarkup(results,value=>paginationPath(address,value)),bootstrap:{path:address,panel:'search',title:'搜索结果',search:{keyword,total,page,pageSize}}}};
}
if(['detail','play'].includes(route.group)){
const commentSort=params.get('commentSort')==='1'?1:2;
const detailRequest=contentRequest<DramaDetail>('playlet/detail',contentQuery(route.key),true);
const playletQuery=contentQuery(route.key,'playletId','playletSlug');
// Slug-aware chapter reads stay parallel. Only ID-only endpoints wait for slug resolution.
@@ -170,13 +139,11 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
if(!matchesContentKey(detail,route.key)||!Number.isSafeInteger(detail.id)||detail.id<=0)throw new ApiError('剧集不存在',404);
return detail.id;
});
const [detail,chapterResult,chapters,comments,configuration,recommendations]=await Promise.all([
const [detail,chapterResult,chapters,recommendations]=await Promise.all([
detailRequest,
route.group==='play'?contentRequest<Chapter>('playlet/chapter/detail',{...playletQuery,chapterId:route.value!},true):Promise.resolve(undefined),
contentRequest<{list?:Chapter[]}>('playlet/chapter/list',playletQuery,true).then(data=>data.list).catch(()=>undefined),
(route.group==='play'?Promise.resolve(Number(route.value)):resolvedId).then(objectId=>contentRequest<PageData<Comment>>('comment/list',{objectId,objectType:route.group==='play'?11:10,pageNum:route.page||1,pageSize:10,sortOrder:commentSort},true)).catch(()=>({list:[],totalCount:0,error:'评论暂不可用,请稍后重试'})),
getSeoConfig(),
resolvedId.then(playletId=>contentRequest<PageData<Drama>>('playlet/guess-like/list',{playletId,pageNum:1,pageSize:6})).catch(()=>undefined),
resolvedId.then(playletId=>contentRequest<PageData<Drama>>('playlet/guess-like/list',{playletId,pageNum:1,pageSize:12})).catch(()=>undefined),
]);
let chapter:Chapter|undefined;
if(route.group==='play'){
@@ -187,14 +154,11 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
const content=dramaSeoContent(detail,chapter);
if(!matchesContentKey(detail,route.key))return {...content,missing:true};
if(!content.title)return {failed:true};
if(comments&&!comments.error){content.totalCount=comments.totalCount;content.pageSize=10;}
if(comments?.error&&(route.page||1)>1)content.failed=true;
const ordered=orderedChapters(chapters??detail.playletChapterInfo);
detail.relatedRecommendData=recommendations?.list||[];
const initial=playerInitialData(detail,ordered,chapter||null,comments);
const initial=playerInitialData(detail,ordered,chapter||null);
const title=content.title||'剧集详情',overview=route.group==='detail';
const pageHref=(page:number,sort:number)=>paginationPath(contentPath(overview?'detail':'play',detail,chapter?.id)+search,page,{commentSort:sort===2?null:sort});
return {...content,view:{panel:'player',title,overview,html:playerMarkup(initial.detail,initial.chapters,{chapter:initial.chapter,isVideo:false,page:route.page||1,commentSort,comments:initial.comments,config:configuration.config,pageHref}),bootstrap:{path:address,panel:'player',title,player:initial}}};
return {...content,view:{panel:'player',title,overview,html:playerMarkup(initial.detail,initial.chapters,{chapter:initial.chapter,isVideo:false}),bootstrap:{path:address,panel:'player',title,player:initial}}};
}
if(route.group!=='page')return {};
const api:Pick<ApiClient,'get'>={get:(endpoint,values={})=>contentRequest(endpoint,Object.fromEntries(Object.entries(values).filter(([,value])=>value!==undefined).map(([key,value])=>[key,String(value)])))};
@@ -220,10 +184,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
const rendered=channelView(route,data,address);
return {...listSeoContent(data,route,home?.data),view:{panel:'channel',channel:route.key,...rendered,bootstrap:{path:address,panel:'channel',title:rendered.title,subtitle:rendered.subtitle}}};
}catch(error){
if(error instanceof ApiError&&(error.code===401||EXPIRED_CODES.has(error.code))&&(await preparedSession()).state==='login-required'){
const title='登录后继续访问';
return {loginRequired:true,view:{panel:'channel',title,html:empty('请先登录账号')+'<button type="button" class="primary" data-auth-mode="login">登录账号</button>'}};
}
const resource=route.group==='detail'||route.group==='play'||route.group==='page'&&['theme','tag','config'].includes(route.key)||route.group==='page'&&route.key==='topics'&&!!route.value;
// The current service uses 5009 ("无数据") for missing short dramas, episodes and topics.
if(resource&&error instanceof ApiError&&[404,410,5009].includes(error.code))return {missing:true};
+22 -26
View File
@@ -1,15 +1,16 @@
import { NAVIGATION_SITE_NAME, OFFICIAL_TG_URL, SITE_ORIGIN } from './site';
import type { Chapter, Config, DramaDetail, HomeData, NavigationCategory, PageData } from './api/types';
import type { InfoPageDefinition } from './info-pages';
import { chapterAccess, chapterRequiresPayment } from './client/playback';
import { chapterAccess } from './client/playback';
import { contentPath, getRankPage, navigationSection, paginationPath, parseSitePath, supportsPagination, type ContentIdentity, type SiteRoute } from './routing';
import { buildStructuredData, dramaSchemaData, faqFromHtml, homeSchemaItems, listSchemaItems, schemaUrl, type SchemaGraph, type SchemaDrama, type SchemaItem, type SchemaQuestion } from './structured-data';
import { buildStructuredData, dramaSchemaData, faqFromHtml, homeSchemaItems, listSchemaItems, type SchemaGraph, type SchemaDrama, type SchemaItem, type SchemaQuestion } from './structured-data';
export interface NormalizedSeo { title:string; description:string; keywords:string[]; siteName:string }
export interface SeoEnvironment { origin:string; site:NormalizedSeo; navigation:NormalizedSeo; categories:NavigationCategory[]; sameAs?:string[] }
export interface SeoContent {
seoInfo?:unknown; title?:string; description?:string; keywords?:string[]; episode?:number;
totalCount?:number; pageSize?:number; failed?:boolean; missing?:boolean; unavailable?:boolean; loginRequired?:boolean;
routeConfig?:boolean; indexable?:boolean; paywalled?:boolean; identity?:ContentIdentity;
totalCount?:number; pageSize?:number; failed?:boolean; missing?:boolean; unavailable?:boolean;
routeConfig?:boolean; indexable?:boolean; identity?:ContentIdentity;
items?:SchemaItem[]; entity?:SchemaDrama; faq?:SchemaQuestion[];
}
export interface PageHead {
@@ -51,19 +52,18 @@ export function siteOrigin(value:unknown):string {
return url.origin;
}catch{return '';}
}
/** Canonical URLs always use the site's own domain, never configured mirror addresses. */
export function seoEnvironment(config:Config={},categories:NavigationCategory[]=[]):SeoEnvironment {
const origin=siteOrigin(config.config?.foreverUrlAddr)||siteOrigin(config.newAddressInfo?.foreverUrlAddr);
const channels={...config.newAddressInfo,...config.config};
const sameAs=[...new Set([channels.twitterAddr,channels.tgGroup].map(value=>schemaUrl(value,origin)).filter(Boolean))];
const origin=SITE_ORIGIN;
const sameAs=[OFFICIAL_TG_URL];
return {origin,site:normalizeSeo(config.seoInfo),navigation:normalizeSeo(config.seoNavInfo),categories:Array.isArray(categories)?categories.filter(item=>item&&typeof item==='object'):[],sameAs};
}
/** A partial/failed configuration cannot replace the last valid site's identity. */
export function resolveSeoEnvironment(config:Config,categories:NavigationCategory[]|undefined,previous?:SeoEnvironment):SeoEnvironment {
const current=seoEnvironment(config,categories??previous?.categories??[]);
if(!current.origin&&previous?.origin)return previous;
return {...current,
site:mergeSeo(current.site,previous?.site||normalizeSeo({siteName:'91短剧'})),
site:mergeSeo(current.site,previous?.site||normalizeSeo({siteName:'黄果吃瓜'})),
navigation:mergeSeo(current.navigation,previous?.navigation||normalizeSeo({})),
};
}
@@ -73,21 +73,18 @@ export function clientSeoEnvironment(current:SeoEnvironment,config:Config):SeoEn
return current.origin?current:resolveSeoEnvironment(config,current.categories,current);
}
const infoTitles:Record<string,string>={'about-us':'关于我们','user-agreement':'用户协议','privacy-policy':'隐私政策','help-center':'常见问题','2257':'2257',dmca:'DMCA',feedback:'意见反馈','user-center':'个人中心',settings:'账号设置',deposit:'金币充值'};
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',rank:'热播榜',apps:'福利应用','home-road':'回家的路',theme:'题材短剧',tag:'标签短剧',config:'更多好剧',center:'个人中心',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置'};
const infoTitles:Record<string,string>={'about-us':'关于我们','user-agreement':'用户协议','privacy-policy':'隐私政策','help-center':'常见问题','2257':'2257',dmca:'DMCA'};
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',rank:'热播榜',apps:'福利应用','home-road':'回家的路',theme:'题材短剧',tag:'标签短剧',config:'更多好剧'};
const descriptions:Record<string,string>={
shorts:'精选短剧与最新更新,都市甜宠、古装仙侠、悬疑逆袭,发现值得追的精彩故事。',
topics:'精选短剧专题与主题合集,按专题发现值得追的好剧。',
rank:'查看热播榜、新剧榜和追剧榜,发现大家正在看的热门短剧。',
'about-us':'了解91短剧的品牌介绍、平台服务与联系方式。',
'user-agreement':'了解91短剧的用户协议、使用条款与服务规则。',
'privacy-policy':'了解91短剧如何收集、使用及保护个人信息。',
'help-center':'查阅91短剧的观看、账户与服务常见问题。',
'2257':'91短剧的2257合规说明与相关信息。',dmca:'91短剧的版权保护政策与DMCA通知说明。',
'about-us':'了解黄果吃瓜的品牌介绍、平台服务与联系方式。',
'user-agreement':'了解黄果吃瓜的用户协议、使用条款与服务规则。',
'privacy-policy':'了解黄果吃瓜如何收集、使用及保护个人信息。',
'help-center':'查阅黄果吃瓜的观看、账户与服务常见问题。',
'2257':'黄果吃瓜的2257合规说明与相关信息。',dmca:'黄果吃瓜的版权保护政策与DMCA通知说明。',
};
function privateRoute(route:SiteRoute):boolean {
return route.group==='member'||route.group==='info'&&['user-center','settings','deposit','feedback'].includes(route.key);
}
function canonicalPath(path:string):string {
if(path==='/')return '/';
return path.replace(/\/page\/1\/?$/,'/').replace(/\/?$/,'/');
@@ -144,7 +141,7 @@ export function dramaSeoContent(data:DramaDetail,chapter?:Chapter):SeoContent {
if(chapter&&entity.video)entity.video.source=access.allowed?access.source:undefined;
// The current locked view repeats the series synopsis; metadata alone is not unique episode content.
const indexable=chapter?!!(title&&Number.isSafeInteger(chapter.currentEpisode)&&chapter.currentEpisode!>0&&access.allowed&&access.source):undefined;
return {seoInfo:seo,title,description,keywords,episode:chapter?.currentEpisode,indexable,paywalled:chapter?chapterRequiresPayment(chapter):undefined,missing:!Number.isSafeInteger(data.id)||data.id<=0,entity};
return {seoInfo:seo,title,description,keywords,episode:chapter?.currentEpisode,indexable,missing:!Number.isSafeInteger(data.id)||data.id<=0,entity};
}
/** Only a successful count can prove a page is beyond the end of a collection. */
@@ -163,7 +160,7 @@ export function pageResponseStatus(pathname:string,content:SeoContent={}):200|40
export function buildPageHead(pathname:string,search:string,environment:SeoEnvironment,content:SeoContent={}):PageHead {
const navigation=pathname.replace(/\/$/,'')==='/navigation';
const route=navigation?{group:'navigation',key:''}:parseSitePath(pathname,search);
const siteName=first(environment.site.siteName,navigation?environment.navigation.siteName:'',normalizeSeo(content.seoInfo).siteName,'91短剧');
const siteName=navigation?NAVIGATION_SITE_NAME:first(environment.site.siteName,normalizeSeo(content.seoInfo).siteName,'黄果吃瓜');
const page=route?.page||1;
const params=new URLSearchParams(search);
const sort=Number(params.get('sort')||1);
@@ -186,11 +183,11 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
if(fallback)seo=mergeSeo(seo,fallback);
}
const ranking=route?.group==='page'&&route.key==='rank'?getRankPage(route.value):undefined;
const pageTitle=!route?'页面不存在':!route.group?`${siteName} · 百万大神原创短剧`:navigation?'官方入口':route.group==='search'?(route.key.trim()?`${route.key.trim()} · 搜索结果`:'搜索短剧'):route.group==='info'?(route.key==='deposit'&&route.value==='vip'?'会员畅享':content.title||infoTitles[route.key]):isContent?(content.title||'剧集详情'):ranking?.title||content.title||labels[route.key]||'内容列表';
const pageTitle=!route?'页面不存在':!route.group?`${siteName} · 百万大神原创短剧`:navigation?'官方入口':route.group==='search'?(route.key.trim()?`${route.key.trim()} · 搜索结果`:'搜索短剧'):route.group==='info'?(content.title||infoTitles[route.key]):isContent?(content.title||'剧集详情'):ranking?.title||content.title||labels[route.key]||'内容列表';
let title=seo.title||pageTitle||siteName;
const episode=route?.group==='play'&&content.episode?`第${content.episode}集`:'';
if(episode&&!title.includes(episode))title+=` - ${episode}`;
let description=seo.description||content.description||(!route?.group?`${siteName},发现值得一看的短剧。都市甜宠、古装仙侠、悬疑逆袭,精彩故事随心看。`:isContent?`在${siteName}查看《${content.title||'精选剧集'}》的剧集介绍、更新与观看信息。`:(descriptions[route.key]||`${pageTitle},${siteName}为你提供最新内容与相关服务。`).replaceAll('91短剧',siteName));
let description=seo.description||content.description||(!route?.group?`${siteName},发现值得一看的短剧。都市甜宠、古装仙侠、悬疑逆袭,精彩故事随心看。`:isContent?`在${siteName}查看《${content.title||'精选剧集'}》的剧集介绍、更新与观看信息。`:(descriptions[route.key]||`${pageTitle},${siteName}为你提供最新内容与相关服务。`).replaceAll('黄果吃瓜',siteName));
if(episode&&!description.includes(episode))description+=` - ${episode}`;
let keywords=seo.keywords.length?seo.keywords:[...new Set([content.title||pageTitle,...(content.keywords||[]),'在线短剧',siteName].filter(Boolean))];
if(ranking){
@@ -218,7 +215,6 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
let indexable=!!route&&(!route.group||route.group==='page'&&!['apps','home-road'].includes(route.key)||isContent||route.group==='info'&&!!descriptions[route.key]);
if(route?.group==='page'&&!(route.key==='shorts'?[1,2,3,4]:[1,2,3]).includes(sort))indexable=false;
if(route?.group==='page'&&sort!==1&&route.key!=='rank'&&route.key!=='config')indexable=false;
if(params.has('commentSort')&&params.get('commentSort')!=='2')indexable=false;
if(isContent&&page>1)indexable=false;
if((route?.group==='play'||route?.group==='page'&&route.key==='config')&&content.indexable!==true)indexable=false;
if(content.indexable===false)indexable=false;
@@ -227,8 +223,8 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
const totalPages=totalCount===undefined?undefined:Math.max(1,Math.ceil(totalCount/pageSize));
const outOfRange=totalPages!==undefined&&page>totalPages;
if(route?.group==='page'&&totalCount===0)indexable=false;
if(content.failed||content.missing||content.unavailable||content.loginRequired||outOfRange)indexable=false;
const robots=indexable?'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1':route&&privateRoute(route)?'noindex,nofollow':'noindex,follow';
if(content.failed||content.missing||content.unavailable||outOfRange)indexable=false;
const robots=indexable?'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1':'noindex,follow';
const origin=environment.origin;
const absolute=(path:string)=>origin?new URL(path,origin).href:'';
const canonical=indexable?absolute(canonicalPagePath(pathname,content)):'';
+4 -5
View File
@@ -2,10 +2,10 @@ import type { Config, FooterInfoResponse, Advertisement } from './api/types';
import { recordList } from './api/protocol';
import type { SiteSharedData } from './page-data';
/** Only fields used by visible site controls enter the client snapshot. SDK config stays server-side. */
/** Only fields used by visible site controls enter the client snapshot. */
export function publicSiteData(source:Config,footer:FooterInfoResponse={}):SiteSharedData {
const section=(value:Record<string,unknown>|undefined)=>Object.fromEntries([
'foreverUrlAddr','newUrlAddr','newUrlAddr1','newUrlAddr2','tgGroup','telegram','telegramAdd','twitterAddr','appAddr','officialAppAddr','customerServiceHost','emailAddr','email',
'appAddr','officialAppAddr',
].filter(key=>typeof value?.[key]==='string').map(key=>[key,String(value![key]).slice(0,4096)]));
const advitise=Object.fromEntries(['dialogList','nineGridList','detailTopList','detailbannerList','bottomBanner'].map(key=>[key,recordList<Advertisement>(source.advitise?.[key]).map(item=>{
const output:Record<string,unknown>={};
@@ -14,8 +14,7 @@ export function publicSiteData(source:Config,footer:FooterInfoResponse={}):SiteS
return output as unknown as Advertisement;
})]));
return {
config:{config:section(source.config),newAddressInfo:section(source.newAddressInfo),advitise,navPageList:recordList<{line:number;url:string}>(source.navPageList).map(item=>({line:Number(item.line)||0,url:typeof item.url==='string'?item.url:''}))},
footer:{footerInfo:{brand:{content:typeof footer.footerInfo?.brand?.content==='string'?footer.footerInfo.brand.content:''},contactList:recordList<{name:string;value:string}>(footer.footerInfo?.contactList).map(item=>({name:typeof item.name==='string'?item.name:'',value:typeof item.value==='string'?item.value:''}))}},
trackerConfigured:typeof source.reportSdkConfig==='string'&&!!source.reportSdkConfig.trim(),
config:{config:section(source.config),newAddressInfo:section(source.newAddressInfo),advitise},
footer:{footerInfo:{brand:{content:typeof footer.footerInfo?.brand?.content==='string'?footer.footerInfo.brand.content:''}}}
};
}
+8
View File
@@ -0,0 +1,8 @@
/** The site's only public address. Pages never display or link to configured mirror addresses. */
export const SITE_ORIGIN='https://huangguocg.com';
export const SITE_URL=SITE_ORIGIN+'/';
/** Name shown on the standalone navigation page. */
export const NAVIGATION_SITE_NAME='黄果吃瓜';
/** The only contact channel shown anywhere on the site, matching the other 黄果 sites. */
export const OFFICIAL_TG_HANDLE='@jbd125';
export const OFFICIAL_TG_URL='https://t.me/jbd125';
+2 -6
View File
@@ -105,10 +105,6 @@ export function buildStructuredData(pathname:string,head:PageHead,environment:Se
];
const page:SchemaNode={'@type':route.group==='page'?'CollectionPage':route.group==='info'&&route.key==='about-us'?'AboutPage':'WebPage','@id':pageURL+'#webpage',name:head.title,description:head.description,url:pageURL,inLanguage:'zh-CN',isPartOf:ref(website),publisher:ref(organization)};
graph.push(page);
if(route.group==='play'&&content.paywalled){
page.isAccessibleForFree=false;
page.hasPart={'@type':'WebPageElement',isAccessibleForFree:false,cssSelector:'.play-paywalled-content'};
}
if(route.group){
page.breadcrumb=ref(pageURL+'#breadcrumb');
graph.push({'@type':'BreadcrumbList','@id':pageURL+'#breadcrumb',itemListElement:breadcrumbItems(route,pageURL,origin,content).map(([name,item],index)=>({'@type':'ListItem',position:index+1,name,item}))});
@@ -153,7 +149,7 @@ export function buildStructuredData(pathname:string,head:PageHead,environment:Se
if(route.group==='play'&&id(content.episode)){
episode={'@type':'TVEpisode','@id':watchURL+'#episode',name:`${entity.name} - 第${content.episode}集`,description:head.description,episodeNumber:content.episode,url:watchURL,partOfSeries:ref(seriesID)};
if(cover)episode.image=[cover];
if(content.paywalled)episode.isAccessibleForFree=false;
episode.isAccessibleForFree=true;
if(published)episode.datePublished=published;
graph.push(episode);page.mainEntity=ref(watchURL+'#episode');
}
@@ -162,7 +158,7 @@ export function buildStructuredData(pathname:string,head:PageHead,environment:Se
if(route.group!=='detail'&&cover&&published&&source){
const video:SchemaNode={'@type':'VideoObject','@id':videoID,name:episode?.name||entity.name,description:head.description,url:watchURL,mainEntityOfPage:ref(pageURL+'#webpage'),inLanguage:'zh-CN',isFamilyFriendly:false,contentRating:'adult',isPartOf:ref(website),publisher:ref(organization),thumbnailUrl:[cover],image:[cover],uploadDate:published,potentialAction:{'@type':'WatchAction',target:{'@type':'EntryPoint',urlTemplate:watchURL}}};
video.contentUrl=source;
if(content.paywalled)video.isAccessibleForFree=false;
video.isAccessibleForFree=true;
if(modified)video.dateModified=modified;
if(positive(media?.duration)&&!String(media.duration).includes('e'))video.duration=`PT${media.duration}S`;
if(id(media?.width))video.width=media.width;
-34
View File
@@ -1,34 +0,0 @@
import type { Chapter, DramaDetail } from './api/types';
export const TRACKER_APP_ID=process.env.NEXT_PUBLIC_TRACKER_APP_ID||'1047';
export const SDK_COMMON_KEYS=['sid','device_id','device','device_fingerprint','fp_version','app_version'] as const;
export const COMMON_KEYS=['app_id','uid','channel','sid','client_ts','device','device_id','device_brand','device_model','device_fingerprint','fp_version','user_agent','sdk_version','app_version'] as const;
export const SDK_FIELD_KEYS=[...COMMON_KEYS,'system_name','system_version'] as const;
export const COMMON_FIELD_LIMITS:Record<string,number>={app_id:50,uid:32,channel:50,sid:50,device:30,device_id:50,device_brand:100,device_model:100,device_fingerprint:128,fp_version:128,user_agent:4096,sdk_version:128,app_version:128,system_name:100,system_version:100};
export const SERVER_EVENTS:Record<string,string>={
'playlet/like/set':'drama_like','playlet/chapter/like/set':'drama_like',
'playlet/collect/set':'drama_collect','playlet/chapter/collect/set':'drama_collect',
'comment/send':'drama_comment','playlet/chapter/unlock':'drama_purchase',
};
export type Fields=Record<string,string|number>;
export interface TrackingPage {key:string;name:string}
export const trackingText=(value:unknown)=>typeof value==='string'||typeof value==='number'?String(value).trim():'';
export function trackingPage(path:string):TrackingPage {
const parts=path.split('/').filter(Boolean),key=parts[0]||'home';
if(key==='rank')return {key:'rank_'+(parts[1]||'hot'),name:({'hot':'热播榜',new:'新剧榜',follow:'追剧榜'} as Record<string,string>)[parts[1]||'hot']||'排行榜'};
if(key==='topics'&&parts[1]&&parts[1]!=='page')return {key:'topic_detail',name:'专题详情'};
const slug=['info','member'].includes(key)?parts[1]:key;
const names:Record<string,string>={home:'首页',shorts:'短剧列表',series:'剧集列表',topics:'专题推荐',theme:'题材短剧',tag:'标签短剧',config:'更多好剧',detail:'短剧详情',play:'短剧播放页',video:'剧集播放页',search:'搜索结果页',navigation:'官方入口',apps:'福利应用','home-road':'回家的路',center:'个人中心','user-center':'个人中心',settings:'账号设置',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',deposit:'充值中心','about-us':'关于我们','privacy-policy':'隐私政策','user-agreement':'用户协议','help-center':'常见问题',feedback:'意见反馈','2257':'2257合规声明',dmca:'DMCA版权声明'};
return {key:names[slug]?slug.replace(/-/g,'_'):'internal_other',name:names[slug]||'站内其他页面'};
}
export function dramaContext(drama:DramaDetail,chapter?:Chapter|null):Fields {
return {media_id:trackingText(chapter?.mediaId||drama.mediaId),drama_id:String(drama.id),drama_title:drama.playName||drama.title||'未命名短剧',episode:Math.max(1,chapter?.currentEpisode||1),total_episode:Math.max(1,drama.totalEpisode||drama.playletChapterInfo?.length||1),video_id:chapter?String(chapter.id):'',drama_type_id:trackingText(drama.themeIds?.[0])||'default',drama_type_name:drama.themeNames?.[0]||'默认分类',drama_content_type:'short_drama',recommend_trace_id:trackingText(drama.recommendTraceId),drama_tag_key:drama.tagIds?.join(',')||'default',drama_tag_name:drama.tagNames?.join(',')||'默认标签',drama_duration:Math.max(0,chapter?.mediaTime||0)};
}
export function base64JSON(value:unknown,url=false):string {
const bytes=new TextEncoder().encode(JSON.stringify(value));
const encoded=btoa(Array.from(bytes,byte=>String.fromCharCode(byte)).join(''));
return url?encoded.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''):encoded;
}
-276
View File
@@ -1,276 +0,0 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
import {runInNewContext} from 'node:vm';
// Run the bundled SDK's real named-event validators without initializing it:
// accepted events stay in its pre-init queue and never contact an analytics server.
const sdkLogs=[];
const sdkSandbox={console:{warn:(...args)=>sdkLogs.push(args)}};
runInNewContext(await readFile(new URL('../public/scripts/web-sdk-v1.2.0.js',import.meta.url),'utf8'),sdkSandbox);
async function moduleUrl(file,replace=code=>code){
const code=ts.transpileModule(await readFile(new URL(file,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
}
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
const {TRACKER_APP_ID,COMMON_KEYS,SDK_COMMON_KEYS,base64JSON,trackingPage}=await import(trackingUrl);
const authUrl='data:text/javascript,export function authMetadata(){return {channel:"paid_1047"}}';
const analyticsUrl=await moduleUrl('../src/lib/client/analytics.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl)).replace("'./auth'",JSON.stringify(authUrl)));
const {SiteTracker}=await import(analyticsUrl);
const {trackingHeaders}=await import(await moduleUrl('../src/lib/api/tracking-headers.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl))));
const flush=()=>new Promise(resolve=>setImmediate(resolve));
const common=()=>({app_id:TRACKER_APP_ID,uid:'',channel:'paid_1047',sid:'sid-raw',client_ts:Math.floor(Date.now()/1000),device:'PC',device_id:'sdk-device',device_brand:'',device_model:'',device_fingerprint:'raw-fingerprint',fp_version:'3.0.1',user_agent:'test UA',sdk_version:'1.2.0',app_version:'1.0.0'});
class Element extends EventTarget {
dataset={};hidden=false;attributes=new Map();
getClientRects(){return this.hidden?[]:[{}];}
closest(selector){
if(selector.includes('[hidden]'))return this.hidden||this.closedDialog?this:null;
if(selector==='[data-track-item]'&&this.hasAttribute('data-track-item'))return this;
if(selector==='[data-track-ad="external"]'&&this.getAttribute('data-track-ad')==='external')return this;
if(selector.includes('a[data-app-download]')&&this.hasAttribute('data-app-download'))return this;
if(selector.includes('a[data-track-navigation]')&&this.dataset.trackNavigation)return this;
return this.parents?.[selector]||null;
}
getAttribute(name){return this.attributes.get(name)||null;}
hasAttribute(name){return this.attributes.has(name);}
}
class Video extends Element {currentTime=0;duration=100;playbackRate=1.25;paused=false;ended=false;seeking=false;}
function fixture(){
const saved=new Map(),events=[],calls=[],nodes=[],results=[],diagnostics=[],observed=new Set(),consoleCalls=[];
let clock=1000,video,intersection,mutation,modal=null;
const fields=common();
const sdk={init(options){calls.push(options);},track:new Proxy({}, {get:(_,method)=>async payload=>{events.push({method,payload});const result=JSON.parse(JSON.stringify(await sdkSandbox.WebSDK.track[method](payload)));results.push({method,...result});return result;}}),getCommonFields:async()=>({...fields}),setUid(uid){fields.uid=uid;},setChannel(channel){fields.channel=channel;},notifyPageLoad(){calls.push('route');},flush:async()=>{calls.push('flush');}};
const win=new EventTarget();win.WebSDK=sdk;win.console={warn:(...args)=>consoleCalls.push(args)};
const document=new EventTarget();Object.assign(document,{referrer:'https://search.example/',documentElement:new Element(),head:{append(){}},querySelector:selector=>selector==='dialog:modal'?modal:video||null,querySelectorAll:()=>nodes});
document.addEventListener('tracker:diagnostic',event=>diagnostics.push(event.detail));
const values={window:win,document,location:{pathname:'/',search:'',origin:'https://site.example',href:'https://site.example/'},performance:{now:()=>clock,getEntriesByType:()=>[{duration:42}]},Element,HTMLVideoElement:Video,IntersectionObserver:class{constructor(callback){intersection=callback;}observe(node){observed.add(node);}unobserve(node){observed.delete(node);}disconnect(){observed.clear();}},MutationObserver:class{constructor(callback){mutation=callback;}observe(){}},innerWidth:1000,innerHeight:800,screen:{width:1000,height:800}};
for(const [key,value] of Object.entries(values)){saved.set(key,Object.getOwnPropertyDescriptor(globalThis,key));Object.defineProperty(globalThis,key,{configurable:true,writable:true,value});}
const tracker=new SiteTracker();win.tracker=tracker;
return {tracker,events,calls,fields,nodes,win,document,results,diagnostics,observed,sdk,consoleCalls,location:values.location,mutate:()=>mutation([]),setModal:value=>{modal=value;},setClock:value=>{clock=value;},setVideo:value=>{video=value;},intersect:node=>intersection([{target:node,isIntersecting:true}]),close(){for(const [key,descriptor] of saved)if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}};
}
const drama={id:23456,title:'短剧标题',totalEpisode:12,themeIds:[8],themeNames:['都市'],tagIds:[9],tagNames:['爱情']};
const chapters=[{id:90001,currentEpisode:1,mediaTime:100},{id:90002,currentEpisode:2,mediaTime:100}];
function advertisement(placement='inline'){
const node=new Element();node.attributes.set('data-track-ad','external');
node.dataset={adId:'same-code',adSlotKey:'slot-3',adSlotName:'推荐应用',adType:'icon',adClickable:'1',adPlacement:placement};
return node;
}
test('real SDK accepts content exposures, clicks, search and navigation fields including the root parent id',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
const item=new Element();item.attributes.set('data-track-item','');item.dataset={itemId:'23456',itemType:'short_drama',itemName:'剧名'};
f.intersect(item);f.tracker.click({target:item,clientX:10,clientY:10});
f.tracker.search('短剧',28,1);
const nav=new Element();nav.dataset.trackNavigation='tg_group';nav.textContent='官方TG群';nav.href='https://t.me/example';nav.attributes.set('href',nav.href);
f.tracker.click({target:nav,clientX:10,clientY:10});
await flush();
for(const method of ['appPageView','appPageitemExposure','appPageitemClick','keywordSearch','navigation','pageClick'])assert.ok(f.results.some(result=>result.method===method&&result.ok),method);
assert.ok(f.events.filter(e=>e.method.startsWith('appPageitem')).every(e=>e.payload.parent_item_id==='0'));
assert.deepEqual(f.results.filter(result=>!result.ok),[]);assert.deepEqual(f.diagnostics,[]);
}finally{f.close();}
});
test('home, large-popup and grid-popup advertisements report independently without duplicating each placement',async()=>{
const f=fixture();try{
const nodes=['inline','startup-ad','startup-grid'].map(advertisement);
// Popup events can arrive before the asynchronous SDK configuration.
for(const node of nodes){f.tracker.externalAd(node,'show');f.tracker.externalAd(node,'show');f.tracker.externalAd(node,'click');}
assert.equal(f.events.length,0);
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(f.events.filter(e=>e.method==='adImpression').length,3);assert.equal(f.events.filter(e=>e.method==='adClick').length,3);
assert.ok(f.events.filter(e=>e.method==='adImpression').every(e=>e.payload.ad_slot_key==='slot-3'),'retain API advertisement slot codes');
assert.deepEqual(f.results.filter(result=>!result.ok),[]);
const count=f.events.length;f.tracker.externalAd(nodes[1],'close');assert.equal(f.events.length,count,'closing is not an ad click');
for(const node of nodes.slice(1)){f.tracker.advertising(node,'close');f.tracker.advertising(node,'close');}
const popup=f.events.filter(e=>e.method==='advertising');
assert.equal(popup.filter(e=>e.payload.event_type==='show').length,2);
assert.equal(popup.filter(e=>e.payload.event_type==='click').length,2);
assert.equal(popup.filter(e=>e.payload.event_type==='close').length,2);
await flush();assert.deepEqual(f.results.filter(result=>!result.ok),[]);
}finally{f.close();}
});
test('closed dialogs and obscured background content are observed again when the modal closes',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
const background=advertisement(),grid=advertisement('startup-grid'),offscreen=advertisement('offscreen');offscreen.dataset.adId='offscreen';
f.nodes.push(background,grid,offscreen);grid.closedDialog=true;f.mutate();
const modal={contains:node=>node===grid||node===offscreen};f.setModal(modal);grid.closedDialog=false;f.mutate();
assert.equal(f.observed.has(background),false);assert.ok(f.observed.has(grid));
f.intersect(background);assert.equal(f.events.filter(e=>e.method==='adImpression').length,0);
f.intersect(grid);assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);
assert.equal(f.events.some(e=>e.payload.ad_id==='offscreen'),false,'a scrollable grid must only expose intersecting tiles');
f.intersect(offscreen);assert.equal(f.events.filter(e=>e.method==='adImpression').length,2);
grid.closedDialog=true;offscreen.closedDialog=true;f.setModal(null);f.mutate();
assert.ok(f.observed.has(background));f.intersect(background);assert.equal(f.events.filter(e=>e.method==='adImpression').length,3);
f.document.hidden=true;const hidden=advertisement('hidden');hidden.dataset.adId='hidden';const before=f.events.length;
f.tracker.externalAd(hidden,'show');f.intersect(hidden);assert.equal(f.events.length,before);
f.nodes.push(hidden);f.document.hidden=false;f.document.dispatchEvent(new Event('visibilitychange'));
assert.ok(f.observed.has(hidden));f.intersect(hidden);assert.equal(f.events.filter(e=>e.method==='adImpression').length,4);
}finally{f.close();}
});
test('SDK validation errors and denied event codes produce sanitized diagnostics instead of disappearing silently',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
const broken=new Element();broken.attributes.set('data-track-item','');broken.dataset={itemId:'7',itemType:'short_drama'};
f.intersect(broken);await flush();assert.equal(f.diagnostics[0].reason,'event_validation_failed');assert.ok(f.diagnostics[0].missingFields.includes('item_name'));
f.sdk.track={adImpression:async()=>({ok:false,reason:'event_code_not_allowed',encryptedConfig:'never-log-cipher',payload:{uid:'never-log-uid'}})};
f.tracker.externalAd(advertisement(),'show');await flush();
assert.equal(f.diagnostics.at(-1).reason,'event_code_not_allowed');assert.ok(!JSON.stringify(f.diagnostics).includes('never-log'));
f.tracker.externalAd(advertisement('next'),'show');await flush();assert.equal(f.diagnostics.filter(d=>d.reason==='event_code_not_allowed').length,1);
assert.deepEqual(f.consoleCalls,[]);assert.deepEqual(sdkLogs,[]);
}finally{f.close();}
});
test('bootstrap SDK configuration requests bypass SDK readiness, and delayed config preserves more than 200 queued events',async()=>{
const f=fixture();try{
f.sdk.getCommonFields=()=>{throw Error('configuration must not depend on SDK fields');};
assert.deepEqual(await f.tracker.requestHeaders('config/info','GET',{}),{});
for(let n=0;n<220;n++){const node=advertisement();node.dataset.adId='ad-'+n;f.tracker.externalAd(node,'show');}
f.tracker.externalAd(advertisement('startup-grid'),'click');
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(f.events.filter(e=>e.method==='adImpression').length,221);assert.equal(f.events.filter(e=>e.method==='adClick').length,1);
assert.deepEqual(f.results.filter(result=>!result.ok),[]);
}finally{f.close();}
});
test('all playback behavior and episode status payloads pass the real SDK validator',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
f.tracker.setDrama(drama,chapters[0],chapters);
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
for(const behavior of ['ep_play','ep_pause','ep_forward','ep_rewind','ep_complete','ep_share','ep_failed'])f.tracker.dramaEvent(behavior,video);
for(const behavior of ['ep_previous','ep_next','ep_switch','ep_unlock','ep_auto_play'])f.tracker.dramaStatusChange(chapters[1],behavior);
await flush();assert.deepEqual(f.results.filter(result=>!result.ok),[]);assert.deepEqual(f.diagnostics,[]);
assert.ok(f.results.some(result=>result.method==='dramaEvent'));assert.ok(f.results.some(result=>result.method==='dramaStatusChange'));
}finally{f.close();}
});
test('SDK loading can recover after the first script was unavailable and a failed init leaves queued events retryable',async()=>{
const f=fixture();try{
delete f.win.WebSDK;let loaded=0;
f.document.createElement=()=>({remove(){}});
f.document.head.append=script=>{loaded++;queueMicrotask(()=>{f.win.WebSDK=f.sdk;script.onload();});};
const initialize=f.sdk.init;let fail=true;
f.sdk.init=options=>{if(fail)throw Error('private-config-must-not-appear');return initialize(options);};
f.tracker.externalAd(advertisement('startup-ad'),'show');f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(loaded,1);assert.equal(f.events.length,0);assert.equal(f.diagnostics[0].reason,'sdk_initialization_failed');
fail=false;f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(f.events.filter(e=>e.method==='appPageView').length,1);assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);
assert.ok(!JSON.stringify(f.diagnostics).includes('private-config'));
}finally{f.close();}
});
test('SDK initializes once from dynamic config, queues the first PV and tracks each native route once',async()=>{
const f=fixture();try{
f.tracker.configure({});await flush();assert.equal(f.calls.length,0);assert.equal(f.events.length,0);
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(f.calls[0].appId,TRACKER_APP_ID);assert.equal(f.calls[0].encryptedConfig,'configured-cipher');assert.deepEqual(f.calls[0].disableAutoEvents,['app_page_view','page_click']);
assert.equal(f.events.filter(e=>e.method==='appPageView').length,1);
f.tracker.configure({reportSdkConfig:'configured-cipher'});f.tracker.pageView();assert.equal(f.events.length,1);
f.location.pathname='/rank/new/page/2/';f.tracker.pageView();
const pv=f.events.at(-1);assert.equal(pv.payload.page_key,'rank_new');assert.equal(pv.payload.referrer_page_key,'home');assert.equal(f.calls.filter(c=>c==='route').length,1);
f.tracker.syncUser({userId:123456});assert.equal(f.fields.uid,'123456');f.tracker.syncUser(null);assert.equal(f.fields.uid,'');
for(const path of ['/navigation/','/info/about-us/','/member/settings/','/topics/3/','/video/8/'])assert.notEqual(trackingPage(path).key,'internal_other');
const count=f.events.filter(e=>e.method==='appPageView').length;
f.tracker.resumePage();assert.equal(f.events.filter(e=>e.method==='appPageView').length,count+1);
f.tracker.pageView();assert.equal(f.events.filter(e=>e.method==='appPageView').length,count+1,'ordinary bindings must not duplicate the restored page view');
}finally{f.close();}
});
test('ad visibility and clicks deduplicate across DOM replacements, resetting only on a new page',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
const ad=new Element();ad.attributes.set('data-track-ad','external');ad.dataset={adId:'advertise-code',adSlotKey:'slot',adSlotName:'广告位',adType:'banner',adClickable:'1'};
ad.hidden=true;f.intersect(ad);assert.equal(f.events.filter(e=>e.method==='adImpression').length,0);
ad.hidden=false;f.intersect(ad);f.tracker.externalAd(ad,'show');f.tracker.externalAd(ad,'click');
assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);assert.equal(f.events.filter(e=>e.method==='adClick').length,1);
f.location.pathname='/shorts/';f.tracker.pageView();f.tracker.externalAd(ad,'show');assert.equal(f.events.filter(e=>e.method==='adImpression').length,2);
f.location.pathname='/';f.tracker.pageView();f.tracker.externalAd(ad,'show');assert.equal(f.events.filter(e=>e.method==='adImpression').length,2,'returning to a previously seen tab must not repeat its ads');
}finally{f.close();}
});
test('play durations exclude buffering and preview, speed is scaled, and seek/completion/status use named events',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
f.tracker.setDrama(drama,chapters[0],chapters);
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
const emit=type=>f.tracker.videoEvent({type,target:video});
emit('play');emit('playing');f.setClock(5000);emit('waiting');f.setClock(15000);emit('playing');f.setClock(18000);video.paused=true;video.currentTime=30;emit('pause');
const pause=f.events.filter(e=>e.payload.play_behavior_key==='ep_pause').at(-1);assert.equal(pause.payload.play_duration,7);assert.equal(pause.payload.play_speed,125);assert.equal(pause.payload.play_progress,30);
const count=f.events.length;mode.dataset.playMode='preview';emit('play');emit('playing');assert.equal(f.events.length,count);mode.dataset.playMode='main';
video.currentTime=20;emit('timeupdate');emit('seeking');video.currentTime=60;emit('seeked');assert.equal(f.events.at(-1).payload.play_behavior_key,'ep_forward');
video.currentTime=100;video.ended=true;emit('ended');assert.equal(f.events.at(-2).payload.play_behavior_key,'ep_complete');assert.equal(f.events.at(-1).payload.play_behavior_key,'ep_play');assert.equal(f.events.at(-1).payload.play_progress,100);
f.tracker.dramaStatusChange(chapters[1]);assert.equal(f.events.at(-1).payload.drama_behavior_key,'ep_next');f.tracker.dramaStatusChange(chapters[1],'ep_unlock');assert.equal(f.events.at(-1).payload.drama_behavior_key,'ep_unlock');
assert.ok(!f.events.some(e=>['dramaLike','dramaCollect','dramaPurchase','dramaComment'].includes(e.method)));
}finally{f.close();}
});
test('request headers preserve SDK values and snapshot the selected purchase chapter independently of route changes',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();f.tracker.setDrama(drama,chapters[0],chapters);
const pending=f.tracker.requestHeaders('playlet/chapter/unlock','POST',{playletId:23456,chapterId:90002});
f.location.pathname='/shorts/';f.tracker.pageView();
const headers=await pending,context=JSON.parse(Buffer.from(headers['X-DC-Request-Context'],'base64url'));
assert.deepEqual(context.common,f.fields);assert.equal(context.drama.drama_id,'23456');assert.equal(context.drama.video_id,'90002');assert.equal(context.drama.episode,2);
assert.equal(context.event_name,'drama_purchase');assert.match(context.request_id,/^[a-f0-9]{32}$/);assert.equal(context.request_id,headers['X-Tracker-Request-Id']);
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),f.fields);
const videoComment=await f.tracker.requestHeaders('comment/send','POST',{objectId:2,objectType:20});assert.equal(videoComment['X-DC-Request-Context'],undefined);
const order=await f.tracker.requestHeaders('coin/buy','POST',{});assert.ok(order['X-SDK-Common']);assert.equal(order['X-Tracker-Event-Name'],undefined);
}finally{f.close();}
});
test('terminal playback errors report failed once per attempt, exclude preview and use the video dictionary',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
f.tracker.setDrama(drama,chapters[0],chapters);
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
f.tracker.videoEvent({type:'playing',target:video});f.setClock(5000);video.currentTime=30;
f.tracker.playbackFailed(video);f.tracker.playbackFailed(video);
const failed=f.events.filter(e=>e.payload.play_behavior_key==='ep_failed');assert.equal(failed.length,1);assert.equal(failed[0].payload.play_behavior_name,'播放失败');assert.equal(failed[0].payload.play_duration,4);
mode.dataset.playMode='preview';f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.filter(e=>e.payload.play_behavior_key==='ep_failed').length,1);
mode.dataset.playMode='main';f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.filter(e=>e.payload.play_behavior_key==='ep_failed').length,2);
f.tracker.setDrama(drama,null,[],true);assert.equal(f.events.at(-1).payload.video_behavior_name,'视频展示');
f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.at(-1).method,'videoEvent');assert.equal(f.events.at(-1).payload.video_behavior_key,'video_failed');assert.equal(f.events.at(-1).payload.video_behavior_name,'播放失败');
}finally{f.close();}
});
test('completion includes 100 percent play progress while preserving the final playback interval',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();f.tracker.setDrama(drama,chapters[0],chapters);
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
f.tracker.videoEvent({type:'playing',target:video});f.setClock(9000);video.currentTime=100;video.ended=true;f.tracker.videoEvent({type:'ended',target:video});
const [complete,progress]=f.events.slice(-2);assert.equal(complete.payload.play_behavior_key,'ep_complete');assert.equal(progress.payload.play_behavior_key,'ep_play');
assert.equal(complete.payload.play_progress,100);assert.equal(progress.payload.play_progress,100);assert.equal(complete.payload.play_duration,8);assert.equal(progress.payload.play_duration,8);
const count=f.events.length;f.tracker.reportProgress();assert.equal(f.events.length,count,'the final playback interval must be consumed after progress reporting');
}finally{f.close();}
});
test('all SDK public fields reach login and order requests unchanged including numeric timestamps and optional system metadata',async()=>{
const f=fixture();try{
Object.assign(f.fields,{device_brand:'品牌',device_model:'model',system_name:'test-system',system_version:'1.2.3'});
for(const path of ['auth/login','auth/register','coin/buy','vip/pay']){
const headers=await f.tracker.requestHeaders(path,'POST',{}),forwarded=trackingHeaders(new Headers(headers),path,'POST');
assert.deepEqual(JSON.parse(Buffer.from(forwarded['X-SDK-Common'],'base64')),f.fields);assert.equal(forwarded['X-DC-Request-Context'],undefined);
}
}finally{f.close();}
});
test('the BFF accepts only the correct route, app, common fields and context schema without blocking business data',()=>{
const fields=common(),requestId='1234567890abcdef1234567890abcdef';
const context={v:1,request_id:requestId,event_name:'drama_purchase',common:fields,drama:{media_id:'',drama_id:'23456',episode:2,video_id:'90002',recommend_trace_id:''}};
const source=new Headers({'X-SDK-Common':base64JSON(Object.fromEntries(SDK_COMMON_KEYS.map(k=>[k,fields[k]]))),'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_purchase','X-DC-Request-Context':base64JSON(context,true)});
const forwarded=trackingHeaders(source,'playlet/chapter/unlock','POST');assert.deepEqual(JSON.parse(Buffer.from(forwarded['X-DC-Request-Context'],'base64url')),context);
assert.ok(forwarded['X-User-Agent'].includes('sid=sid-raw'));assert.equal(forwarded.Authorization,undefined);
for(const [path,method] of [['playlet/collect/set','POST'],['config/info','GET'],['playlet/chapter/unlock','GET']])assert.equal(trackingHeaders(source,path,method)['X-DC-Request-Context'],undefined);
for(const invalid of [{...context,request_id:'other'},{...context,common:{...fields,app_id:'JHA-1047'}},{...context,common:{...fields,token:'secret'}},{...context,drama:{...context.drama,episode:0}}]){
const headers=new Headers(source);headers.set('X-DC-Request-Context',base64JSON(invalid,true));const result=trackingHeaders(headers,'playlet/chapter/unlock','POST');assert.equal(result['X-DC-Request-Context'],undefined);assert.ok(result['X-SDK-Common']);
}
assert.deepEqual(trackingHeaders(new Headers({'X-SDK-Common':'invalid','X-DC-Request-Context':'x'.repeat(9000)}),'playlet/chapter/unlock','POST'),{});
});
+3 -3
View File
@@ -21,9 +21,9 @@ const cache=await compile('public-cache');
test('development uses its configured domain and supports legacy overrides',()=>{
delete environment.UPSTREAM_API_URL;delete environment.API_BASE_URL;
assert.equal(config.getApiBaseUrl(),'http://172.104.181.99:21022');
environment.APP_ENV='test';assert.equal(config.getApiBaseUrl(),'http://172.104.181.99:21022');
environment.APP_ENV='production';assert.equal(config.getApiBaseUrl(),'http://172.104.56.28:26125');
assert.equal(config.getApiBaseUrl(),'https://webhgcgapi.mfcg.me');
environment.APP_ENV='test';assert.equal(config.getApiBaseUrl(),'https://webhgcgapi.mfcg.me');
environment.APP_ENV='production';assert.equal(config.getApiBaseUrl(),'https://webhgcgapi.mfcg.me');
delete environment.APP_ENV;
environment.API_BASE_URL='https://legacy-api.example/';assert.equal(config.getApiBaseUrl(),'https://legacy-api.example');
environment.UPSTREAM_API_URL=' http://127.0.0.1:21111/ ';
+4 -5
View File
@@ -24,11 +24,10 @@ test('proxy methods and public endpoints are backed by the reviewed API contract
for(const method of ['GET','POST'])for(const path of ['video/detail','video/all/list','playlet/operate/list','playlet/home/config/detail/list','statcenter/sync'])assert.equal(server.allowedRequest(path,method),false,method+' '+path);
});
test('the user-confirmed likes list is an authenticated GET endpoint',()=>{
assert.equal(server.allowedRequest('playlet/like/list','GET'),true);
assert.equal(server.allowedRequest('playlet/like/list','POST'),false);
assert.equal(server.PUBLIC_PATHS.has('playlet/like/list'),false);
assert.deepEqual(contract['GET playlet/like/list'].query,['pageNum','pageSize']);
test('removed account, recharge, unlock and login endpoints are no longer proxied',()=>{
for(const path of ['playlet/like/list','playlet/collect/list','playlet/history/list','playlet/bought/list','user/center/data','coin/trans/list','coin/orders','vip/orders','recharge/package/list'])assert.equal(server.allowedRequest(path,'GET'),false,path);
for(const path of ['auth/login','auth/register','auth/resetPassword','auth/profile','auth/logout','coin/buy','vip/pay','playlet/chapter/unlock','upload/image','playlet/history/clear','playlet/history/delete','feedback/add'])assert.equal(server.allowedRequest(path,'POST'),false,path);
assert.equal(server.allowedRequest('auth/guest','POST'),true);
});
test('all public list routes send only supported query fields with their native page numbers',()=>{
+110 -311
View File
@@ -112,7 +112,7 @@ before(async()=>{
else if(request.headers.authorization==='http-expired'){response.writeHead(401,{'Content-Type':'application/json'});response.end(JSON.stringify({code:401,data:null}));return;}
else if(request.headers.authorization==='registered-user-a-secret'||request.headers.authorization==='registered-user-b-secret'){const first=request.headers.authorization==='registered-user-a-secret';data={userId:first?101:202,nickName:first?'用户甲':'用户乙',recharge:first?111:222,type:1,isRegister:true,token:request.headers.authorization,password:'must-not-leak'};}
else data={userId:77,nickName:'接口昵称',token:'must-not-leak',recharge:90};
}else if(path==='config/info')data={seoInfo:{webSiteName:'91短剧'},...seoConfig,config:{foreverUrlAddr:'https://default.drama.test',telegram:'https://example.com/contact',tgGroup:'https://example.com/group',twitterAddr:'https://example.com/twitter',officialAppAddr:downloadAddress,...seoConfig.config},navPageList:Object.hasOwn(seoConfig,'navPageList')?seoConfig.navPageList:[{line:2,url:'https://example.com/entry-two'},{line:1,url:'https://example.com/entry-one'},{line:3,url:'javascript:alert(1)'}]};
}else if(path==='config/info')data={seoInfo:{webSiteName:'黄果吃瓜'},...seoConfig,config:{foreverUrlAddr:'https://huangguocg.com',telegram:'https://example.com/contact',tgGroup:'https://example.com/group',twitterAddr:'https://example.com/twitter',officialAppAddr:downloadAddress,...seoConfig.config},navPageList:Object.hasOwn(seoConfig,'navPageList')?seoConfig.navPageList:[{line:2,url:'https://example.com/entry-two'},{line:1,url:'https://example.com/entry-one'},{line:3,url:'javascript:alert(1)'}]};
else if(path==='navigation/header'){data=headerPayload;if(headerFailure)code=500;}
else if(path==='config/footer/info'&&footerPayload)data=footerPayload;
else if(path==='translation/category/list')data={categoryList:categories};
@@ -156,15 +156,15 @@ before(async()=>{
after(async()=>{await stopNext();if(mock)await new Promise(resolve=>mock.close(resolve));if(seoSnapshotPath)await rm(seoSnapshotPath,{force:true});});
test('a cold configuration failure never uses request/forwarded hosts as canonical or publisher URLs',async()=>{
test('a cold configuration failure never uses request/forwarded hosts as canonical or publisher URLs, only the site domain',async()=>{
try{
configFailure='http';
const response=await fetch(base+'/info/about-us/',{headers:{'X-Forwarded-Host':'untrusted.entry.test','X-Forwarded-Proto':'https'}});
assert.equal(response.status,200);
const html=await response.text(),head=html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.ok(!head.includes('rel="canonical"'));assert.ok(!head.includes('property="og:url"'));
assert.ok(!head.includes('untrusted.entry.test'));assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
assert.match(head,/<title>关于我们 - 91短剧<\/title>/);
assert.match(head,/rel="canonical" href="https:\/\/[^"]+\/info\/about-us\/"/);
assert.ok(!head.includes('untrusted.entry.test'));
assert.match(head,/<title>关于我们 - 黄果吃瓜<\/title>/);
}finally{configFailure='';}
});
@@ -179,7 +179,7 @@ test('robots.txt serves crawler text without sessions and advertises the configu
assert.match(body,/User-Agent: \*/i);assert.ok(!body.includes('<html'));assert.ok(!body.includes('untrusted.example'));
if(testEnvironment==='production'){
assert.match(body,/^Allow: \/$/m);assert.match(body,/^Disallow: \/api\/$/m);assert.match(body,/^Disallow: \/healthz$/m);
assert.match(body,/^Sitemap: https:\/\/default\.drama\.test\/sitemap\.xml$/m);
assert.match(body,/^Sitemap: https:\/\/huangguocg\.com\/sitemap\.xml$/m);
assert.ok(!/^Disallow: \/$/m.test(body));
}else {assert.match(body,/^Disallow: \/$/m);assert.ok(!body.includes('Sitemap:'));}
}
@@ -187,17 +187,17 @@ test('robots.txt serves crawler text without sessions and advertises the configu
assert.ok(reads.every(call=>call.path==='/api/app/config/info'&&!call.auth),'only public site configuration is needed');
});
test('robots sitemap uses permanent-domain precedence, survives configuration failure and never guesses a host',async()=>{
test('robots sitemap always uses the site domain, survives configuration failure and never guesses a host',async()=>{
if(testEnvironment!=='production')return;
try{
seoConfig={config:{foreverUrlAddr:'https://robots.drama.test/nested/?source=1'},newAddressInfo:{foreverUrlAddr:'https://backup.drama.test'}};
seoConfig={config:{foreverUrlAddr:'https://huangguocg.com/nested/?source=1'},newAddressInfo:{foreverUrlAddr:'https://backup.drama.test'}};
let response=await fetch(base+'/robots.txt');assert.equal(response.status,200);
assert.match(await response.text(),/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m);
assert.match(await response.text(),/^Sitemap: https:\/\/huangguocg\.com\/sitemap\.xml$/m);
configFailure='http';
response=await fetch(base+'/robots.txt',{headers:{'X-Forwarded-Host':'fake.test'}});assert.equal(response.status,200);
assert.match(await response.text(),/^Sitemap: https:\/\/robots\.drama\.test\/sitemap\.xml$/m);
assert.match(await response.text(),/^Sitemap: https:\/\/huangguocg\.com\/sitemap\.xml$/m);
configFailure='';seoConfig={config:{foreverUrlAddr:''},newAddressInfo:{foreverUrlAddr:'https://backup.drama.test/base/'}};
response=await fetch(base+'/robots.txt');assert.match(await response.text(),/^Sitemap: https:\/\/backup\.drama\.test\/sitemap\.xml$/m);
response=await fetch(base+'/robots.txt');assert.match(await response.text(),/^Sitemap: https:\/\/huangguocg\.com\/sitemap\.xml$/m);
}finally{configFailure='';seoConfig={};}
});
@@ -218,8 +218,9 @@ test('SSR uses upstream content, escapes HTML, and does not restore homepage fil
const response=await fetch(base);const html=await response.text();
assert.equal(response.status,200);
assert.equal((html.match(/<h1\b/g)||[]).length,1,'only the homepage title may use h1, including hidden templates');
assert.match(html,/<h1 id="home-title" class="home-page-title">91短剧 · 百万大神原创短剧<\/h1>/);
assert.match(html,/<h2>接口剧名 &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/h2>/);
assert.match(html,/<h1 id="home-title" class="home-page-title">黄果吃瓜 · 百万大神原创短剧<\/h1>/);
assert.match(html,/<h2 class="hg-hero-title"><a href="\/detail\/23456\/">接口剧名 &lt;script&gt;alert\(1\)&lt;\/script&gt;<\/a><\/h2>/);
assert.match(html,/<ol class="hg-hero-list" aria-label="今日推荐剧集"><li><a href="\/detail\/23456\/" data-feature="0" aria-current="true">/);
assert.ok(html.includes('后台配置标题'));
assert.ok(html.includes('接口剧名 &lt;script&gt;'));
assert.ok(!html.includes('<script>alert(1)</script>'));
@@ -268,13 +269,13 @@ test('/navigation renders direct download links without a hover QR panel',async(
const before=calls.length;
const response=await fetch(base+'/navigation/',{redirect:'manual'});const html=await response.text();
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
assert.ok(html.includes('<title>官方入口 - 91短剧</title>'));
assert.ok(html.includes('<title>官方入口 - 黄果吃瓜</title>'));
assert.ok(html.includes('<div id="entry-navigation" class="navigation-page">'));
assert.ok(!html.includes('<dialog id="entry-navigation"'));
assert.ok(html.includes('91短剧 · 线路一'));assert.ok(html.includes('91短剧 · 线路二'));assert.ok(!html.includes('91短剧 · 线路三'));
assert.ok(html.includes('href="https://example.com/entry-one"'));assert.ok(html.includes('href="https://example.com/entry-two"'));
assert.ok(html.includes('黄果吃瓜 · 线路一'));assert.ok(!html.includes('黄果吃瓜 · 线路二'));
assert.ok(html.includes('class="entry-route" href="https://huangguocg.com/"'));assert.ok(!html.includes('href="https://example.com/entry-one"'));assert.ok(!html.includes('href="https://example.com/entry-two"'));
assert.ok(!html.includes('href="javascript:'));
assert.ok(html.includes('href="https://example.com/contact"'));assert.ok(html.includes('href="https://example.com/group"'));
assert.ok(html.includes('官方TG:@jbd125'));assert.ok(html.includes('href="https://t.me/jbd125"'));assert.ok(!html.includes('href="https://example.com/contact"'));assert.ok(!html.includes('href="https://example.com/group"'));
assert.ok(html.includes('href="/home-road/"'));assert.ok(html.includes('href="/apps/"'));
assert.match(html,/<a class="entry-download" href="https:\/\/example.com\/download" target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download=""/);
assert.match(html,/<a class="entry-channel" href="https:\/\/example.com\/download" target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download=""/);
@@ -286,31 +287,32 @@ test('/navigation renders direct download links without a hover QR panel',async(
assert.equal((await fetch(base+'/navigation/',{redirect:'manual',headers:{'Cache-Control':'no-cache'}})).status,200);
});
test('/navigation renders configured identity, routes, permanent entry and channels in its initial HTML',async()=>{
test('/navigation uses the fixed name and site domain with configured description and channels in its initial HTML',async()=>{
try{
seoConfig={seoNavInfo:{dhSiteName:'动态导航站',dhDescription:'后端配置的导航介绍'},config:{telegram:'@configured_contact',tgGroup:'@configured_group',twitterAddr:'https://x.com/configured_91',foreverUrlAddr:'https://permanent.drama.test/entry'},navPageList:[{line:4,url:'https://four.drama.test'},{line:2,url:'https://two.drama.test'}]};
const start=calls.length,response=await fetch(base+'/navigation/'),html=await response.text();
assert.equal(response.status,200);assert.ok(html.includes('动态导航站官方入口'));assert.ok(html.includes('后端配置的导航介绍'));
assert.ok(html.includes('动态导航站 · 线路二'));assert.ok(html.includes('动态导航站 · 线路四'));assert.ok(!html.includes('动态导航站 · 线路一'));
assert.ok(html.indexOf('动态导航站 · 线路二')<html.indexOf('动态导航站 · 线路四'));
assert.match(html,/<a class="entry-permanent" href="https:\/\/permanent.drama.test\/entry" rel="sponsored nofollow noopener noreferrer"/);
for(const url of ['https://t.me/configured_contact','https://t.me/configured_group','https://x.com/configured_91'])assert.ok(html.includes(`href="${url}"`));
assert.equal(response.status,200);assert.ok(html.includes('黄果吃瓜官方入口'));assert.ok(!html.includes('动态导航站官方入口'));assert.ok(!html.includes('动态导航站 · '));assert.ok(!html.match(/<title>[^<]*<\/title>/)[0].includes('动态导航站'));assert.ok(html.includes('后端配置的导航介绍'));
assert.ok(html.includes('黄果吃瓜 · 线路一'));assert.ok(!html.includes('黄果吃瓜 · 线路二'));for(const host of ['permanent.drama.test','two.drama.test','four.drama.test'])assert.ok(!html.includes('href="https://'+host),host);
assert.match(html,/<a class="entry-permanent" href="https:\/\/huangguocg.com\/" rel="sponsored nofollow noopener noreferrer"/);
for(const url of ['https://t.me/configured_contact','https://t.me/configured_group','https://x.com/configured_91'])assert.ok(!html.includes(`href="${url}"`),url);
assert.ok(html.includes('class="entry-contact" href="https://t.me/jbd125"'));assert.ok(!html.includes('官方推特'));
assert.ok(!html.includes('huangguoshortdrama'));assert.ok(!html.includes('huangguodrama'));
assert.deepEqual(calls.slice(start).map(call=>call.path),['/api/app/config/info']);
}finally{seoConfig={};}
});
test('/navigation omits unconfigured routes and contacts instead of showing homepage links or demo accounts',async()=>{
test('/navigation keeps the site entry and official TG but omits unconfigured downloads',async()=>{
const saved=downloadAddress;
try{
downloadAddress='';
seoConfig={navPageList:null,config:{foreverUrlAddr:'',telegram:'',telegramAdd:'',tgGroup:'',twitterAddr:''}};
const response=await fetch(base+'/navigation/'),html=await response.text();
assert.equal(response.status,200);assert.ok(html.includes('暂未配置访问线路'));
for(const className of ['entry-route','entry-permanent','entry-contact','entry-download'])assert.ok(!html.includes(`class="${className}"`));
assert.equal(response.status,200);assert.ok(!html.includes('暂未配置访问线路'));
for(const className of ['entry-route','entry-permanent'])assert.ok(html.includes(`class="${className}" href="https://huangguocg.com/"`),className);
assert.ok(html.includes('class="entry-contact" href="https://t.me/jbd125"'));assert.ok(!html.includes('class="entry-download"'));
for(const label of ['官方TG群','官方推特'])assert.ok(!html.includes(label));
assert.ok(html.includes('href="/home-road/"'));assert.ok(html.includes('href="/apps/"'));
assert.ok(html.includes('grid-template-columns:repeat(2, minmax(0, 1fr))'));
assert.ok(html.includes('grid-template-columns:repeat(3, minmax(0, 1fr))'));
}finally{seoConfig={};downloadAddress=saved;}
});
@@ -328,8 +330,9 @@ test('/navigation omits every download entry when no valid App URL is configured
}finally{downloadAddress=saved;}
});
test('all clean page URLs support direct access and refresh',async()=>{
const paths=['/shorts/','/topics/','/rank/','/apps/','/home-road/','/topics/3/','/theme/8/','/tag/9/','/config/31/','/detail/23456/','/play/23456/90001/','/search/'+encodeURIComponent('都市 / 爱情?#%')+'/','/member/center/','/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/member/settings/','/info/deposit/','/info/deposit/vip/','/info/user-center/','/info/settings/','/info/about-us/','/info/user-agreement/','/info/privacy-policy/','/info/help-center/','/info/2257/','/info/dmca/','/info/feedback/'];
test('all clean page URLs support direct access and refresh, while removed account and recharge pages are 404',async()=>{
for(const path of ['/member/center/','/member/history/','/info/deposit/','/info/deposit/vip/','/info/user-center/','/info/settings/'])assert.equal((await fetch(base+path,{redirect:'manual'})).status,404,path);
const paths=['/shorts/','/topics/','/rank/','/apps/','/home-road/','/topics/3/','/theme/8/','/tag/9/','/config/31/','/detail/23456/','/play/23456/90001/','/search/'+encodeURIComponent('都市 / 爱情?#%')+'/','/info/about-us/','/info/user-agreement/','/info/privacy-policy/','/info/help-center/','/info/2257/','/info/dmca/'];
for(const path of paths){
const response=await fetch(new URL(path,base+'/'),{redirect:'manual'});
assert.equal(response.status,200,path);
@@ -349,7 +352,7 @@ test('all clean page URLs support direct access and refresh',async()=>{
test('page slash redirects preserve encoded paths and queries without requesting upstream content',async()=>{
const keyword=encodeURIComponent('都市 / 爱情?#% example.com');
const paths=['/navigation','/search','/search/'+keyword,'/search/'+keyword+'/page/2','/rank/hot','/rank/new','/rank/follow','/info/deposit/vip','/shorts/page/2'];
const paths=['/navigation','/search','/search/'+keyword,'/search/'+keyword+'/page/2','/rank/hot','/rank/new','/rank/follow','/shorts/page/2'];
const query='?channel=a%2Fb+1&sort=2&source=x%252Fy';
const before=calls.length;
for(const path of paths){
@@ -383,11 +386,11 @@ test('empty and paginated search entries retain native paths and keyword-specifi
test('fixed information pages retain local article markup and only load shared head configuration in SSR',async()=>{
for(const [key,title,marker] of [
['about-us','关于我们','品牌介绍'],['user-agreement','用户协议','关于《使用条款》'],['privacy-policy','隐私政策','八、问题和建议'],
['help-center','常见问题','<details open>'],['2257','2257 合规声明','资料留存'],['dmca','DMCA 版权声明','mailto:huangguoaiai@gmail.com'],
['help-center','常见问题','<details open>'],['2257','2257 合规声明','资料留存'],['dmca','DMCA 版权声明','href="https://t.me/jbd125"'],
]){
const count=calls.length,response=await fetch(base+`/info/${key}/`,{redirect:'manual'});
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
const html=await response.text();assert.ok(html.includes(`<title>${title} - 91短剧</title>`));assert.ok(html.includes(marker),key);
const html=await response.text();assert.ok(html.includes(`<title>${title} - 黄果吃瓜</title>`));assert.ok(html.includes(marker),key);
const main=html.match(/<main\b[^>]*\bid="info-page"[^>]*>/)?.[0];assert.ok(main);assert.ok(main.includes(`data-info="${key}"`));assert.ok(!/\shidden(?:[=\s>])/.test(main));
assert.deepEqual(calls.slice(count).map(call=>call.path).sort(),['/api/app/auth/guest','/api/app/config/info','/api/app/config/footer/info','/api/app/navigation/header','/api/app/translation/category/list'].sort(),'fixed page SSR must not fetch homepage or rich text');
assert.ok(html.includes('class="home-content" hidden=""'));
@@ -412,7 +415,7 @@ test('ranking slash URLs render their titles and bare URLs redirect to the same
continue;
}
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null,path);
const html=await response.text();assert.ok(html.includes(`<title>${title} - 91短剧</title>`),path);
const html=await response.text();assert.ok(html.includes(`<title>${title} - 黄果吃瓜</title>`),path);
assert.ok(html.includes('href="/rank/hot/"'));assertPageLinksHaveTrailingSlash(html);
}
}
@@ -420,7 +423,7 @@ test('ranking slash URLs render their titles and bare URLs redirect to the same
test('SSR emits configured TDK, site_name, canonical and bounded pagination links inside head for browser and crawler user agents',async()=>{
try{
seoConfig={config:{foreverUrlAddr:'https://canonical.drama.test/entry/?channel=abc'},seoInfo:JSON.stringify({title:'后台首页标题',description:'后台首页描述',keyworld:'首页词,短剧',webSiteName:'91剧场'})};
seoConfig={config:{foreverUrlAddr:'https://huangguocg.com/entry/?channel=abc'},seoInfo:JSON.stringify({title:'后台首页标题',description:'后台首页描述',keyworld:'首页词,短剧',webSiteName:'91剧场'})};
categories=[{urlValue:'/playlet/',seoInfo:{title:'短剧导航标题',description:'短剧导航描述',keyworld:['导航关键词']}}];
listPayload={list:[fixture],totalCount:72,seoInfo:{title:'列表接口标题',description:'列表接口描述',keywords:['接口关键词']}};
const headOf=html=>{const match=html.match(/<head>([\s\S]*?)<\/head>/);assert.ok(match);return match[1];};
@@ -430,8 +433,8 @@ test('SSR emits configured TDK, site_name, canonical and bounded pagination link
assert.match(head,/<meta name="description" content="后台首页描述"/);
assert.match(head,/<meta name="keywords" content="首页词,短剧"/);
assert.match(head,/<meta property="og:site_name" content="91剧场"/);
assert.match(head,/<link rel="canonical" href="https:\/\/canonical.drama.test\/"/);
assert.match(head,/<meta property="og:image" content="https:\/\/canonical.drama.test\/og-default.png"/);
assert.match(head,/<link rel="canonical" href="https:\/\/huangguocg.com\/"/);
assert.match(head,/<meta property="og:image" content="https:\/\/huangguocg.com\/og-default.png"/);
assert.match(head,/<meta name="twitter:card" content="summary_large_image"/);
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/playlet/home').length,1,'SSR and metadata share one homepage request');
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/config/info').length,1,'SSR and metadata share one configuration request');
@@ -441,13 +444,13 @@ test('SSR emits configured TDK, site_name, canonical and bounded pagination link
const response=await fetch(base+path+'?channel=campaign',{redirect:'manual',headers:{'User-Agent':agent}});
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
const head=headOf(await response.text());
const canonical=`https://canonical.drama.test/shorts/${page>1?`page/${page}/`:''}`;
const canonical=`https://huangguocg.com/shorts/${page>1?`page/${page}/`:''}`;
assert.ok(head.includes(`<link rel="canonical" href="${canonical}"`));
assert.ok(head.includes(`<meta property="og:url" content="${canonical}"`));
assert.ok(head.includes(`<title>短剧导航标题${page>1?' - 第'+page+'页':''}</title>`));
assert.ok(head.includes(`<meta name="keywords" content="导航关键词${page>1?',第'+page+'页':''}"`));
assert.equal(head.includes('<link rel="prev"'),page>1);assert.equal(head.includes('<link rel="next"'),page<3);
if(page===2)assert.ok(head.includes('rel="prev" href="https://canonical.drama.test/shorts/?channel=campaign"'));
if(page===2)assert.ok(head.includes('rel="prev" href="https://huangguocg.com/shorts/?channel=campaign"'));
assert.equal((head.match(/rel="canonical"/g)||[]).length,1);
}
}
@@ -461,29 +464,29 @@ test('SSR emits configured TDK, site_name, canonical and bounded pagination link
}finally{seoConfig={};categories=[];listPayload=undefined;}
});
test('detail SEO reads protected upstream content without exposing credentials and keeps registered sessions intact',async()=>{
test('detail SEO reads protected upstream content without exposing credentials and replaces stale accounts with a guest',async()=>{
try{
requireSeoAuth=true;
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:'https://canonical.drama.test/'}};
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:'https://huangguocg.com/'}};
detailSeo={seoInfo:{title:'剧集独立标题',description:'真实剧集描述',keywords:'剧集词'}};
const start=calls.length;
const response=await fetch(base+'/detail/23456/',{headers:{'X-Real-IP':'203.0.113.8'}});assert.equal(response.status,200);
const html=await response.text(),head=html.match(/<head>([\s\S]*?)<\/head>/)[1];
assert.ok(head.includes('<title>剧集独立标题 - 91剧场</title>'));assert.ok(head.includes('content="真实剧集描述"'));
assert.ok(head.includes('rel="canonical" href="https://canonical.drama.test/detail/23456/"'));
assert.ok(head.includes('rel="canonical" href="https://huangguocg.com/detail/23456/"'));
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/auth/guest').length,1);
assert.equal(calls.slice(start).find(call=>call.path==='/api/app/auth/guest').ip,'203.0.113.8');
assert.ok(!html.includes('test-session-secret'));assert.ok(!html.includes('drama_session'));
const registeredStart=calls.length;
const registered=await fetch(base+'/detail/23456/',{headers:{Cookie:'drama_session=expired; drama_registered=1'}});
assert.equal(registered.status,200);const signedOut=await registered.text();
assert.ok(signedOut.includes('请先登录账号'));assert.ok(signedOut.includes('data-auth-mode="login"'));assert.ok(signedOut.includes('noindex'));assert.ok(!signedOut.includes('rel="canonical"'));
assert.equal(calls.slice(registeredStart).filter(call=>call.path==='/api/app/auth/guest').length,0,'registered identity is never replaced with guest');
assert.equal(registered.status,200);const restored=await registered.text();
assert.ok(!restored.includes('请先登录账号'));assert.ok(!restored.includes('data-auth-mode'));assert.ok(restored.includes('rel="canonical" href="https://huangguocg.com/detail/23456/"'));
assert.equal(calls.slice(registeredStart).filter(call=>call.path==='/api/app/auth/guest').length,1,'an expired session always becomes a guest');
}finally{requireSeoAuth=false;seoConfig={};detailSeo=undefined;}
});
test('permanent canonical and site identity survive config errors, timeouts, missing fields and a server restart',async()=>{
const domain='https://stable.drama.test',brand='稳定站点';
test('site-domain canonical and site identity survive config errors, timeouts and a server restart',async()=>{
const domain='https://huangguocg.com',brand='稳定站点';
const identity=async()=>{
const response=await fetch(base+'/shorts/page/2/?channel=campaign',{headers:{'X-Forwarded-Host':'alternate.entry.test','X-Forwarded-Proto':'http'}});
assert.equal(response.status,200);
@@ -503,14 +506,11 @@ test('permanent canonical and site identity survive config errors, timeouts, mis
listPayload={list:[fixture],totalCount:72};
await identity();
for(const failure of ['http','business','malformed','timeout']){configFailure=failure;await identity();}
configFailure='';
seoConfig={config:{foreverUrlAddr:''},newAddressInfo:{foreverUrlAddr:''},seoInfo:{webSiteName:'不可替换的错误品牌'}};
await identity();
configFailure='http';await stopNext();await startNext();await identity();
configFailure='';
seoConfig={config:{foreverUrlAddr:'https://updated.drama.test'},seoInfo:{webSiteName:'更新站点'}};
seoConfig={config:{foreverUrlAddr:'https://other.drama.test'},seoInfo:{webSiteName:'更新站点'}};
const html=await (await fetch(base+'/shorts/')).text();
assert.ok(html.includes('rel="canonical" href="https://updated.drama.test/shorts/"'));
assert.ok(html.includes('rel="canonical" href="https://huangguocg.com/shorts/"'));
assert.ok(html.includes('property="og:site_name" content="更新站点"'));
}finally{configFailure='';seoConfig={};categories=[];listPayload=undefined;}
});
@@ -523,7 +523,7 @@ test('SSR JSON-LD matches each canonical route, list page, content entity and vi
const graph=JSON.parse(scripts[0][1]);assert.equal(graph['@context'],'https://schema.org');return graph['@graph'];
};
try{
const origin='https://schema.drama.test';
const origin='https://huangguocg.com';
seoConfig={seoInfo:{webSiteName:'91剧场'},config:{foreverUrlAddr:origin}};
listPayload={list:[{...fixture,id:7},{...fixture,id:8}],totalCount:72,name:'当前接口栏目'};
detailSeo={onlineAt:'2025-10-18 10:30:00',mediaUpdatedAt:'2025-10-19 09:00:00',title:'剧名 </script><script>alert(1)</script>',desc:'真实剧集简介'};
@@ -541,7 +541,7 @@ test('SSR JSON-LD matches each canonical route, list page, content entity and vi
const items=graph.find(node=>node['@type']==='ItemList').itemListElement;
assert.deepEqual(items.map(item=>[item.position,item.url]),[[25,origin+'/detail/7/'],[26,origin+'/detail/8/']]);
}
if(path==='/info/help-center/')assert.equal(graph.find(node=>node['@type']==='FAQPage').mainEntity.length,6);
if(path==='/info/help-center/')assert.equal(graph.find(node=>node['@type']==='FAQPage').mainEntity.length,5);
}
for(const path of ['/detail/23456/','/play/23456/90001/']){
html=await (await fetch(base+path)).text();graph=graphOf(html);
@@ -563,10 +563,9 @@ test('paginated routes serve slash URLs directly and redirect bare paths with th
try{
listPayload=accountListPayload={list:[fixture],totalCount:48};
searchPayload={list:[fixture],totalCount:48,pageSize:24};
commentPayload=transactionsPayload={list:[],totalCount:18};
homePayload={...savedHome,homeConfigData:[{id:31,status:true,name:'后台配置标题',type:1,list:Array.from({length:48},(_,i)=>({...fixture,id:fixture.id+i}))}]};
const keyword=encodeURIComponent('都市 / page/7/?#%');
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+keyword+'/', '/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/info/deposit/','/info/deposit/vip/','/detail/23456/','/play/23456/90001/'];
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+keyword+'/'];
for(const basePath of bases){
for(const suffix of ['page/2/','page/2']){
const path=basePath+suffix+'?sort=2';
@@ -584,7 +583,7 @@ test('paginated routes serve slash URLs directly and redirect bare paths with th
for(const path of ['/shorts/page/0/','/shorts/page/01/','/shorts/page/2/page/3/','/apps/page/2/','/home-road/page/2/','/info/about-us/page/2/']){
const response=await fetch(base+path,{redirect:'manual'});assert.equal(response.status,404,path);assert.equal(response.headers.get('location'),null,path);
}
}finally{homePayload=savedHome;listPayload=accountListPayload=searchPayload=commentPayload=transactionsPayload=undefined;}
}finally{homePayload=savedHome;listPayload=searchPayload=undefined;}
});
test('guest session token stays in HttpOnly cookie and device header is forwarded',async()=>{
@@ -596,7 +595,7 @@ test('guest session token stays in HttpOnly cookie and device header is forwarde
assert.ok(calls.findLast(call=>call.path.endsWith('/auth/guest')).device.includes('DevID='));
});
test('browser BFF stays separate from Go app routes and preserves sessions, queries and logout',async()=>{
test('browser BFF stays separate from Go app routes, preserves sessions and queries, and rejects removed account routes',async()=>{
let start=calls.length;
const guest=await fetch(base+'/api/auth/guest',{method:'POST',headers:{Origin:base,'Content-Type':'application/json'},body:JSON.stringify({devID:'bff-device'})});
assert.equal(guest.status,200);assert.equal((await guest.json()).data.token,undefined);
@@ -610,8 +609,7 @@ test('browser BFF stays separate from Go app routes and preserves sessions, quer
assert.equal(calls.at(-1).path,'/api/app/playlet/search');assert.equal(new URLSearchParams(calls.at(-1).search).get('pageNum'),'2');
start=calls.length;
assert.equal((await fetch(base+'/api/not-allowed')).status,404);
const logout=await fetch(base+'/api/auth/logout',{method:'POST',headers:{Origin:base,'Content-Type':'application/json'},body:'{}'});
assert.equal(logout.status,200);assert.ok(logout.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
for(const path of ['auth/logout','auth/login','auth/register','coin/buy','playlet/chapter/unlock'])assert.equal((await fetch(base+'/api/'+path,{method:'POST',headers:{Origin:base,'Content-Type':'application/json',Cookie:cookie},body:'{}'})).status,404,path);
assert.equal(calls.length,start);
});
@@ -660,50 +658,6 @@ test('public content ignores expired credentials; double-encoded data is normali
assert.equal(payload.data.list[0].id,23456);
});
test('registered session expiry never retries a write as a guest',async()=>{
const guestCount=calls.filter(call=>call.path.endsWith('/auth/guest')).length;
const response=await fetch(base+'/api/app/playlet/collect/set',{method:'POST',headers:{Cookie:'drama_session=expired; drama_registered=1','Content-Type':'application/json'},body:JSON.stringify({id:23456,isCollect:true})});
assert.equal(response.status,401);assert.equal((await response.json()).code,1000);
assert.equal(calls.filter(call=>call.path.endsWith('/auth/guest')).length,guestCount);
assert.equal(response.headers.get('X-Auth-State'),'login-required');
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
});
test('login sends device/channel and trimmed credentials without attaching the previous identity',async()=>{
const response=await fetch(base+'/api/app/auth/login',{method:'POST',headers:{'Content-Type':'application/json','X-Real-IP':'203.0.113.9',Cookie:'drama_session=old-guest; drama_device=device-stable; drama_channel=first'},body:JSON.stringify({account:' player01 ',password:' Secure123 ',devID:'different',channel:'later'})});
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,88);assert.equal(data.data.token,undefined);
const call=calls.at(-1);assert.equal(call.auth,undefined);assert.deepEqual(JSON.parse(call.body),{account:'player01',password:' Secure123 ',devID:'device-stable',channel:'first'});
assert.ok(call.device.includes('DevID=device-stable;'));
assert.equal(call.ip,'203.0.113.9');
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_registered=1;')));
});
test('binding retains the guest Authorization, device, channel and invite and fills the optional email',async()=>{
const response=await fetch(base+'/api/app/auth/register',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=old-guest; drama_registered=0; drama_device=device-stable; drama_channel=first'},body:JSON.stringify({accountNo:' player01 ',password:'Secure123',agentInviteCode:' INV123 ',channel:'later'})});
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,77);assert.equal(data.data.token,undefined);
const call=calls.at(-1);assert.equal(call.auth,'old-guest');
assert.deepEqual(JSON.parse(call.body),{accountNo:'player01',password:'Secure123',email:'player01@91dj.local',devID:'device-stable',channel:'first',agentInviteCode:'INV123'});
const memberResponse=await fetch(base+'/api/app/auth/register',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=registered; drama_registered=1'},body:JSON.stringify({accountNo:'player02',password:'Secure123',email:'me@example.com'})});
assert.equal(memberResponse.status,200);assert.equal(calls.at(-1).auth,undefined);assert.equal(JSON.parse(calls.at(-1).body).email,'me@example.com');
});
test('refresh restores an existing account and keeps its token when getInfo omits it',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-registered-secret; drama_registered=1'},body:'{"automatic":true}'});
assert.equal(response.status,200);const data=await response.json();assert.equal(data.data.userId,88);assert.equal(data.data.isRegister,true);assert.equal(data.data.token,undefined);
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo']);
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=test-registered-secret;')));
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_registered=1;')));
});
test('an expired registered account is cleared during initialization without becoming a guest',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=1'},body:'{"automatic":true}'});
assert.equal(response.status,401);assert.equal(response.headers.get('X-Auth-State'),'login-required');
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo']);
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
});
test('expired guest requests recover once with the same device, including an explicitly rejected write',async()=>{
for(const [path,method,body] of [['auth/getInfo','GET',undefined],['playlet/collect/set','POST',JSON.stringify({id:23456,isCollect:true})]]){
const count=calls.length;
@@ -724,40 +678,14 @@ test('an HTTP 401 follows the same guest recovery path',async()=>{
assert.deepEqual(calls.slice(count).map(call=>call.path),['/api/app/auth/getInfo','/api/app/auth/guest','/api/app/auth/getInfo']);
});
test('SDK snapshots reach order APIs and the same drama context survives the guest retry',async()=>{
const common={app_id:'1047',uid:'',channel:'organic',sid:'sdk-session',client_ts:Math.floor(Date.now()/1000),device:'PC',device_id:'sdk-device',device_brand:'',device_model:'',device_fingerprint:'raw-sdk-fingerprint',fp_version:'3.0.1',user_agent:'test UA',sdk_version:'1.2.0',app_version:'1.0.0'};
const sdk=Buffer.from(JSON.stringify(common)).toString('base64');
const requestId='1234567890abcdef1234567890abcdef',context={v:1,request_id:requestId,event_name:'drama_collect',common,drama:{media_id:'',drama_id:'23456',episode:1,video_id:'90001',recommend_trace_id:''}};
test('client tracking headers are never forwarded, including across the guest retry',async()=>{
const sdk=Buffer.from(JSON.stringify({app_id:'1047',device_id:'sdk-device'})).toString('base64'),requestId='1234567890abcdef1234567890abcdef';
const count=calls.length;
const response=await fetch(base+'/api/app/playlet/collect/set',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=0; drama_device=business-device','X-SDK-Common':sdk,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_collect','X-DC-Request-Context':Buffer.from(JSON.stringify(context)).toString('base64url')},body:'{"id":23456,"isCollect":true}'});
const response=await fetch(base+'/api/app/playlet/collect/set',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=expired; drama_registered=0; drama_device=business-device','X-SDK-Common':sdk,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_collect','X-DC-Request-Context':'e30'},body:'{"playletId":23456,"isCollect":1}'});
assert.equal(response.status,200);
const requests=calls.slice(count);assert.equal(requests.length,3);
for(const call of [requests[0],requests[2]]){
assert.equal(call.trackerId,requestId);assert.equal(call.trackerEvent,'drama_collect');assert.deepEqual(JSON.parse(Buffer.from(call.trackerContext,'base64url')),context);assert.equal(call.sdk,sdk);
}
assert.equal(requests[1].trackerContext,undefined);assert.equal(requests[1].sdk,sdk);assert.equal(JSON.parse(requests[1].body).devID,'business-device');
await fetch(base+'/api/app/coin/buy',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-session-secret','X-SDK-Common':sdk},body:'{"packageId":1,"payType":"alipay"}'});
assert.equal(calls.at(-1).sdk,sdk);assert.equal(calls.at(-1).trackerContext,undefined);
});
test('bad credentials and malformed successful login responses never overwrite an existing account cookie',async()=>{
for(const [account,status] of [['wrong',200],['missing-token',502]]){
const response=await fetch(base+'/api/app/auth/login',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=valid-account; drama_registered=1'},body:JSON.stringify({account,password:'Secure123'})});
assert.equal(response.status,status);assert.notEqual((await response.json()).code,200);
assert.ok(!response.headers.getSetCookie().some(value=>value.startsWith('drama_session=')||value.startsWith('drama_registered=')));
}
});
test('manual logout skips the following automatic guest initialization and explicit sign-in can resume',async()=>{
const logout=await fetch(base+'/api/app/auth/logout',{method:'POST',body:'{}'});
assert.ok(logout.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=1;')));
const count=calls.length;
const skipped=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_skip_auto=1'},body:'{"automatic":true}'});
assert.equal(skipped.status,200);assert.equal((await skipped.json()).data,null);assert.equal(calls.length,count);
assert.ok(skipped.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=;')&&value.includes('Max-Age=0')));
const explicit=await fetch(base+'/api/app/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_skip_auto=1; drama_device=device-stable'},body:'{}'});
assert.equal(explicit.status,200);assert.equal((await explicit.json()).data.userId,77);
assert.ok(explicit.headers.getSetCookie().some(value=>value.startsWith('drama_skip_auto=;')&&value.includes('Max-Age=0')));
for(const call of requests){assert.equal(call.sdk,undefined);assert.equal(call.trackerId,undefined);assert.equal(call.trackerEvent,undefined);assert.equal(call.trackerContext,undefined);}
assert.equal(JSON.parse(requests[1].body).devID,'business-device');
});
test('proxy rejects unlisted endpoints and wrong methods before calling upstream',async()=>{
@@ -772,15 +700,6 @@ test('chapter permissions and identifiers are preserved from the API',async()=>{
const payload=await response.json();assert.equal(payload.data.id,90001);assert.equal(payload.data.canWatch,false);assert.equal(payload.data.singleCoins,13);
});
test('confirmed chapter unlock forwards the exact episode and identity and returns the upstream balance',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/playlet/chapter/unlock',{method:'POST',headers:{'Content-Type':'application/json',Cookie:'drama_session=test-session-secret; drama_device=unlock-test-device'},body:JSON.stringify({playletId:23456,chapterId:90001})});
assert.equal(response.status,200);const payload=await response.json();assert.equal(payload.code,200);assert.equal(payload.data.balance,77);
assert.equal(calls.length,count+1);const call=calls.at(-1);
assert.equal(call.path,'/api/app/playlet/chapter/unlock');assert.equal(call.auth,'test-session-secret');
assert.deepEqual(JSON.parse(call.body),{playletId:23456,chapterId:90001});assert.ok(call.device.includes('DevID=unlock-test-device;'));
});
test('media proxy preserves signed URLs and HLS content while isolating application credentials',async()=>{
const query='?token=a%2Fb+1&sig=x%252Fy';
const response=await fetch(base+'/h5/m3u8/folder/movie.m3u8'+query,{redirect:'manual',headers:{Authorization:'private',Cookie:'drama_session=private','X-Forwarded-For':'203.0.113.10, 10.0.0.1'}});
@@ -826,14 +745,6 @@ test('media byte ranges and upstream failure states remain usable by the player'
assert.equal((await fetch(base+'/h5/m3u8/missing.m3u8')).status,404);
});
test('logout clears cookies without mutating upstream content',async()=>{
const count=calls.length;
const response=await fetch(base+'/api/app/auth/logout',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
assert.equal(response.status,200);assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=;')&&value.includes('Max-Age=0')));
assert.equal(calls.length,count);
});
function currentMain(html,id){
const match=html.match(new RegExp('<main\\b[^>]*\\bid="'+id+'"[^>]*>[\\s\\S]*?<\\/main>'));
assert.ok(match,'main '+id+' must exist');assert.ok(!/^<main[^>]*\shidden(?:[=\s>])/.test(match[0]),id+' must be visible');
@@ -890,39 +801,36 @@ test('standalone lists use H2 card titles while cards within named sections rema
assert.ok(section);assert.equal((section.match(/<h2\b/g)||[]).length,1);
assert.equal((section.match(/<h3\b/g)||[]).length,1,'homepage cards are below their section heading');
}
accountListPayload={list:[fixture],totalCount:1};
const member=await (await fetch(base+'/member/center/')).text();
const shelf=member.match(/<section class="member-library"[^>]*>([\s\S]*?)<\/section>/)?.[1];
assert.ok(shelf);assert.match(shelf,/<h2 id="member-library-title"/);assert.match(shelf,/<h3>/);
const detail=currentMain(await (await fetch(base+'/detail/23456/')).text(),'drama-play-page');
assert.match(detail,/<aside class="play-related"><h2>热门短剧<\/h2>[\s\S]*?<h3>同类推荐短剧<\/h3>/);
assert.equal((detail.match(/<h1\b/g)||[]).length,1);
assert.match(detail,/<h2 id="hg-recommend-title">推荐视频<\/h2>[\s\S]*?<h3 class="hg-vitem-title"><a href="\/detail\/23458\/" title="《同类推荐短剧》在线观看">同类推荐短剧<\/a><\/h3>/);
const search=currentMain(await (await fetch(base+'/search/关键词/')).text(),'search-results-page');
assert.match(search,/<h1\b/);assert.match(search,/<h2>/);assert.ok(!/<h3\b/.test(search));
}finally{homePayload=savedHome;listPayload=accountListPayload=undefined;}
assert.match(search,/<h1\b/);assert.match(search,/<h2 class="hg-card-title">/);assert.ok(!/<h3\b/.test(search));
}finally{homePayload=savedHome;listPayload=undefined;}
});
test('detail, playback, video and comment pages contain real initial content without exposing private payloads or denied video sources',async()=>{
test('detail and playback pages contain real initial content and recommendations without exposing private payloads, and play every supplied source',async()=>{
try{
detailSeo={desc:'服务端剧集完整简介',token:'private-detail-token',reportSdkConfig:{secret:'private-sdk-secret'}};
chapterListPayload={list:[{id:90001,currentEpisode:1,canWatch:true,videoUrl:'https://cdn.example/free.mp4'},{id:90002,currentEpisode:2,canWatch:false,needUnlock:true,payType:1,singleCoins:13,videoUrl:'https://cdn.example/denied-video-secret.mp4'}]};
chapterPayload={...chapterListPayload.list[1],playletId:23456};
commentPayload={list:[{id:45,text:'服务端第2页评论',userName:'评论用户',token:'private-comment-token',children:[{id:46,text:'嵌套回复'}]}],totalCount:25};
videoPayload={...fixture,id:23457,title:'服务端独立视频',video:{url:'https://cdn.example/public-video.mp4',cover:fixture.cover},desc:'视频真实简介'};
for(const path of ['/detail/23456/page/2/?commentSort=1','/play/23456/90002/page/2/']){
for(const path of ['/detail/23456/','/play/23456/90002/']){
const start=calls.length,response=await fetch(base+path);assert.equal(response.status,200);
const html=await response.text(),main=currentMain(html,'drama-play-page');
assert.ok(main.includes('服务端第2页评论'));assert.ok(main.includes('嵌套回复'));assert.ok(main.includes('评论 (25)'));
assert.ok(main.includes('/page/3/'));assert.ok(!html.includes('后台配置标题'));
for(const secret of ['private-detail-token','private-comment-token','private-sdk-secret','denied-video-secret.mp4'])assert.ok(!html.includes(secret),secret);
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/comment/list').length,1,'metadata/body must share comments');
assert.ok(main.includes('<h2 id="hg-recommend-title">推荐视频</h2>'));assert.ok(!main.includes('play-comment'));assert.ok(main.includes('剧情简介</h2>'));assert.ok(main.includes('全集列表</h2>'));
assert.ok(!html.includes('后台配置标题'));
for(const secret of ['private-detail-token','private-sdk-secret'])assert.ok(!html.includes(secret),secret);
if(path.startsWith('/play/'))assert.ok(html.includes('denied-video-secret.mp4'),'a supplied source is free to watch');
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/comment/list').length,0,'comments are no longer requested');
if(path.startsWith('/video/')){assert.ok(main.includes('服务端独立视频'));assert.ok(main.includes('<video '));}
else{
assert.ok(main.includes('服务端剧集完整简介'));assert.ok(main.includes('href="/play/23456/90001/"'));assert.ok(main.includes('data-locked-chapter="90002"'));
assert.ok(main.includes('服务端剧集完整简介'));assert.ok(main.includes('href="/play/23456/90001/"'));assert.ok(main.includes('href="/play/23456/90002/"'));assert.ok(!main.includes('play-episode-locked'));
if(path.startsWith('/detail/'))assert.ok(main.includes('is-overview'));else assert.ok(main.includes('<video '));
}
assertPageLinksHaveTrailingSlash(main);
}
}finally{detailSeo=undefined;chapterPayload=undefined;chapterListPayload=undefined;commentPayload=undefined;videoPayload=undefined;}
}finally{detailSeo=undefined;chapterPayload=undefined;chapterListPayload=undefined;videoPayload=undefined;}
});
test('search paths decode keyword segments once and include keyword/result cards before JavaScript runs',async()=>{
@@ -967,7 +875,7 @@ test('Chinese content slugs generate ID-based links in rendered pages and struct
chapterPayload={id:90001,playletId:23456,currentEpisode:1,canWatch:true,videoUrl:'https://media.drama.test/1.mp4'};
const detail=await (await fetch(base+'/play/23456/90001/')).text();
assert.ok(detail.includes('href="/tag/9/"'));assert.ok(detail.includes('<span>无标识标签</span>'));assert.ok(!detail.includes('href="/tag//"'));
assert.ok(detail.includes('rel="canonical" href="https://default.drama.test/play/23456/90001/"'));
assert.ok(detail.includes('rel="canonical" href="https://huangguocg.com/play/23456/90001/"'));
for(const html of [home,detail]){
for(const [,href] of html.matchAll(/<a\b[^>]*href="(\/[^"#?]*)/g))if(!href.startsWith('/search/'))assert.ok(!/[^\x00-\x7f]/.test(decodeURIComponent(href)),href);
}
@@ -1030,11 +938,9 @@ test('known pagination limits return 404 while existing empty first pages remain
const overflow=await fetch(base+path+'page/2/',{redirect:'manual'});assert.equal(overflow.status,404,path);await overflow.text();
}
listPayload={list:[fixture],totalCount:48};
commentPayload=transactionsPayload={list:[],totalCount:20};
accountListPayload={list:[],totalCount:48};
searchPayload={list:[fixture],totalCount:48,pageSize:24};
for(const path of ['/shorts/','/rank/hot/','/rank/new/','/rank/follow/','/topics/','/topics/7/','/theme/8/','/tag/9/',
'/detail/23456/','/play/23456/90001/','/search/关键词/','/member/history/','/member/favorites/','/member/purchased/','/info/deposit/','/info/deposit/vip/']){
'/search/关键词/']){
for(const [page,status] of [[2,200],[3,404]]){
const response=await fetch(base+path+'page/'+page+'/',{redirect:'manual'}),html=await response.text();
assert.equal(response.status,status,path+' page '+page);assert.equal(response.headers.get('location'),null);
@@ -1044,7 +950,7 @@ test('known pagination limits return 404 while existing empty first pages remain
const config=await fetch(base+'/config/31/page/2/');assert.equal(config.status,404);await config.text();
searchPayload={list:[],totalCount:0};
const emptySearch=await fetch(base+'/search/没有匹配项/');assert.equal(emptySearch.status,200);assert.ok((await emptySearch.text()).includes('搜索无结果'));
}finally{listPayload=commentPayload=transactionsPayload=accountListPayload=searchPayload=undefined;}
}finally{listPayload=searchPayload=undefined;}
});
test('topic aliases and leading-zero IDs serve content directly with a single primary canonical',async()=>{
@@ -1053,7 +959,7 @@ test('topic aliases and leading-zero IDs serve content directly with a single pr
for(const [path,target] of [['/theme/007/','/topics/7/'],['/theme/007/page/2/','/topics/7/page/2/'],['/topics/007/','/topics/7/'],['/detail/023456/','/detail/23456/']]){
const start=calls.length,response=await fetch(base+path+'?utm_source=campaign',{redirect:'manual'}),html=await response.text();
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null);
assert.ok(html.includes(`rel="canonical" href="https://default.drama.test${target}"`),path);
assert.ok(html.includes(`rel="canonical" href="https://huangguocg.com${target}"`),path);
assert.match(html,/<meta name="robots" content="index,\s*follow/);
assert.ok(html.includes(path.startsWith('/detail/')?'play-detail-title':'同一专题'));
if(path.startsWith('/theme/'))assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/playlet/topic/detail').length,1);
@@ -1061,25 +967,20 @@ test('topic aliases and leading-zero IDs serve content directly with a single pr
}finally{listPayload=undefined;}
});
test('comment pages stay usable but noindex, while ordinary catalog pagination remains indexable',async()=>{
test('detail and play pages have no comment pagination, while ordinary catalog pagination remains indexable',async()=>{
try{
commentPayload={list:[{id:321,text:'第二页的真实评论'}],totalCount:25};
chapterPayload={id:90001,playletId:23456,currentEpisode:2,canWatch:true,videoUrl:'https://media.drama.test/2.mp4'};
for(const path of ['/detail/23456/page/2/','/play/23456/90001/page/2/']){
const response=await fetch(base+path,{redirect:'manual',headers:{'User-Agent':'YandexBot'}}),html=await response.text();
assert.equal(response.status,200);assert.equal(response.headers.get('location'),null);
assert.match(html,/<meta name="robots" content="noindex,\s*follow"/);assert.ok(!html.includes('rel="canonical"'));
assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
assert.ok(html.includes('第二页的真实评论'));assert.ok(html.includes('href="'+path.replace('/page/2/','/page/3/')+'"'));
const response=await fetch(base+path,{redirect:'manual'});assert.equal(response.status,404,path);await response.text();
}
listPayload={list:[fixture],totalCount:72};
const html=await (await fetch(base+'/shorts/page/2/')).text();
assert.match(html,/<meta name="robots" content="index,\s*follow/);
assert.ok(html.includes('rel="canonical" href="https://default.drama.test/shorts/page/2/"'));
}finally{commentPayload=chapterPayload=listPayload=undefined;}
assert.ok(html.includes('rel="canonical" href="https://huangguocg.com/shorts/page/2/"'));
}finally{chapterPayload=listPayload=undefined;}
});
test('SSR applies more-list content rules and real episode access rules without exposing locked media',async()=>{
test('SSR applies more-list content rules and treats every supplied episode source as free',async()=>{
const savedHome=homePayload;
try{
const block={id:31,status:true,name:'都市精选',type:1,list:[fixture]};
@@ -1093,17 +994,15 @@ test('SSR applies more-list content rules and real episode access rules without
detailSeo={desc:'剧集共用简介',onlineAt:'2025-10-18 10:30:00'};
const chapter={id:90001,playletId:23456,currentEpisode:3,payType:1,videoUrl:'https://media.drama.test/paid-episode.m3u8'};
for(const allowed of [false,true]){
// Payment flags are ignored: a supplied source is always free to watch and indexable.
chapterPayload={...chapter,canWatch:allowed,needUnlock:!allowed};
const response=await fetch(base+'/play/23456/90001/'),html=await response.text();assert.equal(response.status,200);
assert.equal(/<meta name="robots" content="index,/.test(html),allowed);
assert.equal(html.includes('rel="canonical"'),allowed);assert.equal(html.includes(chapter.videoUrl),allowed);
assert.ok(html.includes('class="play-screen play-paywalled-content"'));
if(allowed){
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
const page=graph.find(node=>node['@type']==='WebPage');
assert.equal(page.isAccessibleForFree,false);assert.equal(page.hasPart.cssSelector,'.play-paywalled-content');
assert.equal(graph.find(node=>node['@type']==='VideoObject').isAccessibleForFree,false);
}else assert.ok(!/<script\b[^>]*data-site-json-ld=/.test(html));
assert.match(html,/<meta name="robots" content="index,/);
assert.ok(html.includes('rel="canonical"'));assert.ok(html.includes(chapter.videoUrl));
assert.ok(html.includes('class="play-screen"'));assert.ok(!html.includes('play-paywalled-content'));
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
assert.equal(graph.find(node=>node['@type']==='WebPage').isAccessibleForFree,undefined);
assert.equal(graph.find(node=>node['@type']==='VideoObject').isAccessibleForFree,true);
}
chapterPayload={...chapter,canWatch:true,needUnlock:false,payType:0,videoUrl:''};
const html=await (await fetch(base+'/play/23456/90001/')).text();
@@ -1156,19 +1055,15 @@ test('HEAD requests report the same missing-resource and temporary-error statuse
}finally{responseOverrides={};}
});
test('optional data failures preserve real detail content and do not manufacture pagination 404s',async()=>{
test('optional data failures preserve real detail content',async()=>{
try{
responseOverrides={
'comment/list':{status:503},'playlet/chapter/list':{status:503},'playlet/guess-like/list':{status:503},
'playlet/chapter/list':{status:503},'playlet/guess-like/list':{status:503},
};
for(const path of ['/detail/23456/','/play/23456/90001/']){
const response=await fetch(base+path);assert.equal(response.status,200,path);
const html=await response.text();assert.ok(html.includes('接口剧名'));assert.ok(html.includes('评论暂不可用'));
const commentsPage=await fetch(base+path+'page/2/');assert.equal(commentsPage.status,500,'unknown comments count must not become 404');await commentsPage.text();
const html=await response.text();assert.ok(html.includes('接口剧名'));assert.ok(html.includes('暂无推荐视频'));
}
responseOverrides={'coin/trans/list':{status:503}};
const wallet=await fetch(base+'/info/deposit/');assert.equal(wallet.status,200);await wallet.text();
const page=await fetch(base+'/info/deposit/page/2/');assert.equal(page.status,500);await page.text();
}finally{responseOverrides={};}
});
@@ -1240,10 +1135,10 @@ test('navigation failure leaves page content available and never restores a hard
});
test('applications, return-home, feedback and contacts arrive as complete HTML without a loading shell',async()=>{
test('applications, return-home and contacts arrive as complete HTML without a loading shell',async()=>{
try{
appsPayload={categoryList:[{id:80,title:'接口应用分类',appList:[{id:81,name:'接口应用',url:'https://apps.example/download',logo:'https://apps.example/logo.webp'}]}]};
for(const [path,panel,marker] of [['/apps/','channel-page','接口应用分类'],['/home-road/','channel-page','return-home-directory'],['/info/feedback/','info-page','id="feedback-form"'],['/info/about-us/','info-page','https://example.com/contact']]){
for(const [path,panel,marker] of [['/apps/','channel-page','接口应用分类'],['/home-road/','channel-page','return-home-directory'],['/info/about-us/','info-page','官方TG:<a href="https://t.me/jbd125"']]){
const start=calls.length,response=await fetch(base+path);assert.equal(response.status,200,path);
const html=await response.text(),main=currentMain(html,panel);assert.ok(main.includes(marker),path);assert.ok(!/api-loading|正在加载|加载中/.test(main),path);
if(path==='/apps/')assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/applist/list').length,1);
@@ -1251,94 +1146,6 @@ test('applications, return-home, feedback and contacts arrive as complete HTML w
}finally{appsPayload=undefined;}
});
test('fresh private pages render real guest records with the same persisted identity and no credentials in HTML',async()=>{
try{
accountListPayload={list:[{...fixture,title:'游客自己的历史',password:'private-list-password'}],totalCount:72};
const start=calls.length,response=await fetch(base+'/member/history/page/2/'),html=await response.text(),main=currentMain(html,'info-page');
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/no-store/);
assert.ok(main.includes('游客自己的历史'));assert.ok(main.includes('真实接口用户'));assert.ok(main.includes('href="/member/history/page/3/"'));
assert.ok(!/api-loading|正在加载|加载中/.test(main));
for(const secret of ['test-session-secret','private-list-password'])assert.ok(!html.includes(secret));
assert.ok(response.headers.getSetCookie().some(value=>value.startsWith('drama_session=')&&/HttpOnly/.test(value)));
const reads=calls.slice(start);assert.equal(reads.filter(call=>call.path==='/api/app/auth/guest').length,1);assert.equal(reads.filter(call=>call.path==='/api/app/auth/getInfo').length,0);
assert.equal(reads.find(call=>call.path==='/api/app/playlet/history/list').auth,'test-session-secret');
}finally{accountListPayload=undefined;}
});
test('likes pages render the current account list, native links and pagination before JavaScript without shared caching',async()=>{
try{
strictContract=true;
accountListPayload=(path,request)=>({list:[{...fixture,slug:'liked-story',title:request.headers.authorization==='registered-user-a-secret'?'甲的点赞短剧':'乙的点赞短剧',token:'private-liked-token'}],totalCount:72});
for(const [token,title,other] of [['registered-user-a-secret','甲的点赞短剧','乙的点赞短剧'],['registered-user-b-secret','乙的点赞短剧','甲的点赞短剧']]){
const start=calls.length;
const response=await fetch(base+'/member/likes/page/2/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1'}}),html=await response.text(),main=currentMain(html,'info-page');
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/private.*no-store/);
assert.match(html,/<meta name="robots" content="noindex,\s*nofollow"/);
assert.match(main,/<h1\b[^>]*>我的点赞<\/h1>/);assert.ok(main.includes(title));assert.ok(!main.includes(other));
assert.ok(main.includes('href="/member/likes/" aria-current="page"'));assert.ok(main.includes('href="/member/likes/page/3/"'));
assert.ok(main.includes('href="/detail/liked-story/"'));assert.ok(main.includes('共 72 部短剧'));
assert.ok(!/api-loading|正在加载|加载中/.test(main));
for(const secret of [token,'private-liked-token'])assert.ok(!html.includes(secret));
const reads=calls.slice(start).filter(call=>call.path==='/api/app/playlet/like/list');
assert.equal(reads.length,1);assert.equal(reads[0].auth,token);assert.equal(reads[0].cookie,undefined);
assert.equal(new URLSearchParams(reads[0].search).get('pageNum'),'2');assert.equal(new URLSearchParams(reads[0].search).get('pageSize'),'24');
}
const response=await fetch(base+'/api/playlet/like/list?pageNum=3&pageSize=24',{headers:{Cookie:'drama_session=registered-user-a-secret; drama_registered=1'}});
assert.equal(response.status,200);assert.match(response.headers.get('cache-control'),/no-store/);
assert.equal((await response.json()).code,200);assert.equal(calls.at(-1).auth,'registered-user-a-secret');
}finally{accountListPayload=undefined;strictContract=false;}
});
test('likes distinguish empty records, out-of-range pages, backend failures and expired accounts',async()=>{
const headers={Cookie:'drama_session=registered-user-a-secret; drama_registered=1'};
try{
accountListPayload={list:[],totalCount:0};
const emptyResponse=await fetch(base+'/member/likes/',{headers}),emptyHtml=await emptyResponse.text();
assert.equal(emptyResponse.status,200);assert.ok(emptyHtml.includes('暂无点赞的短剧'));assert.ok(emptyHtml.includes('href="/shorts/">去发现好剧</a>'));
assert.equal((await fetch(base+'/member/likes/page/2/',{headers})).status,404);
responseOverrides={'playlet/like/list':{status:503,code:503}};
const failed=await fetch(base+'/member/likes/',{headers}),failedHtml=await failed.text();
assert.equal(failed.status,500);assert.match(failedHtml,/<meta name="robots" content="noindex/);assert.ok(!failedHtml.includes('暂无点赞的短剧'));
responseOverrides={};
const start=calls.length;
const expiredResponse=await fetch(base+'/member/likes/',{headers:{Cookie:'drama_session=expired; drama_registered=1'}}),expiredHtml=await expiredResponse.text();
assert.ok(expiredHtml.includes('请先登录账号'));assert.ok(expiredHtml.includes('data-auth-mode="login"'));
assert.ok(!calls.slice(start).some(call=>['/api/app/auth/guest','/api/app/playlet/like/list'].includes(call.path)));
}finally{accountListPayload=undefined;responseOverrides={};}
});
test('private settings isolate two users and ignore forged SSR identity headers',async()=>{
const start=calls.length;
for(const [token,name,other] of [['registered-user-a-secret','用户甲','用户乙'],['registered-user-b-secret','用户乙','用户甲']]){
const forged=Buffer.from(JSON.stringify({userId:999,nickName:'伪造管理员'})).toString('base64url');
const response=await fetch(base+'/member/settings/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1','x-drama-ssr-session':'ready','x-drama-ssr-user':forged}});
const html=await response.text(),main=currentMain(html,'info-page');assert.ok(main.includes(name));assert.ok(!html.includes(other));assert.ok(!html.includes('伪造管理员'));
for(const secret of [token,'must-not-leak'])assert.ok(!html.includes(secret));assert.ok(!main.includes('api-loading'));
}
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/auth/getInfo').length,2);assert.ok(!calls.slice(start).some(call=>call.path==='/api/app/auth/guest'));
});
test('registered expiry and explicit logout render sign-in immediately without guest downgrade',async()=>{
for(const cookie of ['drama_session=expired; drama_registered=1','drama_skip_auto=1']){
const start=calls.length,response=await fetch(base+'/member/settings/',{headers:{Cookie:cookie}}),html=await response.text(),main=currentMain(html,'info-page');
assert.ok(main.includes('请先登录账号'));assert.ok(main.includes('data-auth-mode="login"'));assert.ok(!main.includes('id="profile-form"'));assert.ok(!main.includes('api-loading'));
assert.ok(!calls.slice(start).some(call=>call.path==='/api/app/auth/guest'));
}
});
test('wallet SSR contains configured packages and transactions without creating an order or leaking extra fields',async()=>{
try{
packagesPayload={coinList:[{id:91,name:'真实金币套餐',price:1000,baseCoin:100,giftCoin:5,totalCoin:105,packageType:2,payMethods:[{type:'alipay',typeName:'支付宝'}],token:'private-package-token'}],vipList:[]};
transactionsPayload={list:[{title:'真实交易明细',changeAmount:25,createdAt:'2026-10-21 10:30:00',token:'private-transaction-token'}],totalCount:18};
const start=calls.length,response=await fetch(base+'/info/deposit/'),html=await response.text(),main=currentMain(html,'info-page');
assert.ok(main.includes('100金币'));assert.ok(main.includes('真实交易明细'));assert.ok(main.includes('href="/info/deposit/page/2/"'));assert.ok(!/api-loading|正在加载|加载中/.test(main));
for(const secret of ['private-package-token','private-transaction-token','test-session-secret'])assert.ok(!html.includes(secret));
const reads=calls.slice(start);assert.equal(reads.filter(call=>call.path==='/api/app/recharge/package/list').length,1);assert.equal(reads.filter(call=>call.path==='/api/app/coin/trans/list').length,1);
assert.ok(!reads.some(call=>['/api/app/coin/buy','/api/app/vip/pay','/api/app/playlet/chapter/unlock'].includes(call.path)));
}finally{packagesPayload=undefined;transactionsPayload=undefined;}
});
test('header and complete page content share the server-prepared identity before scripts run',async()=>{
const start=calls.length,response=await fetch(base+'/shorts/'),html=await response.text();
assert.ok(html.indexOf('id="global-site-header"')<html.indexOf('<main class="shell"'));
@@ -1352,34 +1159,32 @@ test('slug details and episodes render directly with numeric dependent reads, sl
strictContract=true;
detailSeo={slug:'city-story',title:'都市故事',desc:'作品独立介绍',tagNames:['都市'],tagIds:[9],tagSlugs:['urban'],onlineAt:'2026-10-20'};
chapterPayload={id:90001,playletId:23456,currentEpisode:3,canWatch:true,videoUrl:'https://media.drama.test/3.mp4'};
commentPayload={list:[],totalCount:25};
for(const [path,chapter] of [['/detail/city-story/',false],['/play/city-story/90001/',true]]){
const start=calls.length,response=await fetch(base+path,{redirect:'manual'}),html=await response.text();
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null);
const main=currentMain(html,'drama-play-page');assert.ok(main.includes('都市故事'));
assert.ok(main.includes('href="/play/city-story/90001/"'));
assert.ok(main.includes(`href="${path}page/2/"`));
assert.ok(html.includes(`rel="canonical" href="https://default.drama.test${path}"`));
assert.ok(html.includes(`rel="canonical" href="https://huangguocg.com${path}"`));
const reads=calls.slice(start),detail=reads.filter(call=>call.path==='/api/app/playlet/detail');assert.equal(detail.length,1);
assert.equal(new URLSearchParams(detail[0].search).get('slug'),'city-story');assert.equal(new URLSearchParams(detail[0].search).has('id'),false);
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/playlet/chapter/list').search).get('playletSlug'),'city-story');
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/playlet/guess-like/list').search).get('playletId'),'23456');
assert.equal(new URLSearchParams(reads.find(call=>call.path==='/api/app/comment/list').search).get('objectId'),chapter?'90001':'23456');
assert.ok(!reads.some(call=>call.path==='/api/app/comment/list'));
if(chapter){assert.ok(main.includes('href="/tag/urban/"'));assert.ok(main.includes('href="/detail/city-story/"'));}
const graph=JSON.parse(html.match(/<script\b[^>]*data-site-json-ld=""[^>]*>([\s\S]*?)<\/script>/)[1])['@graph'];
assert.equal(graph.find(node=>node['@type']==='TVSeries').url,'https://default.drama.test/detail/city-story/');
if(chapter)assert.equal(graph.find(node=>node['@type']==='TVEpisode').url,'https://default.drama.test/play/city-story/90001/');
assert.equal(graph.find(node=>node['@type']==='TVSeries').url,'https://huangguocg.com/detail/city-story/');
if(chapter)assert.equal(graph.find(node=>node['@type']==='TVEpisode').url,'https://huangguocg.com/play/city-story/90001/');
}
const legacy=await fetch(base+'/detail/23456/',{redirect:'manual'}),html=await legacy.text();
assert.equal(legacy.status,200);assert.equal(legacy.headers.get('location'),null);
assert.ok(html.includes('rel="canonical" href="https://default.drama.test/detail/city-story/"'));assert.ok(html.includes('href="/detail/city-story/page/2/"'));
assert.ok(html.includes('rel="canonical" href="https://huangguocg.com/detail/city-story/"'));
detailSeo={...detailSeo,slug:'都市 / 剧?#%2F'};
const path='/play/'+encodeURIComponent(detailSeo.slug)+'/90001/';
const special=await fetch(base+path);assert.equal(special.status,200);
const specialHtml=await special.text();
assert.ok(specialHtml.includes('rel="canonical" href="https://default.drama.test/play/23456/90001/"'));
assert.ok(specialHtml.includes('rel="canonical" href="https://huangguocg.com/play/23456/90001/"'));
assert.ok(specialHtml.includes('href="/play/23456/90001/"'));
}finally{strictContract=false;detailSeo=chapterPayload=commentPayload=undefined;}
}finally{strictContract=false;detailSeo=chapterPayload=undefined;}
});
test('home, topics, aliases, tags, more and search generate slug-first destinations and serve refreshed slug URLs',async()=>{
@@ -1395,7 +1200,7 @@ test('home, topics, aliases, tags, more and search generate slug-first destinati
assert.equal(response.status,200,path);assert.equal(response.headers.get('location'),null);
if(path==='/')for(const href of ['/detail/city-story/','/topics/urban-topic/','/config/editors-choice/'])assert.ok(html.includes(`href="${href}"`));
if(path.startsWith('/topics/urban')||path.startsWith('/theme/')){
assert.ok(html.includes(`rel="canonical" href="https://default.drama.test${path.replace('/theme/','/topics/')}"`));
assert.ok(html.includes(`rel="canonical" href="https://huangguocg.com${path.replace('/theme/','/topics/')}"`));
const query=new URLSearchParams(calls.slice(start).find(call=>call.path==='/api/app/playlet/topic/detail').search);
assert.equal(query.get('id'),'7');assert.equal(query.has('slug'),false);
}
@@ -1419,12 +1224,12 @@ test('missing slugs stay real 404 responses and topic directory outages stay ser
}finally{detailSeo=listPayload=undefined;responseOverrides={};}
});
test('detail, episode, chapter list, comments and recommendations are requested before detail finishes',async()=>{
test('detail, episode, chapter list and recommendations are requested before detail finishes',async()=>{
let release;blockedDetail={promise:new Promise(resolve=>release=resolve)};
const start=calls.length;
const pending=fetch(base+'/play/23456/90001/');
try{
const paths=['playlet/detail','playlet/chapter/detail','playlet/chapter/list','comment/list','playlet/guess-like/list'].map(path=>'/api/app/'+path);
const paths=['playlet/detail','playlet/chapter/detail','playlet/chapter/list','playlet/guess-like/list'].map(path=>'/api/app/'+path);
for(let attempt=0;attempt<100;attempt++){
if(paths.every(path=>calls.slice(start).some(call=>call.path===path)))break;
await delay(10);
@@ -1434,7 +1239,7 @@ test('detail, episode, chapter list, comments and recommendations are requested
const html=await (await pending).text();assert.ok(currentMain(html,'drama-play-page').includes('play-detail-title'));
});
test('public payloads are reused across page requests while two accounts and permissions stay uncached',async()=>{
test('public payloads are reused across page requests while permissions stay uncached',async()=>{
await stopNext();await startNext({PUBLIC_DATA_CACHE_TTL_MS:'60000'});
const previousHeader=headerPayload;
try{
@@ -1448,12 +1253,6 @@ test('public payloads are reused across page requests while two accounts and per
assert.equal(calls.slice(start).filter(call=>call.path==='/api/app/playlet/home').length,1);
headerPayload={categoryList:[]};
assert.ok(navigationHtml(await (await fetch(base+'/shorts/')).text(),'top').includes('短剧'),'public navigation remains valid within TTL');
const privateStart=calls.length;
for(const [token,name,other] of [['registered-user-a-secret','用户甲','用户乙'],['registered-user-b-secret','用户乙','用户甲']]){
const html=await (await fetch(base+'/member/settings/',{headers:{Cookie:'drama_session='+token+'; drama_registered=1'}})).text();
assert.ok(currentMain(html,'info-page').includes(name));assert.ok(!html.includes(other));
}
assert.equal(calls.slice(privateStart).filter(call=>call.path==='/api/app/auth/getInfo').length,2);
const permissionStart=calls.length;
await (await fetch(base+'/play/23456/90001/')).text();await (await fetch(base+'/play/23456/90001/')).text();
for(const path of ['playlet/detail','playlet/chapter/detail'])assert.equal(calls.slice(permissionStart).filter(call=>call.path==='/api/app/'+path).length,2,path+' must stay realtime');
@@ -1463,7 +1262,7 @@ test('public payloads are reused across page requests while two accounts and per
}
});
test('current-origin deployments render SSR and proxy account, API and signed media requests through the current site',async()=>{
test('current-origin deployments render SSR and proxy guest, API and signed media requests through the current site',async()=>{
await stopNext();await startNext({UPSTREAM_API_URL:'current'});
// Emulate Nginx: preserve the visitor Host and route only backend paths to Go.
const ingress=createServer((request,response)=>{
@@ -1485,8 +1284,8 @@ test('current-origin deployments render SSR and proxy account, API and signed me
const config=await fetch(origin+'/api/config/info');assert.equal(config.status,200);assert.equal((await config.json()).code,200);
const session=await fetch(origin+'/api/auth/guest',{method:'POST',headers:{'Content-Type':'application/json',Origin:origin},body:'{}'});
assert.equal(session.status,200);assert.match(session.headers.get('set-cookie'),/drama_session=/);assert.equal((await session.json()).code,200);
const privatePage=await fetch(origin+'/member/settings/',{headers:{Cookie:'drama_session=registered-user-a-secret; drama_registered=1'}});
assert.equal(privatePage.status,200);assert.ok(currentMain(await privatePage.text(),'info-page').includes('用户甲'));
const detailPage=await fetch(origin+'/detail/23456/');
assert.equal(detailPage.status,200);assert.ok((await detailPage.text()).includes('接口剧名'));
assert.equal(calls.at(-1).cookie,undefined);
const key=await fetch(origin+'/enkey?id=12700&ticket=a%2Fb+1');
assert.equal(key.status,200);assert.equal((await key.arrayBuffer()).byteLength,16);
+3 -3
View File
@@ -16,10 +16,10 @@ const {loadAppCategories,renderAppDirectory,scrollToAppCategory}=await import(ap
const app=id=>({id,name:'应用 '+id,url:'https://example.com/app/'+id,logo:'https://example.com/icon.png'});
const category=(id,appList)=>({id,title:'分类 '+id,...(appList===undefined?{}:{appList})});
test('benefit applications report linked advertisements with their API ids and ordinary applications as content',()=>{
test('benefit applications mark only linked advertisements and carry no tracking attributes',()=>{
const html=renderAppDirectory([category(3,[app(11),{...app(12),advitiseId:'ad-12',location:8}])]);
assert.match(html,/data-track-item data-item-id="11" data-item-name="应用 11" data-item-type="app" data-parent-item-id="3"/);
assert.match(html,/data-track-ad="external" data-ad-id="ad-12" data-ad-clickable="1" data-ad-slot-key="8" data-ad-slot-name="福利应用" data-ad-type="icon"/);
assert.equal(html.match(/data-ad /g)?.length,1);
assert.doesNotMatch(html,/data-track|data-item-|data-ad-/);
});
test('loads every missing category once, preserves directory order, and omits empty categories',async()=>{
+21 -114
View File
@@ -11,34 +11,17 @@ async function moduleUrl(file,replace=code=>code){
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
const {authFormPayload}=await import(authUrl);
let sequence=0;
async function client(){
const url=await moduleUrl('../src/lib/client/api.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./auth"',JSON.stringify(authUrl+'#'+sequence++)));
return new (await import(url)).ApiClient();
}
const guest={userId:7,type:0,isRegister:false};
const member={userId:8,type:1,isRegister:true};
const other={userId:8,type:0,isRegister:false};
const response=(data,code=200,headers={})=>new Response(JSON.stringify({code,data,msg:code===200?'ok':'session expired'}),{status:code===1000?401:200,headers:{'Content-Type':'application/json',...headers}});
const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return {promise,resolve};};
async function withFetch(fetcher,work){const old=globalThis.fetch;globalThis.fetch=fetcher;try{await work();}finally{globalThis.fetch=old;}}
test('login accepts account/email, trims identifiers, and preserves password bytes',()=>{
assert.deepEqual(authFormPayload('login',{account:' test ',password:' 1234 '}),{account:'test',password:' 1234 '});
assert.deepEqual(authFormPayload('login',{account:'me@example.com',password:'1234'}),{account:'me@example.com',password:'1234'});
assert.throws(()=>authFormPayload('login',{account:'bad@',password:'1234'}),/有效邮箱/);
assert.throws(()=>authFormPayload('login',{account:'test',password:'123'}),/密码/);
const account='abcdefghijklmnopqrst',password='SafePassword12345678';
assert.equal(authFormPayload('login',{account,password}).account,account);
});
test('guest binding validates confirmation and agreement without requiring an email',()=>{
const values={account:' player01 ',password:'Secure123',passwordConfirm:'Secure123',agree:'on'};
assert.deepEqual(authFormPayload('register',values),{accountNo:'player01',password:'Secure123'});
for(const change of [{account:'a_b'},{password:'abcdefgh',passwordConfirm:'abcdefgh'},{passwordConfirm:'Wrong123'},{agree:''}])assert.throws(()=>authFormPayload('register',{...values,...change}));
assert.deepEqual(authFormPayload('resetPassword',{account:' player01 ',email:' me@example.com ',password:'NewPass123',passwordConfirm:'NewPass123'}),{accountNo:'player01',email:'me@example.com',newPassword:'NewPass123'});
});
test('device identity, first channel and invite survive page navigation without persisting credentials',async()=>{
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
const storage=new Map([['uuid','old-device']]);
@@ -66,7 +49,7 @@ test('concurrent private requests share one guest initialization and public data
if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);}
return response({});
},async()=>{
const first=api.get('user/center/data'),second=api.get('playlet/collect/list');
const first=api.get('comment/list'),second=api.get('playlet/detail');
await Promise.resolve();assert.equal(calls.length,1);gate.resolve();await Promise.all([first,second]);
assert.equal(calls.filter(call=>call.url.endsWith('/auth/guest')).length,1);
assert.ok(calls[0].body.devID);assert.equal(api.user.userId,7);
@@ -74,14 +57,26 @@ test('concurrent private requests share one guest initialization and public data
});
});
test('history restoration revalidates current cookies, respects logout and cannot overwrite a newer login',async()=>{
test('history restoration revalidates current cookies and cannot overwrite a newer session',async()=>{
const api=await client();api.seedSession(guest);
await withFetch(async(_url,options)=>{assert.equal(JSON.parse(options.body).automatic,true);return response(null);},async()=>{
await api.revalidateSession();assert.equal(api.user,null);assert.equal(await api.initializeSession(),null);
});
await withFetch(async()=>response(null),async()=>{await api.revalidateSession();assert.equal(api.user,null);});
const gate=deferred();api.seedSession(guest);
await withFetch(async()=>{await gate.promise;return response(guest);},async()=>{
const refresh=api.revalidateSession();await Promise.resolve();api.setUser(member);gate.resolve();await refresh;assert.equal(api.user.userId,member.userId);
const refresh=api.revalidateSession();await Promise.resolve();api.setUser(other);gate.resolve();await refresh;assert.equal(api.user.userId,other.userId);
});
});
test('an expired session is replaced by a fresh guest on the next private request and never prompts for login',async()=>{
const api=await client();api.seedSession(guest);const calls=[];let expired=true;
await withFetch(async url=>{
calls.push(url);
if(url.endsWith('/auth/guest'))return response(other);
if(expired){expired=false;return response(null,1000);}
return response({list:[]});
},async()=>{
await assert.rejects(api.get('comment/list',{objectId:1}));assert.equal(api.user,null);
await api.get('comment/list',{objectId:1});assert.equal(api.user.userId,other.userId);
assert.deepEqual(calls.map(url=>url.split('?')[0]),['/api/comment/list','/api/auth/guest','/api/comment/list']);
});
});
@@ -100,96 +95,8 @@ test('search and suggestions wait for a shared guest session before requesting p
});
});
test('slow guest restoration finishes before login and binding waits for a guest identity',async()=>{
const api=await client(),gate=deferred(),calls=[];
await withFetch(async(url,options)=>{
calls.push({url,body:JSON.parse(options.body)});
if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);}
return response(member);
},async()=>{
const restore=api.initializeSession(),login=api.post('auth/login',{account:'player',password:'Secure123'});
await Promise.resolve();assert.equal(calls.length,1);gate.resolve();await Promise.all([restore,login]);
assert.equal(calls[1].body.devID,calls[0].body.devID);assert.equal(api.user.userId,8);
const binding=await client();await binding.post('auth/register',{accountNo:'player',password:'Secure123'});
assert.deepEqual(calls.slice(2).map(call=>call.url),['/api/auth/guest','/api/auth/register']);
assert.equal(binding.user.userId,8);
});
});
test('invalid credentials preserve the current identity; late expired requests cannot clear a new login',async()=>{
const api=await client();api.setUser(guest);
const gate=deferred();let invalid=true;
await withFetch(async url=>{
if(url.endsWith('/auth/login'))return invalid?response(null,1000):response(member);
await gate.promise;return response(null,1000);
},async()=>{
await assert.rejects(api.post('auth/login',{account:'player',password:'bad'}));assert.equal(api.user.userId,7);
const pending=api.get('user/center/data');await Promise.resolve();invalid=false;
await api.post('auth/login',{account:'player',password:'Secure123'});gate.resolve();await assert.rejects(pending);
assert.equal(api.user.userId,8);
});
});
test('registered expiry prompts once and blocks automatic guest downgrade',async()=>{
const api=await client();api.setUser(member);let prompts=0,calls=0;api.addEventListener('expired',()=>prompts++);
await withFetch(async()=>{calls++;return response(null,1000,{'X-Auth-State':'login-required'});},async()=>{
await assert.rejects(api.get('user/center/data'));assert.equal(api.user,null);assert.equal(prompts,1);
assert.equal(await api.initializeSession(),null);
await assert.rejects(api.ensureSession());await assert.rejects(api.ensureSession());
assert.equal(calls,1);assert.equal(prompts,1);
});
});
test('manual logout and the following skipped initialization stay signed out',async()=>{
const api=await client();api.setUser(member);const calls=[];
await withFetch(async url=>{calls.push(url);return response(null);},async()=>{
await api.post('auth/logout',{});assert.equal(api.user,null);
assert.equal(await api.initializeSession(),null);assert.deepEqual(calls,['/api/auth/logout']);
const refreshed=await client();assert.equal(await refreshed.initializeSession(),null);
await assert.rejects(refreshed.ensureSession());assert.deepEqual(calls,['/api/auth/logout','/api/auth/guest']);
});
});
test('restoring an expired registered cookie prompts for login even before a user is loaded',async()=>{
const api=await client();let prompts=0;api.addEventListener('expired',()=>prompts++);
await withFetch(async()=>response(null,1000,{'X-Auth-State':'login-required'}),async()=>{
await assert.rejects(api.initializeSession());await assert.rejects(api.ensureSession());
assert.equal(prompts,1);assert.equal(api.user,null);
});
});
test('auth dialog renders binding confirmation, blocks duplicate submission, and clears revealed passwords on close',async()=>{
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)));
const {bindAccountUI,openAuth}=await import(accountUrl);
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const listeners={},error={textContent:''},button={textContent:'绑定账号',disabled:false};
const password={value:'Secure123',type:'password'},confirmation={value:'Secure123',type:'password'};
const form={dataset:{mode:'register'},isConnected:true,reportValidity:()=>true,
querySelector:selector=>selector==='#auth-error'?error:button,querySelectorAll:()=>[password,confirmation]};
const dialog={id:'member-auth-dialog',innerHTML:'',open:false,classList:{toggle(){}},querySelector:selector=>selector==='#auth-account'?{focus(){}}:selector==='#auth-password'?password:null,
querySelectorAll:()=>[password,confirmation],addEventListener:(name,handler)=>listeners[name]=handler,showModal(){this.open=true;},close(){this.open=false;listeners.close();}};
const gate=deferred();let submitted=0,routed=0;
const app={api:{post:async(path,payload)=>{submitted++;assert.equal(path,'auth/register');assert.equal(payload.accountNo,'player01');await gate.promise;}},notify(){},async route(){routed++;}};
globalThis.document={getElementById:id=>id==='member-auth-dialog'?dialog:null};
globalThis.FormData=class extends OldFormData{constructor(){super();for(const [key,value] of Object.entries({account:'player01',password:'Secure123',passwordConfirm:'Secure123',agree:'on'}))this.append(key,value);}};
try{
bindAccountUI(app);openAuth('bind');assert.ok(dialog.open);
assert.ok(dialog.innerHTML.includes('name="passwordConfirm"'));assert.ok(!dialog.innerHTML.includes('name="email"'));
const toggle={dataset:{authPasswordToggle:'auth-password'},setAttribute(){}};
listeners.click({target:{closest:()=>toggle}});assert.equal(password.type,'text');
const event={target:form,preventDefault(){}};listeners.submit(event);listeners.submit(event);
assert.equal(submitted,1);assert.equal(button.disabled,true);
gate.resolve();await new Promise(resolve=>setImmediate(resolve));
assert.equal(routed,1);assert.equal(button.disabled,false);assert.equal(password.value,'');assert.equal(confirmation.value,'');
openAuth('switch');assert.ok(dialog.innerHTML.includes('切换账号'));
}finally{globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
});
test('a server-prepared session is reused without another guest handshake and a sign-in snapshot blocks automatic downgrade',async()=>{
test('a server-prepared session is reused without another guest handshake',async()=>{
await withFetch(()=>assert.fail('prepared sessions must not refetch auth'),async()=>{
const ready=await client();ready.seedSession(member);assert.equal((await ready.initializeSession()).userId,member.userId);
const signedOut=await client();signedOut.seedSession(null);assert.equal(await signedOut.initializeSession(),null);
const ready=await client();ready.seedSession(other);assert.equal((await ready.initializeSession()).userId,other.userId);
});
});
+11 -12
View File
@@ -10,31 +10,30 @@ async function moduleUrl(file,replace=code=>code){
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const siteUrl=await moduleUrl('../src/lib/site.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)));
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'../site'",JSON.stringify(siteUrl)));
const {overviewMarkup,officialLinks,bindSeriesDescription,detailActionIcon}=await import(detailUrl);
test('overview retains API data, encrypted image loading and real chapter links in the original layout',()=>{
test('overview exposes H1, labelled facts, linked genres, synopsis heading and real chapter links',()=>{
const html=overviewMarkup({id:23456,title:'剧名 <script>',coverImg:'https://cdn.example/encrypted.webp',description:'真实简介 <img onerror=alert(1)>',themeNames:['古装','爱情'],isNew:true,countCollect:23000,countLike:100,heatValue:999},{id:90042,currentEpisode:1},'<div class="play-detail-actions">actions</div>');
assert.ok(html.includes('剧名 &lt;script&gt;'));assert.ok(!html.includes('<script>'));
assert.ok(html.includes('真实简介 &lt;img onerror=alert(1)&gt;'));assert.ok(html.includes('古装 · 爱情'));
assert.ok(html.includes('真实简介 &lt;img onerror=alert(1)&gt;'));assert.ok(html.includes('<dt>类型</dt>'));assert.ok(html.includes('<span>古装</span><span>爱情</span>'));assert.ok(html.includes('<dt>集数</dt>'));
assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));assert.ok(!/\ssrc="https:\/\/cdn\.example/.test(html));
assert.ok(html.includes('href="/play/23456/90042/"'));assert.ok(html.includes('series-badge--new'));
assert.ok(html.includes('<div class="series-action-bar"><div class="series-actions">'));
assert.ok(html.includes('href="/play/23456/90042/"'));assert.ok(html.includes('hg-detail-badge--new'));assert.ok(html.includes('<h1 id="play-detail-title">剧名 &lt;script&gt;</h1>'));assert.ok(html.includes('《剧名 &lt;script&gt;》剧情简介</h2>'));assert.ok(html.includes('alt="《剧名 &lt;script&gt;》海报"'));
assert.ok(html.includes('<div class="play-detail-actions">actions</div>'));
assert.ok(html.includes('aria-controls="series-description-text"'));assert.ok(html.includes('data-app-download-entry'));
assert.ok(!overviewMarkup({id:7,title:'无章节'},undefined,'').includes('href="/play/'));
});
test('official links use configured safe URLs and retain the original feedback entrance',()=>{
const html=officialLinks({config:{newUrlAddr:'https://entry.example',foreverUrlAddr:'https://permanent.example',tgGroup:'https://t.me/example',telegram:'https://contact.example'}});
for(const url of ['https://entry.example/','https://permanent.example/','https://t.me/example','https://contact.example/'])assert.ok(html.includes(`href="${url}"`));
const fallback=officialLinks({config:{newUrlAddr:'javascript:alert(1)',tgGroup:'javascript:alert(1)'},navPageList:[{line:1,url:'https://backup.example'}]});
assert.ok(fallback.includes('href="https://backup.example/"'));assert.ok(fallback.includes('href="/info/feedback/"'));assert.ok(!fallback.includes('javascript:'));
assert.ok(!fallback.includes('huangguo.me'));assert.ok(!fallback.includes('huangguo.tv'));
test('official links show only the site domain and the official TG, ignoring configured mirrors and contacts',()=>{
const html=officialLinks();
assert.ok(html.includes('href="https://huangguocg.com/"'));assert.ok(html.includes('官方TG:'));assert.ok(html.includes('href="https://t.me/jbd125"'));assert.ok(html.includes('>@jbd125</a>'));
for(const text of ['官方客服','Telegram 群','javascript:'])assert.ok(!html.includes(text),text);
});
test('original action SVGs remain accessible and inherit the active colors',()=>{
for(const kind of ['like','save','share','feedback']){
for(const kind of ['like','save','share']){
const html=detailActionIcon(kind);assert.match(html,/<svg class="play-action-icon"/);assert.match(html,/aria-hidden="true"/);assert.ok(html.includes('<path d="'));
}
});
+1 -1
View File
@@ -103,7 +103,7 @@ test('project profiles and Next configuration keep the requested API and port wi
for(const [environment,directory] of [['development','.next-dev'],['test','.next-test'],['production','.next']]){
const command=nextCommand(environment,'build',[],{variables:{}}),exports={};
vm.runInNewContext(source,{exports,process:{env:command.env,cwd:()=>process.cwd()}});
assert.equal(exports.default.distDir,directory);assert.equal(command.env.UPSTREAM_API_URL,environment==='production'?'http://172.104.56.28:26125':'http://172.104.181.99:21022');assert.equal(command.env.API_BASE_URL,command.env.UPSTREAM_API_URL);assert.equal(command.env.PORT,'21021');assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID,'1047');
assert.equal(exports.default.distDir,directory);assert.equal(command.env.UPSTREAM_API_URL,'https://webhgcgapi.mfcg.me');assert.equal(command.env.API_BASE_URL,command.env.UPSTREAM_API_URL);assert.equal(command.env.PORT,'21021');assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID,undefined);
const ignored=spawnSync('git',['check-ignore','--no-index',`config/${environment}.local.env`],{encoding:'utf8'});assert.equal(ignored.status,0);
}
const pkg=JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8'));
+2 -1
View File
@@ -3,12 +3,13 @@ import ts from 'typescript';
const compile=async path=>ts.transpileModule(await readFile(new URL('../../'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
const moduleUrl=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64');
export const siteUrl=moduleUrl(await compile('src/lib/site.ts'));
const routingUrl=moduleUrl(await compile('src/lib/routing.ts'));
const protocolUrl=moduleUrl(await compile('src/lib/api/protocol.ts'));
const playbackUrl=moduleUrl((await compile('src/lib/client/playback.ts')).replace("'../api/protocol'",JSON.stringify(protocolUrl)));
export const structuredUrl=moduleUrl((await compile('src/lib/structured-data.ts')).replace("'./routing'",JSON.stringify(routingUrl)));
export const structuredData=await import(structuredUrl);
export const seoUrl=moduleUrl((await compile('src/lib/seo.ts')).replace("'./routing'",JSON.stringify(routingUrl)).replace("'./structured-data'",JSON.stringify(structuredUrl)).replace("'./client/playback'",JSON.stringify(playbackUrl)));
export const seoUrl=moduleUrl((await compile('src/lib/seo.ts')).replace("'./site'",JSON.stringify(siteUrl)).replace("'./routing'",JSON.stringify(routingUrl)).replace("'./structured-data'",JSON.stringify(structuredUrl)).replace("'./client/playback'",JSON.stringify(playbackUrl)));
export const seo=await import(seoUrl);
export const clientSeo=await import(moduleUrl((await compile('src/lib/client/seo.ts')).replace("'../structured-data'",JSON.stringify(structuredUrl))));
+13 -17
View File
@@ -8,25 +8,21 @@ async function moduleUrl(file,replace=code=>code){
return 'data:text/javascript;base64,'+Buffer.from(replace(ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText)).toString('base64');
}
const protocol=await moduleUrl('../src/lib/api/protocol.ts');
const {navigationPageData}=await import(await moduleUrl('../src/lib/navigation-page.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol))));
const siteUrl=await moduleUrl('../src/lib/site.ts');
const {navigationPageData}=await import(await moduleUrl('../src/lib/navigation-page.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol)).replace("'./site'",JSON.stringify(siteUrl))));
test('navigation renders configured lines in order without duplicating positional fallbacks or invalid URLs',()=>{
const config={navPageList:[{line:2,url:'https://two.test/path'},{line:1,url:'javascript:alert(1)'},{line:1,url:'https://one.test'},{line:1,url:'https://duplicate.test'},{line:3,url:''},{line:0,url:'https://invalid.test'}]};
assert.deepEqual(navigationPageData(config).routes,[{line:1,href:'https://one.test/',label:'线路一'},{line:2,href:'https://two.test/path',label:'线路二'}]);
test('navigation entry and permanent address always use the site domain, never configured mirrors',()=>{
for(const config of [{navPageList:[{line:1,url:'https://one.test'},{line:2,url:'https://two.test'}]},{config:{dhNewUrlAddr1:'https://nav-one.test',foreverUrlAddr:'https://permanent.test'}},{navPageList:null}]){
const data=navigationPageData(config);
assert.deepEqual(data.routes,[{line:1,href:'https://huangguocg.com/',label:'线路一'}]);
assert.equal(data.permanent,'https://huangguocg.com/');
}
const empty=navigationPageData({});assert.equal(empty.telegram,'https://t.me/jbd125');assert.equal(empty.telegramHandle,'@jbd125');
});
test('legacy navigation fields and address sections supply valid configured destinations only',()=>{
const data=navigationPageData({config:{dhNewUrlAddr1:'https://nav-one.test',dhNewUrlAddr2:'javascript:bad',newUrlAddr1:'https://backup.test',foreverUrlAddr:''},newAddressInfo:{foreverUrlAddr:'https://permanent.test/entry',dhNewUrlAddr2:'https://nav-two.test'}});
assert.deepEqual(data.routes.map(route=>route.href),['https://nav-one.test/','https://nav-two.test/']);
assert.equal(data.permanent,'https://permanent.test/entry');
const empty=navigationPageData({navPageList:null});
assert.deepEqual(empty.routes,[]);for(const key of ['permanent','telegram','tgGroup','twitter'])assert.equal(empty[key],'');
});
test('navigation identity and contacts use configuration, support TG handles, and omit demo accounts',()=>{
test('navigation uses the fixed site name, configured description and only the official TG contact',()=>{
const data=navigationPageData({seoNavInfo:JSON.stringify({dhSiteName:'配置导航站',dhDescription:'配置说明'}),config:{telegram:'javascript:bad',telegramAdd:'@business_91',tgGroup:'',twitterAddr:'data:text/html,bad'},newAddressInfo:{tgGroup:'@group_91',twitterAddr:'https://x.com/configured'}});
assert.equal(data.siteName,'配置导航站');assert.equal(data.description,'配置说明');
assert.equal(data.telegram,'https://t.me/business_91');assert.equal(data.tgGroup,'https://t.me/group_91');assert.equal(data.twitter,'https://x.com/configured');
assert.equal(navigationPageData({config:{dhSiteName:'配置名称'}}).siteName,'配置名称');
assert.equal(navigationPageData({seoInfo:{webSiteName:'站点名称'}}).siteName,'站点名称');
assert.equal(data.siteName,'黄果吃瓜');assert.equal(data.description,'配置说明');
assert.equal(data.telegram,'https://t.me/jbd125');assert.equal(data.telegramHandle,'@jbd125');assert.equal(data.tgGroup,undefined);assert.equal(data.twitter,undefined);
assert.equal(navigationPageData({config:{dhSiteName:'配置名称'}}).siteName,'黄果吃瓜');
});
+5 -143
View File
@@ -11,155 +11,17 @@ async function moduleUrl(file,replace=code=>code){
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)));
const feedbackUrl=await moduleUrl('../src/lib/client/feedback.ts',code=>code.replace('"./render"',JSON.stringify(renderUrl)));
const {card}=await import(renderUrl);
const {accountMenuMarkup,settingsMarkup,memberMarkup,renderMember}=await import(accountUrl);
const {feedbackPayload,feedbackMarkup,renderFeedbackPage}=await import(feedbackUrl);
const flush=()=>new Promise(resolve=>setImmediate(resolve));
test('ranking cards retain original poster frames and global positions, and search highlights only escaped titles',()=>{
test('poster cards keep ranking numerals, descriptive link titles and alt text, and search highlights only escaped titles',()=>{
const drama={id:7,title:'测试 <短剧>',cover:'https://cdn.example/cover.webp',totalEpisode:10};
const ranked=card(drama,false,{rank:25});
assert.ok(ranked.includes('class="ranking-poster-frame"'));assert.ok(ranked.includes('data-rank="25"'));assert.ok(ranked.includes('第 25 名'));assert.ok(ranked.includes('>25</span>'));
assert.ok(ranked.includes('<article class="hg-card hg-card--ranked">'));assert.ok(ranked.includes('data-rank="25"'));assert.ok(ranked.includes('第 25 名'));assert.ok(ranked.includes('>25</span>'));
assert.ok(ranked.includes('href="/detail/7/"'));assert.ok(ranked.includes('data-src="https://cdn.example/cover.webp"'));
assert.ok(card(drama,false,{rank:1}).includes('>01</span>'));
assert.ok(!card(drama,false,{rank:-1}).includes('rank-home-badge'));
assert.ok(!card(drama,false,{rank:-1}).includes('hg-card-rank'));
assert.ok(ranked.includes('title="《测试 &lt;短剧&gt;》全10集 在线观看"'));assert.ok(ranked.includes('alt="《测试 &lt;短剧&gt;》海报"'));assert.ok(ranked.includes('<span class="hg-card-eps">全10集</span>'));
const search=card(drama,false,{keyword:'<短剧>'});
assert.ok(search.includes('<span class="search-keyword">&lt;短剧&gt;</span>'));assert.ok(!search.includes('<短剧>'));
assert.ok(search.includes('aria-label="查看测试 &lt;短剧&gt;"'));
});
test('personal pages use actual library data and route links inside the original summary and tabs',async()=>{
const oldDocument=globalThis.document,root={innerHTML:''},calls=[];
const user={userId:88,nickName:'真实昵称 <script>',recharge:2400,avatar:'https://cdn.example/avatar.webp'};
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{calls.push({path,query});return {list:[{id:7,title:'已看过的短剧'}],totalCount:72};}}};
globalThis.document={getElementById:()=>root};
try{
await renderMember(app,'center',1,()=>'/member/center/');
assert.equal(calls.at(-1).path,'playlet/history/list');
for(const className of ['member-name-row','member-settings-link','member-wallet','coin-soft-star','member-library-tabs','member-library-toolbar','member-library-cards','member-library-item'])assert.ok(root.innerHTML.includes(className),className);
assert.ok(root.innerHTML.includes('真实昵称 &lt;script&gt;'));assert.ok(root.innerHTML.includes('2,400'));assert.ok(root.innerHTML.includes('ID:88'));
assert.ok(root.innerHTML.includes('href="/member/settings/"'));assert.ok(root.innerHTML.includes('href="/member/history/page/2/"'));assert.ok(!root.innerHTML.includes('/member/center/page/'));
assert.ok(root.innerHTML.includes('href="/member/favorites/"'));assert.ok(root.innerHTML.includes('href="/member/likes/"'));assert.ok(root.innerHTML.includes('href="/member/purchased/"'));
await renderMember(app,'favorites',2,page=>`/member/favorites/page/${page}/`);
assert.equal(calls.at(-1).path,'playlet/collect/list');assert.equal(calls.at(-1).query.pageNum,2);
assert.ok(root.innerHTML.includes('href="/member/favorites/" aria-current="page"'));
assert.ok(!root.innerHTML.includes('吴秀英'));assert.ok(!root.innerHTML.includes('1,280'));
}finally{globalThis.document=oldDocument;}
});
test('likes use the protected list endpoint, native pagination and the reference library markup',async()=>{
const previous=globalThis.document,root={innerHTML:''},calls=[];
const user={userId:8,nickName:'点赞用户'};
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{
calls.push({path,query});return {list:[{id:7,slug:'liked-story',title:'点赞 <短剧>',totalEpisode:8}],totalCount:72};
}}};
globalThis.document={getElementById:()=>root};
try{
await renderMember(app,'likes',2,page=>page===1?'/member/likes/':`/member/likes/page/${page}/`);
assert.deepEqual(calls.at(-1),{path:'playlet/like/list',query:{pageNum:2,pageSize:24}});
assert.ok(root.innerHTML.includes('href="/member/likes/" aria-current="page"><h2 id="member-library-title"'));
assert.ok(root.innerHTML.includes('共 72 部短剧'));assert.ok(root.innerHTML.includes('href="/member/likes/page/3/"'));
assert.ok(root.innerHTML.includes('href="/detail/liked-story/"'));assert.ok(root.innerHTML.includes('点赞 &lt;短剧&gt;'));
for(const tab of ['history','favorites','likes','purchased'])assert.ok(root.innerHTML.includes(`href="/member/${tab}/"`));
const initial={user,list:{list:[],totalCount:0}};calls.length=0;
await renderMember(app,'likes',1,()=>'/member/likes/',initial);
assert.equal(calls.length,0,'SSR data must be reused without a second list request');
assert.ok(root.innerHTML.includes('member-library-empty'));assert.ok(root.innerHTML.includes('暂无点赞的短剧'));
assert.ok(root.innerHTML.includes('href="/shorts/">去发现好剧</a>'));
assert.ok(!memberMarkup(user,'likes',{list:null,totalCount:0}).includes('undefined'));
}finally{globalThis.document=previous;}
});
test('the account menu exposes a native likes entry for guests and registered users',()=>{
for(const user of [null,{userId:8,nickName:'用户 <script>',isRegister:true}]){
const html=accountMenuMarkup(user);
assert.ok(html.includes('<a href="/member/likes/">我的点赞 ›</a>'));
assert.ok(html.indexOf('/member/favorites/')<html.indexOf('/member/likes/'));
assert.ok(html.indexOf('/member/likes/')<html.indexOf('/member/history/'));
assert.ok(!html.includes('<script>'));
}
});
test('settings preserve actual profile fields and auth actions inside the reference cards',()=>{
const html=settingsMarkup({userId:8,nickName:'用户 <script>',introduction:'简介 <img>',avatar:'https://cdn.example/avatar.webp',isRegister:true});
for(const text of ['member-settings-card','member-avatar-editor','member-avatar-preview','member-nickname-editor','账号与安全','data-auth-mode="register"','data-auth-mode="switch"','data-logout','id="security-form"','name="oldPassword"','name="newPassword"'])assert.ok(html.includes(text),text);
assert.ok(html.includes('value="用户 &lt;script&gt;"'));assert.ok(html.includes('简介 &lt;img&gt;'));assert.ok(html.includes('已绑定'));
assert.ok(html.includes('id="member-avatar-file" accept="image/png,image/jpeg,image/webp" hidden'));assert.ok(html.includes('data-src="https://cdn.example/avatar.webp"'));
});
test('feedback options map to the real API type and keep environment details with actual attachments',()=>{
const html=feedbackMarkup();
for(const text of ['feedback-reference','feedback-options','feedback-panel','feedback-description-wrap','feedback-upload-box','feedback-submit-button'])assert.ok(html.includes(text),text);
assert.ok(!html.includes('video/mp4'));assert.ok(!html.includes('保存到本地'));
const data=new FormData();for(const [key,value] of Object.entries({category:'登录异常',content:'播放时一直缓冲,无法正常观看视频。',contact:' me@example.com ',network:'Wi-Fi',device:'iPhone'}))data.set(key,value);
const body=feedbackPayload(data,['/files/real.png']);assert.equal(body.type,4);assert.equal(body.contact,'me@example.com');assert.deepEqual(body.screenshots,['/files/real.png']);
assert.ok(body.content.includes('网络类型:Wi-Fi'));assert.ok(body.content.includes('设备类型:iPhone'));
data.set('category','无法播放');assert.equal(feedbackPayload(data).type,2);data.set('category','其他问题');assert.equal(feedbackPayload(data).type,5);
data.set('content','长'.repeat(500));assert.equal(feedbackPayload(data).content,'长'.repeat(500));
data.set('content','中'.repeat(490));assert.equal(feedbackPayload(data).content.length,500);
data.set('content','太短');assert.throws(()=>feedbackPayload(data),/10–500/);
});
class Element extends EventTarget {
nodes=new Map();dataset={};innerHTML='';textContent='';disabled=false;value='';files=[];
querySelector(selector){return this.nodes.get(selector)||null;}
querySelectorAll(){return [];}
reportValidity(){return true;}
reset(){this.resetCount=(this.resetCount||0)+1;}
}
function emit(element,type){element.dispatchEvent(new Event(type,{cancelable:true}));}
test('restyled settings forms retain distinct profile/password payloads and prevent repeated saves',async()=>{
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const controller=new AbortController(),root=new Element(),profile=new Element(),security=new Element();
const values=new Map([[profile,{nickName:'新昵称',introduction:'新的简介'}],[security,{oldPassword:'Old12345',newPassword:'New12345'}]]);
for(const form of [profile,security]){form.nodes.set('[type="submit"]',new Element());form.nodes.set('.api-error',new Element());}
root.nodes.set('#profile-form',profile);root.nodes.set('#security-form',security);
const calls=[];let finish,refreshes=0,routes=0;
const app={signal:controller.signal,home:{},api:{ensureSession:async()=>({userId:8}),get:async()=>({}),post:async(path,body)=>{calls.push({path,body});if(calls.length===1)await new Promise(resolve=>finish=resolve);},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values.get(form)))this.set(key,value);}};
try{
await renderMember(app,'settings',1,()=>'/member/settings/');
emit(profile,'submit');emit(profile,'submit');assert.equal(calls.length,1);assert.equal(profile.querySelector('[type="submit"]').disabled,true);
finish();await flush();assert.deepEqual(calls[0],{path:'auth/profile',body:{nickName:'新昵称',introduction:'新的简介'}});assert.equal(refreshes,1);assert.equal(routes,1);
emit(security,'submit');await flush();assert.deepEqual(calls[1],{path:'auth/profile',body:{oldPassword:'Old12345',newPassword:'New12345'}});assert.equal(refreshes,2);assert.equal(routes,1);
}finally{controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
});
function feedbackFixture(post){
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const controller=new AbortController(),root=new Element(),form=new Element(),input=new Element(),list=new Element(),status=new Element(),error=new Element(),content=new Element(),count=new Element(),submit=new Element();
const values={content:'播放视频时持续缓冲,无法正常观看。',category:'无法播放',contact:'me@example.com',network:'Wi-Fi'};
root.nodes.set('#feedback-form',form);
for(const [selector,node] of [['#feedback-files',input],['#feedback-file-list',list],['#feedback-status',status],['#feedback-file-error',error],['[name="content"]',content],['#feedback-count',count],['[type="submit"]',submit]])form.nodes.set(selector,node);
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values))this.set(key,value);}};
const calls=[];renderFeedbackPage({signal:controller.signal,api:{post:async(path,body)=>{calls.push({path,body});return post(path,body);}}});
return {controller,form,input,list,status,error,submit,calls,dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}};
}
test('reference feedback form uploads images only on submit and blocks duplicate feedback writes',async()=>{
let finish;const f=feedbackFixture(path=>path==='upload/image'?new Promise(resolve=>finish=resolve):Promise.resolve({}));
try{
f.input.files=[new File(['image'],'shot.png',{type:'image/png'})];emit(f.input,'change');assert.equal(f.calls.length,0);assert.ok(f.list.innerHTML.includes('shot.png'));
emit(f.form,'submit');emit(f.form,'submit');assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'upload/image');assert.equal(f.submit.disabled,true);
finish({sourceNameFs:'/uploaded/shot.png'});await flush();
assert.equal(f.calls.length,2);assert.equal(f.calls[1].path,'feedback/add');assert.deepEqual(f.calls[1].body.screenshots,['/uploaded/shot.png']);assert.equal(f.calls[1].body.type,2);
assert.equal(f.form.resetCount,1);assert.match(f.status.textContent,/反馈已提交/);assert.equal(f.submit.disabled,false);
}finally{f.dispose();}
});
test('unsupported files, failed uploads and navigation cannot silently submit feedback',async()=>{
const failed=feedbackFixture(async()=>({}));
try{
failed.input.files=[new File(['bad'],'clip.mp4',{type:'video/mp4'})];emit(failed.input,'change');assert.match(failed.error.textContent,/JPG/);assert.equal(failed.calls.length,0);
failed.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(failed.input,'change');emit(failed.form,'submit');await flush();
assert.deepEqual(failed.calls.map(call=>call.path),['upload/image']);assert.match(failed.status.textContent,/上传失败/);assert.equal(failed.submit.disabled,false);assert.equal(failed.form.resetCount,undefined);
}finally{failed.dispose();}
let finish;const late=feedbackFixture(()=>new Promise(resolve=>finish=resolve));
try{
late.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(late.input,'change');emit(late.form,'submit');late.controller.abort();finish({sourceNameFs:'/uploaded/late.png'});await flush();
assert.deepEqual(late.calls.map(call=>call.path),['upload/image']);assert.equal(late.form.resetCount,undefined);
}finally{late.dispose();}
assert.ok(search.includes('<h3 class="hg-card-title">'));
});
+4 -30
View File
@@ -15,13 +15,10 @@ const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const {pagination}=await import(renderUrl);
const {bindPaginationJumps}=await import(await moduleUrl('../src/lib/client/navigation.ts'));
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)));
const {renderMember,renderWallet}=await import(accountUrl);
test('every paginated page type resolves directly from the page suffix',()=>{
const keyword='都市 / page/7/?#%2F';
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+encodeURIComponent(keyword)+'/', '/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/info/deposit/','/info/deposit/vip/','/detail/23456/','/play/23456/90001/'];
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+encodeURIComponent(keyword)+'/'];
for(const base of bases){
for(const page of [1,2,37]){
const path=base+'page/'+page+'/';
@@ -36,7 +33,8 @@ test('every paginated page type resolves directly from the page suffix',()=>{
test('invalid page numbers, nested page suffixes, and non-paginated sections are rejected',()=>{
for(const value of ['0','-1','1.5','02','NaN','9007199254740992'])assert.equal(parseSitePath(`/shorts/page/${value}/`),null,value);
for(const base of ['/','/apps/','/home-road/','/navigation/','/member/center/','/member/settings/','/info/about-us/'])assert.equal(parseSitePath(base+'page/2/'),null,base);
for(const base of ['/','/apps/','/home-road/','/navigation/','/info/about-us/'])assert.equal(parseSitePath(base+'page/2/'),null,base);
for(const base of ['/member/favorites/','/member/history/','/info/deposit/','/info/deposit/vip/','/info/settings/','/info/user-center/'])assert.equal(parseSitePath(base),null,base);
assert.equal(parseSitePath('/shorts/page/2/page/3/'),null);
assert.throws(()=>paginationPath('/shorts/',0));assert.throws(()=>paginationPath('/apps/',2));
});
@@ -49,7 +47,7 @@ test('page links retain filters and encoded search terms and sorting resets to t
assert.equal(paginationPath(base,2),searchPath('都市 / 爱情?#%',2));
const url=new URL(paginationPath(base,2),'https://drama.example');
assert.equal(parseSitePath(url.pathname,url.search).key,'都市 / 爱情?#%');
assert.equal(paginationPath('/play/7/90001/page/3/?commentSort=1',1,{commentSort:null}),'/play/7/90001/');
for(const path of ['/detail/23456/page/2/','/play/23456/90001/page/2/'])assert.equal(parseSitePath(path),null,'detail and play pages have no comment pagination');
});
test('pagination renders real page links with correct boundaries, current page, and bounded page numbers',()=>{
@@ -93,27 +91,3 @@ test('jump forms navigate to independent routes, retain canonical slugs and filt
if(previousInput===undefined)delete globalThis.HTMLInputElement;else globalThis.HTMLInputElement=previousInput;
}
});
test('personal library and wallet requests and page links use the route page',async()=>{
const previousDocument=globalThis.document,calls=[];
const root={innerHTML:'',querySelector:()=>null,addEventListener(){}};
const user={userId:7,nickName:'测试用户'};
const api={user,ensureSession:async()=>user,async get(path,query){
calls.push({path,...query});
if(path==='user/center/data')return {};
if(path==='recharge/package/list')return {coinList:[],vipList:[]};
return {list:[],totalCount:120};
}};
const app={api,signal:new AbortController().signal};
globalThis.document={getElementById:()=>root};
try{
for(const [tab,endpoint] of [['favorites','playlet/collect/list'],['history','playlet/history/list'],['likes','playlet/like/list'],['purchased','playlet/bought/list']]){
await renderMember(app,tab,3,page=>paginationPath(`/member/${tab}/page/3/`,page));
const call=calls.at(-1);assert.equal(call.path,endpoint);assert.equal(call.pageNum,3);
assert.ok(root.innerHTML.includes(`href="/member/${tab}/page/2/"`));
}
await renderWallet(app,true,3,page=>paginationPath('/info/deposit/vip/page/3/',page));
assert.equal(calls.at(-1).path,'coin/trans/list');assert.equal(calls.at(-1).page,3);
assert.ok(root.innerHTML.includes('href="/info/deposit/vip/page/4/"'));
}finally{if(previousDocument===undefined)delete globalThis.document;else globalThis.document=previousDocument;}
});
-190
View File
@@ -1,190 +0,0 @@
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)));
const {paymentDialogMarkup,renderWallet}=await import(accountUrl);
const coinPackage={id:31,name:'金币套餐',packageType:2,price:10000,baseCoin:100,giftCoin:999,totalCoin:1099,payMethods:[{type:'alipay',typeName:'支付宝'},{type:'wechat',typeName:'微信'}]};
const flush=()=>new Promise(resolve=>setImmediate(resolve));
test('confirmation displays actual base/gift/total coins and the price in yuan with original payment icons',()=>{
const html=paymentDialogMarkup(coinPackage,false);
assert.ok(html.includes('<strong>100 金币</strong>'));assert.ok(html.includes('赠送 999 金币 · 合计 1099 金币'));
assert.ok(html.includes('<b>¥100</b>'));assert.ok(html.includes('5652fcead17188e8.svg'));assert.ok(html.includes('2bead9ea5c435c98.svg'));
assert.ok(!html.includes('7848816d05aa8591.svg'));assert.ok(!html.includes('type="radio"'));
assert.ok(html.includes('name="payMode" value="alipay"'));assert.ok(html.includes('class="wallet-payment-title"'));
const derived=paymentDialogMarkup({...coinPackage,baseCoin:undefined,price:12345},false);
assert.ok(derived.includes('<strong>100 金币</strong>'));assert.ok(derived.includes('<b>¥123.45</b>'));
});
test('VIP and missing payment data never introduce demo bonuses or unconfigured channels',()=>{
const vip=paymentDialogMarkup({id:7,name:'月卡 <script>',packageType:1,price:3000},true);
assert.ok(vip.includes('会员畅享'));assert.ok(vip.includes('月卡 &lt;script&gt;'));assert.ok(!vip.includes('赠送 999'));
assert.ok(vip.includes('暂无可用支付方式'));assert.match(vip,/<button class="wallet-submit wallet-payment-go" type="submit" disabled>/);
assert.ok(!vip.includes('data-wallet-method='));
const unknown=paymentDialogMarkup({...coinPackage,payMethods:[null,{type:'',typeName:'空'},{type:'union',typeName:'银联 <img>'},{type:'union',typeName:'重复'}]},false);
assert.equal((unknown.match(/data-wallet-method=/g)||[]).length,1);assert.ok(unknown.includes('银联 &lt;img&gt;'));assert.ok(unknown.includes('wallet-pay-logo--other'));
});
class Element extends EventTarget {
dataset={};attrs=new Map();nodes=new Map();lists=new Map();innerHTML='';textContent='';disabled=false;isConnected=true;value='';
querySelector(selector){return this.nodes.get(selector)||null;}
querySelectorAll(selector){return this.lists.get(selector)||[];}
setAttribute(key,value){this.attrs.set(key,String(value));}
getAttribute(key){return this.attrs.get(key)||null;}
closest(){return this;}
reportValidity(){return true;}
}
function emit(element,type,target=element){const event=new Event(type,{cancelable:true});Object.defineProperty(event,'target',{value:target});element.dispatchEvent(event);}
async function fixture({vip=false,post=async()=>({payUrl:'https://pay.example/checkout',tradeNo:'order-1'}),methods=coinPackage.payMethods,packages,transactions={list:[],totalCount:0},transactionError=false,balance=90,openDialog=true,popupBlocked=false}={}){
const oldDocument=globalThis.document,OldFormData=globalThis.FormData,oldWindow=globalThis.window;
const controller=new AbortController(),root=new Element(),dialog=new Element(),form=new Element(),hidden=new Element(),confirm=new Element(),error=new Element(),refresh=new Element();
const item={...coinPackage,payMethods:methods};hidden.value=methods[0]?.type||'';
const items=packages??[item],pay=new Element(),selection=new Element();pay.dataset.walletAction='pay';
const packageButtons=items.map((item,index)=>{const button=new Element();button.dataset.buyPackage=String(item.id);button.setAttribute('aria-pressed',String(index===0));return button;});
root.nodes.set('[data-wallet-action="pay"]',pay);root.nodes.set('#wallet-selection',selection);root.lists.set('[data-buy-package]',packageButtons);
const choices=methods.map(method=>{const button=new Element();button.dataset.walletMethod=method.type;button.nodes.set('.wallet-method-check',new Element());return button;});
form.nodes.set('[name="payMode"]',hidden);form.nodes.set('[type="submit"]',confirm);form.nodes.set('.api-error',error);form.nodes.set('[data-payment-refresh]',refresh);form.lists.set('[data-wallet-method]',choices);
dialog.nodes.set('form',form);dialog.open=false;dialog.showModal=()=>dialog.open=true;dialog.close=()=>dialog.open=false;
const posts=[],popups=[],redirects=[];let refreshes=0,routes=0;
const app={signal:controller.signal,api:{user:{recharge:balance},get:async path=>{if(path==='recharge/package/list')return {coinList:items,vipList:items};if(transactionError)throw new Error('offline');return transactions;},post:async(path,body)=>{posts.push({path,body});return post();},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
globalThis.document={getElementById:id=>id==='info-content'?root:dialog};
globalThis.window={open(url,target){
if(popupBlocked)return null;
const popup={url,target,closed:false,opener:{},document:{title:'',body:{textContent:''}},location:{replace(value){popup.url=value;}},close(){popup.closed=true;}};
popups.push(popup);return popup;
},location:{assign(url){redirects.push(url);}}};
globalThis.FormData=class extends OldFormData{constructor(form){super();this.append('payMode',form.querySelector('[name="payMode"]').value);}};
await renderWallet(app,vip,1,()=>'/info/deposit/');
if(openDialog)emit(root,'click',pay);
return {app,root,dialog,form,hidden,confirm,error,refresh,choices,posts,pay,selection,packageButtons,popups,redirects,controller,get refreshes(){return refreshes;},get routes(){return routes;},dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;globalThis.window=oldWindow;}};
}
test('recharge page uses the HTML balance, package and transaction layout with actual API values',async()=>{
const f=await fixture({openDialog:false,balance:2400,packages:[{...coinPackage,baseCoin:250,giftCoin:25,totalCoin:275,price:12345}],transactions:{list:[
{title:'解锁 <短剧>',createdAt:'2026-10-20T13:24:00+08:00',flowDir:-1,amount:30,changeAmount:0},
{tranTypeName:'充值到账',createdAt:'2026-10-20 12:00:00',flowDir:1,amount:100,changeAmount:125},
{remark:'余额校验',flowDir:1,amount:0,coin:999},
],totalCount:3}});
try{
const html=f.root.innerHTML;
for(const className of ['wallet-balance-card','wallet-balance-summary','wallet-usage','wallet-pack-panel','wallet-pack-heading','wallet-package-coin','wallet-double-badge','wallet-mobile-selected','wallet-pay-area','wallet-transaction-list'])assert.ok(html.includes(className),className);
assert.ok(html.includes('<strong>2,400</strong><b>金币</b>'));assert.ok(html.includes('>250金币</span>'));assert.ok(html.includes('>赠送 25金币</span>'));assert.ok(html.includes('立即支付 ¥123.45'));
assert.ok(html.includes('已选择 250金币 + 赠送25金币'));assert.ok(html.includes('aria-pressed="true"'));
assert.ok(html.includes('解锁 &lt;短剧&gt;'));assert.ok(html.includes('2026/10/20 13:24'));assert.ok(html.includes('datetime="2026-10-20T05:24:00.000Z"'));
assert.ok(html.includes('expense">−30</span>'));assert.ok(html.includes('income">+125</span>'));assert.ok(html.includes('income">0</span>'));
assert.ok(html.includes('href="/info/feedback/"'));assert.ok(!html.includes('模拟'));assert.ok(!html.includes('首次充值赠送10%'));assert.ok(!html.includes('1,280'));
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
}finally{f.dispose();}
});
test('package click only updates selection and the pay action confirms the selected package ID',async()=>{
const second={...coinPackage,id:84,baseCoin:300,giftCoin:0,totalCoin:300,price:4599};
const f=await fixture({openDialog:false,packages:[coinPackage,second]});
try{
emit(f.root,'click',f.packageButtons[1]);
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
assert.equal(f.packageButtons[0].getAttribute('aria-pressed'),'false');assert.equal(f.packageButtons[1].getAttribute('aria-pressed'),'true');
assert.equal(f.pay.textContent,'立即支付 ¥45.99');assert.equal(f.selection.textContent,'已选择 300金币 · 点击后选择支付方式');
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,true);assert.ok(f.dialog.innerHTML.includes('<strong>300 金币</strong>'));assert.ok(f.dialog.innerHTML.includes('<b>¥45.99</b>'));
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:84,payMode:'alipay'}}]);
}finally{f.dispose();}
});
test('empty packages and unavailable transactions show empty states without sample data or an order',async()=>{
const f=await fixture({openDialog:false,packages:[],transactionError:true,balance:0});
try{
assert.ok(f.root.innerHTML.includes('<strong>0</strong><b>金币</b>'));assert.ok(f.root.innerHTML.includes('暂无可购买套餐'));assert.ok(f.root.innerHTML.includes('余额明细暂不可用,请稍后刷新'));
assert.match(f.root.innerHTML, /data-wallet-action="pay" disabled/);assert.ok(!f.root.innerHTML.includes('data-buy-package='));assert.ok(!f.root.innerHTML.includes('wallet-transaction-row'));
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
}finally{f.dispose();}
});
test('selecting a styled method updates the actual order payload and duplicate submission is blocked',async()=>{
let finish;const f=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
try{
assert.equal(f.dialog.open,true);assert.equal(f.posts.length,0);
emit(f.form,'click',f.choices[1]);assert.equal(f.hidden.value,'wechat');assert.equal(f.choices[1].getAttribute('aria-pressed'),'true');assert.equal(f.choices[1].querySelector('.wallet-method-check').textContent,'✓');
emit(f.form,'submit');emit(f.form,'submit');assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:31,payMode:'wechat'}}]);
assert.equal(f.popups.length,1);assert.equal(f.popups[0].url,'about:blank');assert.equal(f.popups[0].target,'_blank');assert.equal(f.popups[0].opener,null);
assert.equal(f.confirm.disabled,true);emit(f.form,'click',f.choices[0]);assert.equal(f.hidden.value,'wechat');
finish({payUrl:'https://pay.example/checkout',tradeNo:'order-1'});await flush();
assert.equal(f.popups[0].url,'https://pay.example/checkout');assert.equal(f.popups[0].closed,false);assert.deepEqual(f.redirects,[]);
assert.match(f.form.innerHTML,/<a class="primary wallet-payment-link" href="https:\/\/pay\.example\/checkout" target="_blank" rel="sponsored nofollow noopener noreferrer">打开支付页面<\/a>/);assert.ok(f.form.innerHTML.includes('我已完成支付'));assert.equal(f.refreshes,0);assert.equal(f.app.api.user.recharge,90);
emit(f.refresh,'click');await flush();assert.equal(f.refreshes,1);assert.equal(f.dialog.open,false);assert.equal(f.routes,1);
f.controller.abort();assert.equal(f.popups[0].closed,false,'completed payment tabs remain open when leaving the wallet');
}finally{f.dispose();}
});
test('VIP uses its existing endpoint and an invalid payment URL leaves the form available to retry',async()=>{
const f=await fixture({vip:true,post:async()=>({payUrl:'javascript:alert(1)'})});
try{
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'vip/pay',body:{packageId:31,payMode:'alipay'}}]);
assert.match(f.error.textContent,/有效地址/);assert.equal(f.confirm.disabled,false);assert.equal(f.choices[0].disabled,false);assert.equal(f.refreshes,0);
assert.equal(f.popups[0].closed,true);assert.equal(f.popups[0].url,'about:blank');assert.deepEqual(f.redirects,[]);
assert.ok(!f.form.innerHTML.includes('javascript:'));
}finally{f.dispose();}
});
test('missing methods cannot create an order and a replaced form ignores late order responses',async()=>{
const empty=await fixture({methods:[]});
try{emit(empty.form,'submit');assert.equal(empty.posts.length,0);assert.equal(empty.popups.length,0);}finally{empty.dispose();}
let finish;const late=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
try{
emit(late.form,'submit');late.form.isConnected=false;finish({payUrl:'https://pay.example',tradeNo:'old-order'});await flush();
assert.equal(late.form.innerHTML,'');assert.equal(late.refreshes,0);
assert.equal(late.popups[0].closed,true);assert.equal(late.popups[0].url,'about:blank');assert.deepEqual(late.redirects,[]);
}finally{late.dispose();}
});
test('VIP payment opens the returned URL automatically and keeps a link to reopen the order',async()=>{
const f=await fixture({vip:true});
try{
emit(f.form,'submit');await flush();
assert.deepEqual(f.posts,[{path:'vip/pay',body:{packageId:31,payMode:'alipay'}}]);
assert.equal(f.popups.length,1);assert.equal(f.popups[0].url,'https://pay.example/checkout');
assert.ok(f.form.innerHTML.includes('打开支付页面'));assert.ok(f.form.innerHTML.includes('href="https://pay.example/checkout"'));assert.equal(f.refreshes,0);
}finally{f.dispose();}
});
test('a blocked payment popup automatically continues in the current page after order creation',async()=>{
let finish;const f=await fixture({popupBlocked:true,post:()=>new Promise(resolve=>finish=resolve)});
try{
emit(f.form,'submit');assert.deepEqual(f.redirects,[]);
finish({payUrl:'https://pay.example/checkout',tradeNo:'order-1'});await flush();
assert.deepEqual(f.redirects,['https://pay.example/checkout']);assert.equal(f.posts.length,1);
assert.ok(f.form.innerHTML.includes('打开支付页面'));
}finally{f.dispose();}
});
test('failed orders close their reserved tab and can retry with a fresh tab',async()=>{
let attempts=0;const f=await fixture({post:async()=>{
if(!attempts++)throw new Error('下单失败');
return {payUrl:'https://pay.example/retry',tradeNo:'order-2'};
}});
try{
emit(f.form,'submit');await flush();
assert.equal(f.popups[0].closed,true);assert.equal(f.error.textContent,'下单失败');assert.equal(f.confirm.disabled,false);
emit(f.form,'submit');await flush();
assert.equal(f.posts.length,2);assert.equal(f.popups.length,2);assert.equal(f.popups[1].url,'https://pay.example/retry');assert.equal(f.popups[1].closed,false);
}finally{f.dispose();}
});
test('leaving the wallet closes its pending tab and ignores a late payment URL',async()=>{
let finish;const f=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
try{
emit(f.form,'submit');f.controller.abort();assert.equal(f.popups[0].closed,true);
finish({payUrl:'https://pay.example/late',tradeNo:'old-order'});await flush();
assert.equal(f.popups[0].url,'about:blank');assert.deepEqual(f.redirects,[]);assert.equal(f.form.innerHTML,'');
}finally{f.dispose();}
});
+47 -234
View File
@@ -5,7 +5,7 @@ import {readFile} from 'node:fs/promises';
import ts from 'typescript';
import {setMaxListeners} from 'node:events';
import vm from 'node:vm';
import {seoUrl} from './helpers/seo.mjs';
import {seoUrl,siteUrl} from './helpers/seo.mjs';
async function moduleUrl(path,transform=code=>code){
const source=await readFile(new URL(path,import.meta.url),'utf8');
@@ -14,12 +14,11 @@ async function moduleUrl(path,transform=code=>code){
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const playbackModuleUrl=await moduleUrl('../src/lib/client/playback.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
const {playbackUrl,chapterAccess,chapterListGate,chapterUnlockCost,initialPlayMode,preferNativeHls,isCodecFailure,MediaPlayback}=await import(playbackModuleUrl);
const {playbackUrl,chapterAccess,initialPlayMode,preferNativeHls,isCodecFailure,MediaPlayback}=await import(playbackModuleUrl);
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
const appDownloadUrl=await moduleUrl('../src/lib/app-download.ts',code=>code.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
const downloadUrl=await moduleUrl('../src/lib/client/download.ts',code=>code.replace(/from ['"]\.\.\/app-download['"]/,`from '${appDownloadUrl}'`));
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`));
const unlockUrl=await moduleUrl('../src/lib/client/unlock.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`).replace("'../site'",JSON.stringify(siteUrl)));
const feedbackUrl=await moduleUrl('../src/lib/client/request-feedback.ts');
const markupUrl=await moduleUrl('../src/lib/client/player-markup.ts',code=>code
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
@@ -38,7 +37,6 @@ const playerUrl=await moduleUrl('../src/lib/client/player.ts',code=>code
.replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)
.replace(/from ['"]\.\/download['"]/,`from '${downloadUrl}'`)
.replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`)
.replace(/from ['"]\.\/unlock['"]/,`from '${unlockUrl}'`)
.replace(/from ['"]\.\/request-feedback['"]/,`from '${feedbackUrl}'`)
.replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
const {renderPlayer}=await import(playerUrl);
@@ -102,19 +100,15 @@ test('playback addresses preserve signed queries, use the relative proxy once, a
for(const value of [null,{},'', 'javascript:alert(1)','blob:abc','//external/stream','../secret','a/%2e%2e/b','a\\b','https://user:password@example.com/a'])assert.equal(playbackUrl(value),'');
});
test('chapter access follows reference precedence, supports legacy free URLs, and never supplies gated main sources',()=>{
test('every chapter with a playable source is free regardless of payment flags; chapters without one fall back to the preview',()=>{
const url='https://cdn.example/main.mp4';
assert.equal(chapterAccess({canWatch:true,needUnlock:true,videoUrl:url}).allowed,true);
assert.equal(chapterAccess({canWatch:false,payType:0,videoUrl:url}).allowed,true);
for(const chapter of [{needUnlock:true},{payType:1},{canWatch:false,payType:1}]){
const access=chapterAccess({...chapter,videoUrl:url});assert.equal(access.allowed,false);assert.equal(access.gate,'coins');assert.equal(access.source,'');assert.equal(initialPlayMode(access,url),'preview');
for(const chapter of [{canWatch:true},{needUnlock:true},{payType:1},{needVip:true},{payType:2},{canWatch:false,needUnlock:true,needVip:true,payType:1}]){
const access=chapterAccess({...chapter,videoUrl:url});
assert.equal(access.allowed,true);assert.equal(access.gate,'');assert.equal(access.source,url);assert.equal(initialPlayMode(access,url),'main');
}
for(const chapter of [null,{},{canWatch:true},{videoUrl:''},{videoUrl:'javascript:alert(1)'}]){
const access=chapterAccess(chapter);assert.equal(access.allowed,false);assert.equal(access.gate,'unavailable');assert.equal(initialPlayMode(access,url),'preview');
}
assert.equal(chapterAccess({needVip:true,videoUrl:url}).gate,'vip');
assert.equal(chapterAccess({payType:2,videoUrl:url}).gate,'vip');
assert.equal(chapterAccess(null).allowed,false);
assert.equal(chapterUnlockCost({costCoins:12,singleCoins:30}),12);assert.equal(chapterUnlockCost({costCoins:-1,singleCoins:30}),30);
assert.equal(initialPlayMode(chapterAccess({canWatch:true,videoUrl:url}),url),'main');
assert.equal(initialPlayMode(chapterAccess({canWatch:true}),url),'preview');
});
test('native HLS is preferred on Apple browsers while Chromium and shell browsers try MSE first',()=>{
@@ -274,7 +268,7 @@ test('switching routes during a native-to-MSE fallback cannot install an outdate
assert.equal(Hls.instances.length,0);assert.equal(f.video.src,'');assert.equal(f.errors,0);
});
function playerFixture({allowed=false,preview=true,unlock=async()=>({}),commentData={list:[],totalCount:0},detail={},chapterList,mobile=false,loadChapter}={}){
function playerFixture({allowed=false,preview=true,detail={},chapterList,mobile=false,loadChapter}={}){
const original=Object.fromEntries(['document','window','matchMedia'].map(key=>[key,globalThis[key]]));
const signal=new AbortController();setMaxListeners(30,signal.signal);
const page=new Element('MAIN'),box=new Element(),video=new Video(),overlay=new Element(),status=new Element(),lock=new Element('DIALOG');
@@ -285,8 +279,7 @@ function playerFixture({allowed=false,preview=true,unlock=async()=>({}),commentD
page.nodes.set('video',video);page.nodes.set('.play-screen',box);page.nodes.set('.play-source-status',status);
box.nodes.set('.play-screen-overlay',overlay);box.nodes.set('[data-media="toggle"]',new Element('BUTTON'));
box.nodes.set('[data-play-action="previous"]',new Element('BUTTON'));box.nodes.set('[data-play-action="next"]',new Element('BUTTON'));
for(const key of ['.play-episode-pages','.play-episode-grid','.play-comment-list','#play-comments-title','form'])node(page,key);
for(const key of ['.unlock-close','.unlock-pay','#episode-lock-balance','.unlock-cost','#episode-lock-error','#episode-lock-description','.unlock-recharge'])node(lock,key);
for(const key of ['.play-episode-pages','.play-episode-grid'])node(page,key);
page.lists.set('button[data-play-mode]',preview?[mainButton,previewButton]:[]);page.lists.set('[data-play-mode]',preview?[box,mainButton,previewButton]:[box]);
const doc=new EventTarget();doc.getElementById=id=>id==='drama-play-page'?page:lock;doc.createElement=tag=>new Element(tag.toUpperCase());
const browser=new EventTarget();browser.navigator=chrome;
@@ -294,15 +287,15 @@ function playerFixture({allowed=false,preview=true,unlock=async()=>({}),commentD
globalThis.document=doc;globalThis.window=browser;globalThis.matchMedia=()=>({matches:mobile});
const posts=[],navigations=[],notifications=[],getCalls=[];let routes=0,refreshes=0;
const chapters=chapterList||[{id:90001,currentEpisode:1},{id:90042,currentEpisode:2},{id:91200,currentEpisode:3}];
const chapter={id:90042,playletId:23456,currentEpisode:2,canWatch:allowed,needUnlock:!allowed,payType:1,costCoins:12,balance:90,videoUrl:'https://cdn.example/main.mp4'};
const chapter={id:90042,playletId:23456,currentEpisode:2,canWatch:allowed,needUnlock:!allowed,payType:1,costCoins:12,balance:90,...(allowed?{videoUrl:'https://cdn.example/main.mp4'}:{})};
const api={user:{recharge:90},get:async(path,query)=>{
getCalls.push({path,query});
if(path==='playlet/detail')return {id:23456,title:'测试剧集',preview:preview?'https://cdn.example/preview.mp4':'',playletChapterInfo:chapters,...detail};
if(path==='playlet/chapter/list')return {list:chapters};
if(path==='playlet/chapter/detail')return loadChapter?loadChapter(query):chapter;
if(path==='comment/list')return commentData;
if(path==='playlet/guess-like/list')return {list:[{id:31,title:'推荐短剧 <b>',totalEpisode:8,desc:'推荐简介',tagNames:['都市']}]};
throw Error(path);
},post:async(path,body)=>{posts.push({path,body});return path==='playlet/chapter/unlock'?unlock():{};},setUser(user){api.user=user;},refreshUser:async()=>{refreshes++;}};
},post:async(path,body)=>{posts.push({path,body});return {};},setUser(user){api.user=user;},refreshUser:async()=>{refreshes++;}};
const app={api,signal:signal.signal,config:{},home:{},notify:value=>notifications.push(value),navigate:path=>navigations.push(path),route:async()=>{routes++;}};
return {app,page,video,box,status,lock,posts,navigations,notifications,getCalls,mainButton,previewButton,controlActions,chapter,
get routes(){return routes;},get refreshes(){return refreshes;},
@@ -360,88 +353,40 @@ test('episode engagements update chapter counts and keep the numeric chapter wri
f.app.api.post=async(path,body)=>{f.posts.push({path,body});return null;};
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.ok(f.page.innerHTML.includes('>9收藏</span>'));
assert.ok(f.page.innerHTML.includes('>9收藏</dd>'));
f.click(controls.collect.button);await flush();
assert.deepEqual(f.posts.at(-1),{path:'playlet/chapter/collect/set',body:{chapterId:90042,isCollect:true}});
assert.equal(controls.collect.stat.textContent,'10收藏');assert.equal(f.chapter.countCollect,10);
}finally{dispose?.();f.dispose();}
});
test('comment likes update their local count after success without refetching the comment list',async()=>{
const f=playerFixture(),button=new Element('BUTTON'),label=new Element('SPAN');let dispose,resolve;
f.page.nodes.delete('video');button.dataset.commentLike='8';button.dataset.commentCount='9';button.setAttribute('aria-pressed','false');label.textContent='♡ 9';button.nodes.set('[data-request-label]',label);
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(yes=>resolve=yes);};
try{
dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.filter(call=>call.path==='comment/list').length;
f.click(button);f.click(button);assert.equal(f.posts.length,1);assert.equal(label.textContent,'请求中');assert.equal(button.disabled,true);
resolve(null);await flush();assert.equal(label.textContent,'♥ 10');assert.equal(button.getAttribute('aria-pressed'),'true');assert.equal(button.disabled,false);
f.click(button);assert.equal(f.posts.at(-1).body.isLike,false);resolve({countLike:9});await flush();assert.equal(label.textContent,'♡ 9');
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,reads);
}finally{dispose?.();f.dispose();}
});
test('comment submission displays sending feedback, blocks duplicate submits and retains text when the write fails',async()=>{
const f=playerFixture(),input=new Element('TEXTAREA'),button=new Element('BUTTON'),context=new Element(),error=new Element();let dispose,resolve,reject;
f.page.nodes.delete('video');button.textContent='发表评论';input.value='评论内容';
const form=f.page.querySelector('form');form.elements={namedItem:()=>input};form.nodes.set('[type="submit"]',button);f.page.nodes.set('#reply-context',context);f.page.nodes.set('#comment-error',error);
const submit=()=>{const event=new Event('submit',{cancelable:true});Object.defineProperty(event,'target',{value:form});form.dispatchEvent(event);};
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise((yes,no)=>{resolve=yes;reject=no;});};
try{
dispose=await renderPlayer(f.app,23456);await flush();
submit();submit();assert.equal(f.posts.length,1);assert.equal(button.textContent,'发送中');assert.equal(button.disabled,true);
reject(new Error('发送失败'));await flush();assert.equal(input.value,'评论内容');assert.equal(button.textContent,'发表评论');assert.equal(error.textContent,'发送失败');assert.equal(button.disabled,false);
submit();resolve({});await flush();assert.equal(input.value,'');assert.equal(button.textContent,'发表评论');assert.equal(button.disabled,false);assert.equal(error.textContent,'');
}finally{dispose?.();f.dispose();}
});
test('comment pagination uses page links and URL-provided page and sorting',async()=>{
const f=playerFixture({allowed:true,commentData:{list:[],totalCount:95}});let dispose;
const href=(page,sort)=>`/play/23456/90042/${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`;
try{
dispose=await renderPlayer(f.app,23456,90042,false,{page:3,commentSort:1,pageHref:href});await flush();
const request=f.getCalls.find(call=>call.path==='comment/list');
assert.equal(request.query.pageNum,3);assert.equal(request.query.sortOrder,1);assert.equal(request.query.pageSize,10);
const html=f.page.querySelector('.play-comment-list').innerHTML;
assert.ok(html.includes('href="/play/23456/90042/page/4/?commentSort=1"'));
assert.ok(!html.includes('data-comment-page'));assert.ok(!html.includes('data-page-number'));
assert.ok(f.page.innerHTML.includes('data-comment-sort="1" aria-pressed="true"'));
const sort=new Element('BUTTON');sort.dataset.commentSort='2';f.click(sort);await flush();
assert.equal(f.navigations.at(-1),'/play/23456/90042/');
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,1);
}finally{dispose?.();f.dispose();}
});
test('slug playback links survive client episode rendering, next controls, comments and confirmed unlocking with numeric write IDs',async()=>{
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']},commentData:{list:[],totalCount:25}});let dispose;
test('slug playback links survive client episode rendering and next controls, and recommendations replace comments',async()=>{
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']}});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.ok(f.page.innerHTML.includes('href="/detail/city-story/"'));
assert.ok(f.page.innerHTML.includes('href="/tag/urban/"'));
assert.ok(f.page.querySelector('.play-episode-grid').innerHTML.includes('href="/play/city-story/90042/"'));
assert.ok(f.page.querySelector('.play-comment-list').innerHTML.includes('href="/play/city-story/90042/page/2/"'));
assert.ok(f.page.querySelector('.play-episode-grid').innerHTML.includes('title="《测试剧集》第2集"'));
assert.ok(f.page.innerHTML.includes('<h2 id="hg-recommend-title">推荐视频</h2>'));assert.ok(f.page.innerHTML.includes('<article class="hg-vitem">'));
assert.ok(f.page.innerHTML.includes('推荐短剧 &lt;b&gt;'));assert.ok(f.page.innerHTML.includes('href="/detail/31/"'));
assert.ok(!f.page.innerHTML.includes('play-comment'));assert.ok(!f.getCalls.some(call=>call.path==='comment/list'));
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/guess-like/list').query,{playletId:23456,pageNum:1,pageSize:12});
const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush();
assert.equal(f.navigations.at(-1),'/play/city-story/91200/');
}finally{dispose?.();f.dispose();}
const paid=playerFixture({detail:{slug:'city-story'},chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId)});let stop;
paid.page.nodes.delete('video');
try{
stop=await renderPlayer(paid.app,23456);paid.click(lockedButton(90042));await flush();
paid.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(paid.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
assert.equal(paid.navigations.at(-1),'/play/city-story/90042/');
}finally{stop?.();paid.dispose();}
});
test('locked chapters offer only the free preview; choosing main opens confirmation and never auto-unlocks or reports preview views',async()=>{
test('chapters without a source offer the free preview; main shows a not-yet-available notice and never reports',async()=>{
const f=playerFixture();let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0);
await f.controlActions.toggle();assert.equal(f.video.plays,1);assert.equal(f.posts.length,0);
f.mainButton.dispatchEvent(new Event('click'));await flush();
assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main');
await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');
assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main');assert.equal(f.status.textContent,'本集视频暂未开放,请稍后再试');
await f.controlActions.toggle();await f.controlActions.retry();assert.equal(f.lock.open,false);assert.equal(f.posts.length,0);assert.equal(f.video.plays,1);
assert.ok(!f.box.querySelector('.play-screen-overlay').innerHTML.includes('data-show-unlock'));
}finally{dispose?.();f.dispose();}
});
@@ -460,18 +405,7 @@ test('allowed chapters play main despite stale lock flags; only main playback re
}finally{dispose?.();f.dispose();}
});
test('unlocking requires an explicit confirmation, prevents double submission, then refreshes account and chapter data',async()=>{
let finish;const f=playerFixture({preview:false,unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));
f.lock.close();await f.controlActions.toggle();pay.dispatchEvent(new Event('click'));
assert.equal(f.posts.length,1);assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
finish({canWatch:true});await flush();assert.equal(f.lock.open,false);assert.equal(f.refreshes,1);assert.equal(f.routes,1);
}finally{dispose?.();f.dispose();}
});
test('actual player controls delegate play and retry to the media engine and allow unlock clicks to reach the page',async()=>{
test('actual player controls delegate play and retry to the media engine',async()=>{
const source=await readFile(new URL('../public/scripts/site-runtime.js',import.meta.url),'utf8');
const start=source.indexOf('function installPlayerControls('),end=source.indexOf('/* ---- sticky-search-header-script ---- */',start);
const install=vm.runInNewContext(source.slice(start,end)+'\ninstallPlayerControls;', {document:{},emptyStateMarkup:()=>'<div>重试</div>'});
@@ -483,182 +417,61 @@ test('actual player controls delegate play and retry to the media engine and all
const click=(button)=>{const event=new Event('click');Object.defineProperty(event,'target',{value:button});let stopped=false;event.stopPropagation=()=>{stopped=true;};screen.dispatchEvent(event);return stopped;};
assert.equal(click(center),true);await flush();assert.equal(plays,1);assert.equal(video.plays,0);
const retry=new Element('BUTTON');retry.dataset.media='retry';click(retry);await flush();assert.equal(retries,1);assert.equal(video.loads,0);
const unlock=new Element('BUTTON');unlock.setAttribute('data-show-unlock','');assert.equal(click(unlock),false);
screen.dispatchEvent(new Event('playback:error'));assert.equal(center.hidden,true);assert.ok(overlay.innerHTML.includes('重试'));
});
test('detail overview keeps original action groups, actual chapter identifiers and styled comment replies',async()=>{
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'},commentData:{list:[{id:1,userName:'用户 <script>',text:'父评论',children:[{id:2,parentsId:1,text:'回复'}]}],totalCount:2}});let dispose;
test('detail overview keeps action groups, actual chapter identifiers and recommendations without comments',async()=>{
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'}});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);await flush();
assert.ok(f.page.innerHTML.includes('href="/play/23456/90001/"'));assert.ok(f.page.innerHTML.includes('series-action-bar'));
assert.ok(f.page.innerHTML.includes('href="/play/23456/90001/"'));assert.ok(f.page.innerHTML.includes('class="hg-detail-actions"'));
assert.ok(f.page.innerHTML.includes('接口简介'));assert.ok(f.page.innerHTML.includes('class="play-official"'));
assert.ok(!f.getCalls.some(call=>call.path==='playlet/chapter/detail'));
const comments=f.page.querySelector('.play-comment-list').innerHTML;
assert.ok(comments.includes('class="play-comment-body"'));assert.ok(comments.includes('class="play-comment-replies"'));
assert.ok(comments.includes('play-comment-item play-comment-child'));assert.ok(comments.includes('用户 &lt;script&gt;'));
assert.ok(f.page.innerHTML.includes('class="hg-recommend"'));assert.ok(!f.page.innerHTML.includes('play-comment'));
const like=new Element('BUTTON');like.dataset.engage='like';like.nodes.set('span',new Element());
f.click(like);await flush();assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:true}});
assert.equal(like.querySelector('span').textContent,'已点赞');
}finally{dispose?.();f.dispose();}
});
test('episode segments keep their anchor across desktop/mobile layout changes and show original lock and playing markers',async()=>{
test('episode segments keep their anchor across desktop/mobile layout changes, link every episode and show no paywall markers',async()=>{
const chapterList=Array.from({length:130},(_,index)=>({id:90001+index,currentEpisode:index+1,canWatch:index<10,payType:index<10?0:1}));
const f=playerFixture({allowed:true,chapterList});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
const grid=f.page.querySelector('.play-episode-grid'),segments=f.page.querySelector('.play-episode-pages');
assert.ok(segments.innerHTML.includes('class="episode-page-track"'));assert.ok(grid.innerHTML.includes('class="episode-playing"'));
assert.ok(grid.innerHTML.includes('class="coin-soft-star"'));
const tab=new Element('BUTTON');tab.dataset.episodePage='2';f.click(tab);await flush();assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));
for(const text of ['coin-soft-star','episode-lock-corner','episode-vip-mark','play-episode-locked','付费'])assert.ok(!grid.innerHTML.includes(text),text);
const tab=new Element('BUTTON');tab.dataset.episodePage='2';f.click(tab);await flush();assert.ok(grid.innerHTML.includes('href="/play/23456/90091/"'));
globalThis.matchMedia=()=>({matches:true});window.dispatchEvent(new Event('resize'));
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(!grid.innerHTML.includes('data-locked-chapter="90106"'));
assert.ok(grid.innerHTML.includes('href="/play/23456/90091/"'));assert.ok(!grid.innerHTML.includes('href="/play/23456/90106/"'));
globalThis.matchMedia=()=>({matches:false});window.dispatchEvent(new Event('resize'));
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(grid.innerHTML.includes('data-locked-chapter="90130"'));
assert.ok(grid.innerHTML.includes('href="/play/23456/90091/"'));assert.ok(grid.innerHTML.includes('href="/play/23456/90130/"'));
}finally{dispose?.();f.dispose();}
});
test('episode list gating follows reference permissions independently of video URLs',()=>{
for(const chapter of [{payType:0},{payType:1,isUnlocked:true},{canWatch:true,needUnlock:true,needVip:true,payType:1}])assert.equal(chapterListGate(chapter),'');
for(const chapter of [{needUnlock:true},{payType:1},{isUnlocked:true,needUnlock:true}])assert.equal(chapterListGate(chapter),'coins');
for(const chapter of [{needVip:true},{payType:2},{needVip:true,isUnlocked:true}])assert.equal(chapterListGate(chapter),'vip');
});
const lockedList=[{id:90001,currentEpisode:1,canWatch:true},{id:90042,currentEpisode:2,needUnlock:true,payType:1,singleCoins:30},{id:91200,currentEpisode:3,needUnlock:true,payType:1,singleCoins:30}];
const lockedChapter=(id=90042,extra={})=>({id,playletId:23456,currentEpisode:id===90042?2:3,needUnlock:true,payType:1,costCoins:12,singleCoins:30,balance:90,...extra});
const lockedButton=id=>{const button=new Element('BUTTON');button.dataset.lockedChapter=String(id);return button;};
test('clicking a coin episode on detail opens the original dialog, fetches live pricing, and waits for confirmation before navigating',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:async()=>({balance:78})});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);await flush();
const grid=f.page.querySelector('.play-episode-grid').innerHTML;
assert.ok(grid.includes('class="play-episode-locked" data-locked-chapter="90042"'));assert.ok(!grid.includes('href="/play/23456/90042/"'));
f.click(lockedButton(90042));assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);
f.click(lockedButton(90042));await flush();
assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/chapter/detail').query,{playletId:23456,chapterId:90042});
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'90 金币');
assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 2 集/);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(f.posts,[{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}}]);
assert.equal(f.app.api.user.recharge,78);assert.equal(f.refreshes,1);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
}finally{dispose?.();f.dispose();}
});
test('a late chapter response cannot replace the newer episode selected for payment',async()=>{
const pending=new Map();
const f=playerFixture({chapterList:lockedList,loadChapter:query=>new Promise(resolve=>pending.set(query.chapterId,resolve))});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));f.click(lockedButton(91200));
pending.get(91200)(lockedChapter(91200,{costCoins:9}));await flush();
pending.get(90042)(lockedChapter(90042,{costCoins:99}));await flush();
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'9 金币');assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 3 集/);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:91200,playletId:23456}});assert.deepEqual(f.navigations,['/play/23456/91200/']);
}finally{dispose?.();f.dispose();}
});
test('closing a pending dialog or leaving the route prevents stale requests from reopening or submitting it',async()=>{
for(const leave of [false,true]){
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:()=>new Promise(resolve=>finish=resolve)});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));
if(leave)f.dispose();else f.lock.close();
finish(lockedChapter());await flush();
assert.equal(f.lock.open,false);f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
}finally{dispose?.();f.dispose();}
}
});
test('already permitted and VIP chapters follow fresh access results instead of charging coins',async()=>{
for(const [data,path] of [[lockedChapter(90042,{canWatch:true}),'/play/23456/90042/'],[lockedChapter(90042,{needUnlock:false,needVip:true,payType:2}),'/info/deposit/vip/']]){
const f=playerFixture({chapterList:lockedList,loadChapter:()=>data});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.open,false);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[path]);
}finally{dispose?.();f.dispose();}
}
});
test('insufficient displayed balance offers recharge and never sends an unlock request',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:3})});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'金币不足,去充值');
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.equal(f.posts.length,0);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/info/deposit/']);
}finally{dispose?.();f.dispose();}
});
test('mismatched chapter information leaves payment disabled without navigating or charging',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(91200)});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);assert.match(f.lock.querySelector('#episode-lock-error').textContent,/不匹配/);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
}finally{dispose?.();f.dispose();}
});
test('payment errors remain visible and retryable; balance and auth errors follow reference recovery',async()=>{
const {ApiError}=await import(protocolUrl);
for(const [error,path] of [[new Error('网络暂不可用'),null],[new ApiError('金币余额不足',400),'/info/deposit/'],[new ApiError('登录已过期',1000),'login']]){
let attempts=0,authPrompts=0;
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(),unlock:async()=>{attempts++;if(attempts===1)throw error;return {balance:78};}});let dispose;f.page.nodes.delete('video');f.app.openAuth=()=>authPrompts++;
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));await flush();
assert.equal(f.refreshes,0);
if(path==='login'){assert.equal(authPrompts,1);assert.equal(f.lock.open,false);}
else if(path){assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,[path]);}
else{
assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('#episode-lock-error').textContent,error.message);assert.equal(f.lock.querySelector('#episode-lock-error').hidden,false);assert.equal(pay.disabled,false);
pay.dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,2);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
}
}finally{dispose?.();f.dispose();}
}
});
test('a pending payment cannot be repeated or retargeted to a different episode',async()=>{
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));f.click(lockedButton(91200));await flush();
const cancel=new Event('cancel',{cancelable:true});f.lock.dispatchEvent(cancel);
assert.equal(cancel.defaultPrevented,true);assert.equal(f.lock.querySelector('.unlock-close').disabled,true);
assert.equal(f.posts.length,1);assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
finish({balance:78});await flush();assert.deepEqual(f.navigations,['/play/23456/90042/']);assert.equal(f.lock.open,false);
}finally{dispose?.();f.dispose();}
});
test('server player data excludes credentials and denied sources while retaining free episodes, comments and artwork',()=>{
test('server player data excludes credentials while keeping every supplied episode source, recommendations and artwork',()=>{
const locked={id:90042,currentEpisode:2,countLike:27,countCollect:8,countComment:3,ratio:0.5625,seoInfo:{title:'章节独立标题'},canWatch:false,needUnlock:true,payType:1,videoUrl:'https://cdn.example/secret-locked.mp4',token:'chapter-secret'};
const free={id:90001,currentEpisode:1,canWatch:true,videoUrl:'https://cdn.example/free.mp4'};
const initial=playerInitialData({id:23456,title:'公开剧名',cover:'https://cdn.example/cover.webp',token:'detail-secret',reportSdkConfig:{secret:'sdk-secret'},advitise:{nineGridList:[{id:'a',images:'https://cdn.example/ad.webp',title:'广告',link:'https://example.com',secret:'ad-secret'}]},relatedRecommendData:[{id:7,title:'相关剧集',token:'related-secret'}]},[free,locked],locked,{list:[{id:1,text:'评论正文',token:'comment-secret',children:[{id:2,text:'回复'}]}],totalCount:12});
const initial=playerInitialData({id:23456,title:'公开剧名',cover:'https://cdn.example/cover.webp',token:'detail-secret',reportSdkConfig:{secret:'sdk-secret'},advitise:{nineGridList:[{id:'a',images:'https://cdn.example/ad.webp',title:'广告',link:'https://example.com',secret:'ad-secret'}]},relatedRecommendData:[{id:7,title:'相关剧集',token:'related-secret'}]},[free,locked],locked);
const json=JSON.stringify(initial);
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','comment-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret'])assert.ok(!json.includes(value),value);
assert.equal(initial.chapter.videoUrl,'https://cdn.example/secret-locked.mp4','payment flags no longer hide a supplied source');
assert.equal(initial.chapters[0].videoUrl,free.videoUrl);assert.equal(initial.chapter.canWatch,false);assert.equal(initial.chapter.needUnlock,true);
assert.equal(initial.chapter.countLike,27);assert.equal(initial.chapter.ratio,0.5625);assert.equal(initial.chapter.seoInfo.title,'章节独立标题');
assert.equal(initial.comments.list[0].text,'评论正文');assert.equal(initial.comments.list[0].children[0].text,'回复');assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
assert.equal(initial.comments,undefined);assert.equal(initial.detail.relatedRecommendData[0].title,'相关剧集');assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
});
test('player attaches to server markup without repeating initial reads and still verifies coin pricing on click',async()=>{
test('player attaches to server markup without repeating initial reads',async()=>{
const f=playerFixture();let dispose;
const locked={...f.chapter,videoUrl:undefined};
const initial={detail:{id:23456,title:'首屏详情'},chapters:[{id:90001,currentEpisode:1,canWatch:true},locked],chapter:null,comments:{list:[{id:1,text:'首屏评论'}],totalCount:1}};
const initial={detail:{id:23456,title:'首屏详情',relatedRecommendData:[{id:31,title:'首屏推荐'}]},chapters:[{id:90001,currentEpisode:1,canWatch:true},locked],chapter:null};
try{
f.page.innerHTML='<article>首屏详情</article>';f.page.querySelector('.play-comment-list').innerHTML='<p>首屏评论</p>';
f.page.innerHTML='<article>首屏详情</article>';
dispose=await renderPlayer(f.app,23456,undefined,false,{initial,preserveMarkup:true});await flush();
assert.equal(f.getCalls.length,0);assert.equal(f.page.innerHTML,'<article>首屏详情</article>');assert.equal(f.page.querySelector('.play-comment-list').innerHTML,'<p>首屏评论</p>');
f.click(lockedButton(90042));await flush();
assert.equal(f.getCalls.length,1);assert.equal(f.getCalls[0].path,'playlet/chapter/detail');assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
assert.equal(f.getCalls.length,0);assert.equal(f.page.innerHTML,'<article>首屏详情</article>');
assert.equal(f.posts.length,0);
}finally{dispose?.();f.dispose();}
});
+2 -2
View File
@@ -45,9 +45,9 @@ test('cache bounds and endpoint policies keep private data and indefinite growth
assert.equal(publicCachePolicy('config/info','0').ttl,0);assert.equal(publicCachePolicy('playlet/home','999999').ttl,30000);
});
test('shared browser snapshot contains visible configuration and footer fields without SDK or unknown secrets',()=>{
test('shared browser snapshot contains visible configuration and footer fields without unknown secrets',()=>{
const data=publicSiteData({reportSdkConfig:'private-sdk-cipher',token:'private-token',config:{officialAppAddr:'https://app.test/download',telegram:'https://t.me/business',apiKey:'private-api-key'},advitise:{dialogList:[{id:'a',title:'广告',images:'https://ad.test/image',link:'https://ad.test',token:'private-ad-token'}],unknown:[{secret:'private-slot'}]}},{footerInfo:{brand:{content:'真实页脚'},contactList:[{name:'服务时间',value:'每天',token:'private-contact-token'}]}});
const text=JSON.stringify(data);for(const secret of ['private-sdk-cipher','private-token','private-api-key','private-ad-token','private-slot','private-contact-token'])assert.ok(!text.includes(secret));
assert.equal(data.trackerConfigured,true);assert.equal(data.config.config.officialAppAddr,'https://app.test/download');assert.equal(data.footer.footerInfo.brand.content,'真实页脚');
assert.ok(!('trackerConfigured' in data));assert.equal(data.config.config.officialAppAddr,'https://app.test/download');assert.equal(data.footer.footerInfo.brand.content,'真实页脚');
assert.doesNotThrow(()=>publicSiteData({config:null,advitise:null},{footerInfo:null}));
});
+21 -54
View File
@@ -17,7 +17,7 @@ const renderCode=ts.transpileModule(await readFile(new URL('../src/lib/client/re
const renderUrl='data:text/javascript;base64,'+Buffer.from(renderCode.replace("'../routing'",JSON.stringify(routingModuleUrl)).replace('"../api/protocol"',JSON.stringify(protocolUrl))).toString('base64');
const searchCode=ts.transpileModule(await readFile(new URL('../src/lib/client/search.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const search=await import('data:text/javascript;base64,'+Buffer.from(searchCode.replace("'../routing'",JSON.stringify(routingModuleUrl)).replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl))).toString('base64'));
const infoCode=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const infoCode=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText.replace('"./site"',JSON.stringify('data:text/javascript;base64,'+Buffer.from(ts.transpileModule(await readFile(new URL('../src/lib/site.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText).toString('base64')));
const infoPages=await import('data:text/javascript;base64,'+Buffer.from(infoCode).toString('base64'));
const siteNavigationCode=ts.transpileModule(await readFile(new URL('../src/lib/site-navigation.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
const applicationFile=new URL('../src/lib/client/application.ts',import.meta.url);
@@ -51,8 +51,6 @@ function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用
if(specifier==='./config')return {configureSite:async()=>{}};
if(specifier==='./hero-swipe')return {bindHeroSwipe(){}};
if(specifier==='./promo-marquee')return {bindPromotionMarquee:root=>{calls.push({path:'bindPromotionMarquee',root});return ()=>calls.push({path:'disposePromotionMarquee',root});}};
if(specifier==='./account')return {renderMember:async(...args)=>calls.push({path:'renderMember',args}),renderWallet:async(...args)=>calls.push({path:'renderWallet',args})};
if(specifier==='./feedback')return {renderFeedbackPage:(...args)=>calls.push({path:'renderFeedbackPage',args})};
if(specifier==='./player')return {renderPlayer:async(_app,id,chapterId,isVideo,options)=>{calls.push({path:'renderPlayer',id,chapterId,isVideo,...options});return ()=>{};}};
if(specifier==='./render')return {escape:String,empty:message=>`<p>${message}</p>`,loading:()=>'<p>加载中</p>',activeBanners:()=>[],topicCard:()=>'<a href="/topics/1/">专题</a>',renderHome:()=>'<div data-home-loaded>接口首页</div>',card:(item,video,options)=>{cardCalls.push({item,video,options});return '<a href="/detail/1/">剧集</a>';},pagination:(_,page)=>`<span>第 ${page} 页</span>`};
return {};
@@ -64,7 +62,7 @@ function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用
}
test('all native pages bind every square ad row and clean up old loops before rebinding',async()=>{
for(const pathname of ['/','/shorts/','/detail/7/','/play/7/8/','/member/favorites/','/info/deposit/']){
for(const pathname of ['/','/shorts/','/detail/7/','/play/7/8/','/info/about-us/']){
const rows=[{name:'first row'},{name:'second row'}],f=fixture(undefined,{promotionRows:rows});
f.location.pathname=pathname;await f.app.route();
assert.deepEqual(f.calls.filter(call=>call.path==='bindPromotionMarquee').map(call=>call.root),rows,pathname);
@@ -74,38 +72,13 @@ test('all native pages bind every square ad row and clean up old loops before re
}
});
test('about uses business Telegram and the feedback group while retaining configured service hours',()=>{
const page=infoPages.getInfoPage('about-us',{config:{telegram:'https://t.me/business_team',tgGroup:'https://t.me/feedback_group',gmailAddr:'ignored@example.com',customerServiceHost:'https://ignored.example.com'}},[
{name:'商务合作',value:'business@example.com'},{name:'用户反馈',value:'mailto:support@example.com'},
{name:'服务时间',value:'周一至周五 09:00–18:00'},{name:'邮箱',value:'ignored@example.com'},
]);
assert.ok(page.html.includes('商务合作:<a href="https://t.me/business_team"'));
assert.ok(page.html.includes('用户反馈:<a href="https://t.me/feedback_group"'));
assert.ok(page.html.includes('<p>服务时间:周一至周五 09:00–18:00</p>'));
assert.ok(!page.html.includes('mailto:'));assert.ok(!page.html.includes('@example.com'));assert.ok(!page.html.includes('ignored.example.com'));
});
test('Telegram contacts prefer site configuration, fall back to address configuration and hide absent channels',()=>{
const html=infoPages.infoContactSection({config:{telegram:'',tgGroup:''},newAddressInfo:{telegram:'@business_team',tgGroup:'https://t.me/feedback_group'}},null);
assert.ok(html.includes('商务合作:<a href="https://t.me/business_team"'));assert.ok(html.includes('用户反馈:<a href="https://t.me/feedback_group"'));
assert.ok(!html.includes('服务时间'));
const preferred=infoPages.infoContactSection({config:{telegram:'https://t.me/primary'},newAddressInfo:{telegram:'https://t.me/fallback'}});
assert.ok(preferred.includes('t.me/primary'));assert.ok(!preferred.includes('t.me/fallback'));assert.ok(!preferred.includes('用户反馈'));
assert.ok(infoPages.infoContactSection({config:{gmailAddr:'help@example.com',customerServiceHost:'https://ignored.example.com'}}).includes(' hidden'));
assert.ok(infoPages.infoContactSection({config:{telegramAdd:'https://t.me/legacy'}}).includes('t.me/legacy'));
});
test('configured contacts escape text and reject executable or malformed addresses',()=>{
const html=infoPages.infoContactSection({},[
{name:'<img onerror=alert(1)>',value:'<script>alert(1)</script>'},
{name:'注入',value:'javascript:alert(1)'},
{name:'数据链接',value:'data:text/html,<script>alert(1)</script>'},
{name:'正常链接',value:'https://example.com/?a=1&b=2'},null,
]);
assert.ok(html.includes('&lt;script&gt;'));assert.ok(html.includes('&lt;img onerror=alert(1)&gt;'));
assert.ok(!html.includes('<script'));assert.ok(!html.includes('javascript:'));assert.ok(!html.includes('data:text'));
assert.ok(html.includes('href="https://example.com/?a=1&amp;b=2" target="_blank" rel="sponsored nofollow noopener noreferrer"'));
assert.ok(infoPages.infoContactSection({config:{gmailAddr:'javascript:bad@example.com'}}).includes(' hidden'));
test('about and DMCA show only the official TG contact, ignoring configured channels, footer contacts and email',()=>{
const about=infoPages.getInfoPage('about-us').html;
assert.ok(about.includes('官方TG:<a href="https://t.me/jbd125"'));assert.ok(about.includes('>@jbd125</a>'));
for(const text of ['商务合作','用户反馈','服务时间','mailto:','gmail'])assert.ok(!about.includes(text),text);
const dmca=infoPages.getInfoPage('dmca').html;
assert.ok(dmca.includes('href="https://t.me/jbd125"'));assert.ok(!dmca.includes('mailto:'));assert.ok(!dmca.includes('gmail'));
assert.equal(infoPages.infoContactSection(),infoPages.infoContactSection());
});
test('ranking data and native page links derive from the server route and preserve global positions',()=>{
@@ -132,19 +105,13 @@ test('initial server channel and search content is retained without page request
}
});
test('server player data binds once with its comment route and never refetches the initial content',async()=>{
const player={detail:{id:23456,title:'剧集'},chapters:[],chapter:null,comments:{list:[],totalCount:0}};
const path='/play/23456/90001/page/2/',f=fixture(()=>assert.fail('no page fetch'),{page:{path:path+'?commentSort=1',panel:'player',title:'剧集',player}});
f.location.pathname=path;f.location.search='?commentSort=1';await f.app.route();
assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'renderPlayer');assert.equal(f.calls[0].initial,player);assert.equal(f.calls[0].preserveMarkup,true);assert.equal(f.calls[0].page,2);assert.equal(f.calls[0].commentSort,1);
assert.equal(f.calls[0].pageHref(3,1),'/play/23456/90001/page/3/?commentSort=1');await f.app.route();assert.equal(f.calls.length,1);assert.equal(f.reloads.length,1);
});
test('private and feedback pages only bind interactions to their prepared snapshots',async()=>{
for(const [path,info,handler] of [['/member/settings/',{key:'settings',member:{user:{userId:7}}},'renderMember'],['/info/deposit/',{key:'deposit',wallet:{user:{userId:7},packages:{coinList:[]},transactions:{list:[]}}},'renderWallet'],['/info/feedback/',{key:'feedback',feedback:true},'renderFeedbackPage']]){
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'info',title:'完整页面',info}});f.location.pathname=path;await f.app.route();
assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,handler);assert.equal(f.calls[0].args.at(-1),true);
}
test('server player data binds once and never refetches the initial content',async()=>{
const player={detail:{id:23456,title:'剧集'},chapters:[],chapter:null};
const path='/play/23456/90001/',f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'player',title:'剧集',player}});
f.location.pathname=path;await f.app.route();
assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'renderPlayer');assert.equal(f.calls[0].initial,player);assert.equal(f.calls[0].preserveMarkup,true);
assert.equal(f.calls[0].page,undefined);assert.equal(f.calls[0].commentSort,undefined);
await f.app.route();assert.equal(f.calls.length,1);assert.equal(f.reloads.length,1);
});
test('missing or mismatched snapshots leave server error HTML visible without a client retry',async()=>{
@@ -153,21 +120,21 @@ test('missing or mismatched snapshots leave server error HTML visible without a
});
test('client configuration preserves the server content metadata and canonical',async()=>{
const path='/topics/7/',environment=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://canonical.drama.test'}}),content={title:'服务端专题',description:'服务端简介',totalCount:72,pageSize:24};
const path='/topics/7/',environment=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://huangguocg.com'}}),content={title:'服务端专题',description:'服务端简介',totalCount:72,pageSize:24};
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'channel',title:'服务端专题'},seo:{environment,content,path}});f.location.pathname=path;await f.app.route();
await f.app.applyConfig({config:{foreverUrlAddr:'https://other.drama.test'}});
assert.equal(f.document.title,'服务端专题 - 91短剧');assert.equal(f.document.value('link[rel="canonical"]'),'https://canonical.drama.test/topics/7/');
assert.equal(f.document.title,'服务端专题 - 黄果吃瓜');assert.equal(f.document.value('link[rel="canonical"]'),'https://huangguocg.com/topics/7/');
});
test('shared server configuration initializes downloads and contacts without refetching page configuration or footer',async()=>{
const shared={config:{config:{officialAppAddr:'https://app.test/download'}},footer:{footerInfo:{contactList:[{name:'服务时间',value:'每天'}]}},trackerConfigured:false};
const shared={config:{config:{officialAppAddr:'https://app.test/download'}},footer:{footerInfo:{contactList:[{name:'服务时间',value:'每天'}]}}};
const f=fixture(()=>assert.fail('public settings already arrived in HTML'),{shared});
await f.app.initializeShared();assert.equal(f.app.config.config.officialAppAddr,'https://app.test/download');assert.equal(f.calls.length,0);
});
test('public back/forward documents keep working without reload; interactive private pages revalidate as fresh documents',async()=>{
test('public back/forward documents keep working without reload; interactive player pages revalidate as fresh documents',async()=>{
const f=fixture();f.location.pathname='/shorts/';f.app.hidePage(true);assert.equal(f.app.signal.aborted,false);
f.app.restorePage();assert.equal(f.reloads.length,0);assert.deepEqual(f.calls.map(item=>item.path),['restore-session']);
const privatePage=fixture();privatePage.location.pathname='/info/deposit/';privatePage.app.hidePage(true);assert.equal(privatePage.app.signal.aborted,true);
const privatePage=fixture();privatePage.location.pathname='/play/7/8/';privatePage.app.hidePage(true);assert.equal(privatePage.app.signal.aborted,true);
privatePage.app.restorePage();assert.equal(privatePage.reloads.length,1);
});
+5 -5
View File
@@ -15,9 +15,9 @@ const {ads,promotionRow,renderHome,activeBanners,hero,card,topicCard,imageAttrib
const drama={id:23456,title:'正常剧集 <script>alert(1)</script>',cover:'https://example.com/poster.webp',tagNames:['都市']};
const advert={id:'ad-one',title:'有效广告',images:'https://example.com/ad.webp',link:'https://example.com/landing'};
test('topic cards include escaped metadata for content exposure and click tracking',()=>{
test('topic cards escape their names and carry no tracking attributes',()=>{
const html=topicCard({id:7,name:'都市 <甜宠>',icon:'https://example.com/topic.png'});
assert.match(html,/data-track-item data-item-id="7" data-item-type="topic" data-item-name="都市 &lt;甜宠&gt;"/);
assert.ok(html.includes('<h3>都市 &lt;甜宠&gt;</h3>')||html.includes('都市 &lt;甜宠&gt;'));assert.doesNotMatch(html,/data-track|data-item-/);
});
test('API images use data sources and local placeholders until decrypted, including video posters',()=>{
@@ -43,8 +43,8 @@ test('shared square ad rows keep real ad data and links and omit empty placement
const html=promotionRow([advert,{...advert,id:'ad-two',title:'第二个广告'}]);
assert.ok(html.includes('class="promo-marquee" data-promotion-row'));
assert.equal((html.match(/class="promo-tile"/g)||[]).length,2);
assert.ok(html.includes('data-ad-id="ad-one"'));assert.ok(html.includes('data-ad-id="ad-two"'));
assert.ok(html.includes('href="https://example.com/landing"'));assert.ok(html.includes('data-ad-type="icon"'));
assert.equal((html.match(/data-ad href=/g)||[]).length,2);assert.doesNotMatch(html,/data-track|data-ad-/);
assert.ok(html.includes('href="https://example.com/landing"'));
assert.ok(html.includes('width="132" height="132"'));assert.ok(html.includes('alt="有效广告"'));
for(const value of [null,{},[],[null]])assert.equal(promotionRow(value),'');
});
@@ -77,7 +77,7 @@ test('dynamic home sections share a grid without inheriting the prototype editor
for(const id of [31,32,34])assert.ok(html.includes(`href="/config/${id}/"`));
for(const id of [23456,2,3])assert.ok(html.includes(`href="/detail/${id}/"`));
assert.ok(!html.includes('href="/video/4/"'));assert.ok(!html.includes('href="/config/33/"'));assert.ok(html.includes('href="/topics/8/"'));
assert.equal((html.match(/class="drama-episodes"/g)||[]).length,1);
assert.equal((html.match(/class="hg-card-eps"/g)||[]).length,1);
assert.ok(html.indexOf('必看好剧')<html.indexOf('热播榜'));
});
+3 -3
View File
@@ -11,11 +11,11 @@ async function moduleUrl(file,transform=code=>code){
const protocol=await moduleUrl('../src/lib/api/protocol.ts'),auth=await moduleUrl('../src/lib/client/auth.ts');
const apiUrl=await moduleUrl('../src/lib/client/api.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocol)).replace('"./auth"',JSON.stringify(auth)));
const {ApiClient}=await import(apiUrl);
const startupUrl='data:text/javascript,export const calls=[];export function startTracker(){calls.push("tracker")}export function startApplication(...args){calls.push(args)}';
const startupUrl='data:text/javascript,export const calls=[];export function startApplication(...args){calls.push(args)}';
const startup=await import(startupUrl);
const scriptUrl='data:text/javascript,export default function Script(){}';
const runtimeUrl=await moduleUrl('../src/components/site/SiteRuntime.tsx',code=>{
const imports={'react/jsx-runtime':import.meta.resolve('react/jsx-runtime'),'next/script':scriptUrl,'@/lib/client/application':startupUrl,'@/lib/client/analytics':startupUrl};
const imports={'react/jsx-runtime':import.meta.resolve('react/jsx-runtime'),'next/script':scriptUrl,'@/lib/client/application':startupUrl};
for(const [path,url] of Object.entries(imports))for(const quote of ["'",'"'])code=code.replace(quote+path+quote,JSON.stringify(url));
return code;
});
@@ -63,7 +63,7 @@ test('home, player, navigation and wallet hydration starts normally without logg
for(const page of [{path:'/',panel:'home'},{path:'/detail/7/',player:{title:'短剧'}},{path:'/info/deposit/page/2/',panel:'info',info:{key:'deposit',wallet}}]){
const start=startup.calls.length;
SiteRuntime({home,homeLoaded:true,page,navigation}).props.onReady();
assert.equal(startup.calls[start],'tracker');assert.equal(startup.calls[start+1][0],home);assert.equal(startup.calls[start+1][3],page);
assert.equal(startup.calls.length,start+1);assert.equal(startup.calls[start][0],home);assert.equal(startup.calls[start][3],page);
}
assert.equal(wallet.user.token,'session-secret');
});
+7 -9
View File
@@ -32,11 +32,8 @@ test('clean paths select the matching page, IDs, and navigation section',()=>{
assert.deepEqual(parseSitePath('/shorts/'),{group:'page',key:'shorts'});
assert.deepEqual(parseSitePath('/topics/31/'),{group:'page',key:'topics',value:'31'});
assert.deepEqual(parseSitePath('/play/23456/90001/'),{group:'play',key:'23456',value:'90001'});
assert.deepEqual(parseSitePath('/info/deposit/vip/'),{group:'info',key:'deposit',value:'vip'});
assert.deepEqual(parseSitePath('/member/history/'),{group:'member',key:'history'});
assert.deepEqual(parseSitePath('/member/likes/'),{group:'member',key:'likes'});
assert.deepEqual(parseSitePath('/member/likes/page/2/'),{group:'member',key:'likes',page:2});
for(const [path,section] of [['/','home'],['/shorts/','shorts'],['/topics/31/','topics'],['/detail/7/','shorts'],['/member/center/','']]){
for(const path of ['/info/deposit/','/info/deposit/vip/','/info/user-center/','/info/settings/','/info/feedback/','/member/center/','/member/history/','/member/likes/page/2/'])assert.equal(parseSitePath(path),null,path);
for(const [path,section] of [['/','home'],['/shorts/','shorts'],['/topics/31/','topics'],['/detail/7/','shorts'],['/info/about-us/','']]){
assert.equal(navigationSection(parseSitePath(path)),section,path);
}
});
@@ -86,9 +83,10 @@ test('hash page links are not converted or treated as page destinations',()=>{
test('only recognized internal page destinations are handled by the application',()=>{
const base='https://drama.example/detail/7';
assert.equal(internalPagePath('/shorts/',base),'/shorts/');
assert.equal(internalPagePath('https://drama.example/member/history/',base),'/member/history/');
assert.equal(internalPagePath('https://drama.example/rank/new/',base),'/rank/new/');
assert.equal(internalPagePath('https://drama.example/member/history/',base),null);
assert.equal(internalPagePath('/search/'+encodeURIComponent('爱情 / 逆袭')+'/',base),'/search/'+encodeURIComponent('爱情 / 逆袭')+'/');
for(const href of ['https://external.example/shorts','javascript:alert(1)','/api/app/playlet/home','/assets/poster.webp','#comments','/unknown','/play/7/','/shorts/extra/','/member/unknown/','/detail/0/']){
for(const href of ['https://external.example/shorts','javascript:alert(1)','/api/app/playlet/home','/assets/poster.webp','#comments','/unknown','/play/7/','/shorts/extra/','/member/history/','/detail/0/']){
assert.equal(internalPagePath(href,base),null,href);
}
});
@@ -99,8 +97,8 @@ test('page navigation requests a complete server document while leaving hashes,
try{
for(const path of ['#comments','/#page/shorts','https://external.example/shorts','/series/'])navigateTo(path);
assert.deepEqual(calls,[]);
for(const path of ['/play/23456/90001/','/member/history/','/rank/new/','/shorts/page/2/?sort=2'])navigateTo(path);
assert.deepEqual(calls,['/play/23456/90001/','/member/history/','/rank/new/','/shorts/page/2/?sort=2']);
for(const path of ['/play/23456/90001/','/info/about-us/','/rank/new/','/shorts/page/2/?sort=2'])navigateTo(path);
assert.deepEqual(calls,['/play/23456/90001/','/info/about-us/','/rank/new/','/shorts/page/2/?sort=2']);
assert.equal(globalThis.window.location.href,'https://drama.example/shorts/','the old document stays visible until the new response arrives');
}finally{globalThis.window=previous;}
});
+2 -2
View File
@@ -103,10 +103,10 @@ test('search suggestions open the returned drama while hot keywords open search
for(const value of [null,{},[null]])assert.ok(searchSuggestionsMarkup(value,'词').includes('暂无搜索建议'));
});
test('dropdown recommendations keep compact cards, real detail links and tracking without full-page metadata',()=>{
test('dropdown recommendations keep compact cards, real detail links without full-page metadata',()=>{
const html=searchPicksMarkup([null,...Array.from({length:4},(_,index)=>({...drama,id:7+index,slug:'story-'+index,isNew:true,desc:'简介 <script>',countLike:99}))]);
assert.equal((html.match(/class="search-pick"/g)||[]).length,3);
assert.ok(html.includes('href="/detail/story-0/"'));assert.ok(html.includes('data-track-item data-item-id="7"'));
assert.ok(html.includes('href="/detail/story-0/"'));assert.ok(!html.includes('data-track-item'));
assert.ok(html.includes('class="search-pick-cover"'));assert.ok(html.includes('width="160" height="240"'));
assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));assert.ok(html.includes('alt="SEO 标题封面"'));
assert.ok(html.includes('简介 &lt;script&gt;'));assert.ok(html.includes('cover-corner-badge--new'));assert.ok(html.includes('12集'));
+31 -35
View File
@@ -2,7 +2,7 @@ import {test} from 'node:test';
import assert from 'node:assert/strict';
import {seo,clientSeo,headDocument} from './helpers/seo.mjs';
const config={seoInfo:{title:'全站标题',description:'全站描述',keywords:['全站词'],webSiteName:'91剧场'},config:{foreverUrlAddr:'https://drama.example.net/entry/?channel=1'}};
const config={seoInfo:{title:'全站标题',description:'全站描述',keywords:['全站词'],webSiteName:'91剧场'},config:{foreverUrlAddr:'https://huangguocg.com/entry/?channel=1'}};
const environment=seo.seoEnvironment(config);
const page=(path,content={},search='')=>seo.buildPageHead(path,search,environment,content);
@@ -14,13 +14,13 @@ test('path-based search metadata retains keywords and page numbers without becom
assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);
});
test('normalizes legacy/string SEO values and uses the permanent site origin',()=>{
test('normalizes legacy/string SEO values and always uses the site domain as origin',()=>{
assert.deepEqual(seo.normalizeSeo(JSON.stringify({webTitle:' 标题 ',webDescription:' 简介 ',keyworld:'都市,甜宠;都市',webSiteName:'91'})),{title:'标题',description:'简介',keywords:['都市','甜宠'],siteName:'91'});
assert.equal(environment.origin,'https://drama.example.net');
assert.equal(environment.origin,'https://huangguocg.com');
for(const value of ['',null,'""','{bad',[],12])assert.equal(seo.normalizeSeo(value).title,'');
for(const value of ['javascript:alert(1)','https://user:secret@host.test','localhost:3000','https://example.com/path','https://www.xx.xxx/'])assert.equal(seo.siteOrigin(value),'',value);
assert.equal(seo.seoEnvironment({config:{foreverUrlAddr:'https://example.com'},newAddressInfo:{foreverUrlAddr:'drama.example.org/entry'}}).origin,'https://drama.example.org');
assert.equal(seo.seoEnvironment({}).origin,'');
assert.equal(seo.seoEnvironment({config:{foreverUrlAddr:'https://example.com'},newAddressInfo:{foreverUrlAddr:'drama.example.org/entry'}}).origin,'https://huangguocg.com');
assert.equal(seo.seoEnvironment({}).origin,'https://huangguocg.com');
});
test('home uses global SEO, independent route fields survive, and copied homepage fields fall back to page-specific content',()=>{
@@ -49,29 +49,29 @@ test('navigation TDK has priority on category routes and does not replace drama
const home=seo.buildPageHead('/','',seo.seoEnvironment(config,[{urlValue:'https://external.test/',seoInfo:{title:'外站'}}]));assert.equal(home.title,'全站标题 - 91剧场');
});
test('configuration failures and missing permanent origins retain the last valid SEO environment',()=>{
test('configuration failures retain the last valid SEO identity while the origin stays the site domain',()=>{
const previous=seo.resolveSeoEnvironment(config,[{urlValue:'/shorts/',seoInfo:{title:'短剧分类'}}]);
for(const broken of [{},{seoInfo:{webSiteName:'错误品牌'}},{config:{foreverUrlAddr:'javascript:bad'}},{config:{foreverUrlAddr:'http://localhost:3000'}}]){
assert.equal(seo.resolveSeoEnvironment(broken,undefined,previous),previous);
for(const broken of [{},{config:{foreverUrlAddr:'javascript:bad'}},{config:{foreverUrlAddr:'http://localhost:3000'}}]){
const resolved=seo.resolveSeoEnvironment(broken,undefined,previous);
assert.equal(resolved.origin,'https://huangguocg.com');assert.deepEqual(resolved.site,previous.site);assert.deepEqual(resolved.categories,previous.categories);
const head=seo.buildPageHead('/shorts/page/2/','',seo.resolveSeoEnvironment(broken,undefined,previous),{totalCount:72});
assert.equal(head.canonical,'https://drama.example.net/shorts/page/2/');assert.equal(head.siteName,'91剧场');
assert.equal(head.canonical,'https://huangguocg.com/shorts/page/2/');assert.equal(head.siteName,'91剧场');
assert.equal(head.jsonLd['@graph'].find(item=>item['@type']==='Organization').name,'91剧场');
}
const partial=seo.resolveSeoEnvironment({config:config.config},undefined,previous);
assert.equal(partial.site.siteName,'91剧场');assert.equal(partial.site.title,'全站标题');assert.deepEqual(partial.categories,previous.categories);
const updated=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://new.drama.test/path/'},seoInfo:{webSiteName:'新站名'}},[],previous);
assert.equal(updated.origin,'https://new.drama.test');assert.equal(updated.site.siteName,'新站名');assert.deepEqual(updated.categories,[]);
assert.equal(updated.origin,'https://huangguocg.com');assert.equal(updated.site.siteName,'新站名');assert.deepEqual(updated.categories,[]);
});
test('cold starts never guess a canonical and the browser preserves the server identity',()=>{
test('cold starts use the default site domain and the browser preserves the server identity',()=>{
const cold=seo.resolveSeoEnvironment({},[]);
assert.equal(cold.origin,'');assert.equal(cold.site.siteName,'91短剧');
assert.equal(cold.origin,'https://huangguocg.com');assert.equal(cold.site.siteName,'黄果吃瓜');
const head=seo.buildPageHead('/shorts/','',cold,{totalCount:72});
assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);assert.equal(head.next,undefined);
assert.equal(head.canonical,'https://huangguocg.com/shorts/');
assert.equal(seo.clientSeoEnvironment(environment,{config:{foreverUrlAddr:'https://other.drama.test'},seoInfo:{webSiteName:'其他品牌'}}),environment);
assert.equal(seo.clientSeoEnvironment(environment,{}),environment);
const recovered=seo.clientSeoEnvironment(cold,config);
assert.equal(recovered.origin,environment.origin);assert.equal(recovered.site.siteName,environment.site.siteName);
assert.equal(seo.clientSeoEnvironment(cold,config),cold);
const entity=page('/detail/9/',{title:'作品',seoInfo:{webSiteName:'作品接口的其他品牌'}});
assert.equal(entity.siteName,'91剧场');assert.ok(entity.title.endsWith(' - 91剧场'));
assert.equal(entity.jsonLd['@graph'].find(item=>item['@type']==='Organization').name,entity.siteName);
@@ -79,12 +79,12 @@ test('cold starts never guess a canonical and the browser preserves the server i
test('canonical is query-free, every pagination page identifies itself, and first/middle/last page links are bounded',()=>{
const first=page('/shorts',{totalCount:72,pageSize:24},'?channel=test#fragment');
assert.equal(first.canonical,'https://drama.example.net/shorts/');assert.equal(first.previous,undefined);assert.equal(first.next,'https://drama.example.net/shorts/page/2/?channel=test');
assert.equal(first.canonical,'https://huangguocg.com/shorts/');assert.equal(first.previous,undefined);assert.equal(first.next,'https://huangguocg.com/shorts/page/2/?channel=test');
const middle=page('/shorts/page/2/',{totalCount:72,pageSize:24});
assert.equal(middle.canonical,'https://drama.example.net/shorts/page/2/');assert.equal(middle.previous,'https://drama.example.net/shorts/');assert.equal(middle.next,'https://drama.example.net/shorts/page/3/');
assert.equal(middle.canonical,'https://huangguocg.com/shorts/page/2/');assert.equal(middle.previous,'https://huangguocg.com/shorts/');assert.equal(middle.next,'https://huangguocg.com/shorts/page/3/');
assert.match(middle.title,/第2页/);assert.match(middle.description,/第2页/);assert.ok(middle.keywords.includes('第2页'));
const last=page('/shorts/page/3/',{totalCount:72,pageSize:24});assert.equal(last.next,undefined);assert.equal(last.previous,'https://drama.example.net/shorts/page/2/');
assert.equal(page('/shorts/page/1/',{totalCount:72}).canonical,'https://drama.example.net/shorts/');
const last=page('/shorts/page/3/',{totalCount:72,pageSize:24});assert.equal(last.next,undefined);assert.equal(last.previous,'https://huangguocg.com/shorts/page/2/');
assert.equal(page('/shorts/page/1/',{totalCount:72}).canonical,'https://huangguocg.com/shorts/');
const overflow=page('/shorts/page/4/',{totalCount:72,pageSize:24});assert.equal(overflow.canonical,undefined);assert.match(overflow.robots,/noindex/);assert.equal(overflow.previous,undefined);
for(const totalCount of [0,1,24]){const head=page('/shorts/',{totalCount});assert.equal(head.previous,undefined);assert.equal(head.next,undefined);}
const center=page('/member/center/',{totalCount:72});assert.equal(center.previous,undefined);assert.equal(center.next,undefined);
@@ -93,7 +93,7 @@ test('canonical is query-free, every pagination page identifies itself, and firs
test('private/tools/search/error pages do not inherit public canonical or indexability',()=>{
for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/unknown-page/']){const head=page(path);assert.match(head.robots,/noindex,follow/);assert.equal(head.canonical,undefined,path);}
for(const path of ['/member/center/','/member/likes/','/member/likes/page/2/','/member/favorites/page/2/','/info/deposit/','/info/feedback/'])assert.equal(page(path).robots,'noindex,nofollow',path);
for(const path of ['/member/center/','/member/likes/','/info/deposit/','/info/settings/']){const head=page(path);assert.equal(head.robots,'noindex,follow',path);assert.equal(head.canonical,undefined,path);}
assert.match(page('/shorts/',{},'?sort=2').robots,/noindex/);assert.equal(page('/shorts/',{},'?sort=2').canonical,undefined);
for(const content of [{failed:true},{missing:true},{unavailable:true}])assert.equal(page('/detail/9/',content).canonical,undefined);
for(const path of ['/info/about-us/','/info/privacy-policy/','/rank/hot/','/rank/new/','/rank/follow/'])assert.ok(page(path).canonical,path);
@@ -105,7 +105,7 @@ test('random shorts metadata matches the selected feed and remains excluded from
assert.equal(head.robots,'noindex,follow');assert.equal(head.canonical,undefined);
});
test('client navigation updates SSR head once and clears old canonical/OG URL/pagination after private or failed routes',()=>{
test('client navigation updates SSR head once and clears old canonical/OG URL/pagination after tool or missing routes',()=>{
const doc=headDocument();
const first=page('/shorts/page/2/',{totalCount:72});clientSeo.applyPageHead(first,doc);
const duplicate=doc.createElement('link');duplicate.setAttribute('rel','canonical');doc.head.appendChild(duplicate);
@@ -114,10 +114,10 @@ test('client navigation updates SSR head once and clears old canonical/OG URL/pa
assert.equal(doc.title,last.title);assert.equal(doc.value('meta[property="og:site_name"]'),'91剧场');
assert.equal(doc.value('link[rel="canonical"]'),last.canonical);assert.equal(doc.value('meta[property="og:url"]'),last.canonical);
assert.equal(doc.value('link[rel="next"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),last.previous);
const privatePage=page('/info/deposit/');clientSeo.applyPageHead(privatePage,doc);
const toolPage=page('/home-road/');clientSeo.applyPageHead(toolPage,doc);
assert.equal(doc.value('link[rel="canonical"]'),undefined);assert.equal(doc.value('meta[property="og:url"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),undefined);
assert.equal(doc.value('meta[name="robots"]'),'noindex,nofollow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,nofollow');
assert.equal(doc.value('meta[name="twitter:title"]'),privatePage.title);
assert.equal(doc.value('meta[name="robots"]'),'noindex,follow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,follow');
assert.equal(doc.value('meta[name="twitter:title"]'),toolPage.title);
});
@@ -141,7 +141,7 @@ test('topic aliases and numeric ID variants share a primary canonical without ch
assert.equal(page('/search/007/').canonical,undefined);
});
test('empty public lists and comment pagination are noindex while preserving successful and error statuses',()=>{
test('empty public lists are noindex, detail and play pages are indexable and have no paginated variants',()=>{
for(const path of ['/shorts/','/topics/','/topics/7/','/theme/7/','/tag/9/','/rank/hot/']){
const content={totalCount:0},head=page(path,content);
assert.equal(head.robots,'noindex,follow',path);assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);
@@ -152,11 +152,8 @@ test('empty public lists and comment pagination are noindex while preserving suc
const drama=seo.dramaSeoContent({id:9,title:'作品名',desc:'作品简介'});
const playable=seo.dramaSeoContent({id:9,title:'作品名'},{id:90,currentEpisode:3,videoUrl:'https://media.example.net/3.mp4'});
for(const [path,data] of [['/detail/9/',drama],['/play/9/90/',playable]]){
assert.ok(page(path,{...data,totalCount:0,pageSize:10}).robots.startsWith('index,'),path);
const content={...data,totalCount:35,pageSize:10},paged=page(path+'page/2/',content);
assert.equal(seo.pageResponseStatus(path+'page/2/',content),200);
assert.equal(paged.robots,'noindex,follow');
for(const key of ['canonical','previous','next','jsonLd'])assert.equal(paged[key],undefined,key);
assert.ok(page(path,data).robots.startsWith('index,'),path);
assert.equal(seo.pageResponseStatus(path+'page/2/',data),404,path);
}
});
@@ -174,17 +171,16 @@ test('more snapshots need visible collection information and actual items before
assert.deepEqual(topics.items,[{path:'/topics/7/'}]);assert.ok(page('/config/31/',topics).canonical);
});
test('episode indexing requires an actual authorized source and episode identity, not metadata or a shared preview',()=>{
test('episode indexing requires an actual source and episode identity, not metadata or a shared preview, and ignores payment flags',()=>{
const data={id:9,title:'作品名',desc:'作品简介',preview:'https://media.example.net/shared-preview.mp4'};
const chapter={id:90,currentEpisode:3,videoUrl:'https://media.example.net/3.mp4'};
for(const patch of [{videoUrl:''},{videoUrl:'javascript:bad'},{currentEpisode:0},{currentEpisode:undefined},{canWatch:false,needUnlock:true},{canWatch:false,needVip:true}]){
for(const patch of [{videoUrl:''},{videoUrl:'javascript:bad'},{currentEpisode:0},{currentEpisode:undefined}]){
const content=seo.dramaSeoContent(data,{...chapter,...patch,seoInfo:{title:'单集标题',description:'单集描述'}});
const head=page('/play/9/90/',content);
assert.equal(head.robots,'noindex,follow');assert.equal(head.jsonLd,undefined);assert.equal(seo.pageResponseStatus('/play/9/90/',content),200);
if(patch.needUnlock||patch.needVip)assert.equal(content.entity.video.source,undefined);
}
for(const patch of [{},{payType:1,canWatch:true},{payType:2,canWatch:true}])assert.ok(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,...patch})).canonical);
for(const patch of [{},{payType:1,canWatch:true},{payType:2,canWatch:true},{canWatch:false,needUnlock:true},{canWatch:false,needVip:true}])assert.ok(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,...patch})).canonical);
const doc=headDocument();clientSeo.applyPageHead(page('/play/9/90/',seo.dramaSeoContent(data,chapter)),doc);
clientSeo.applyPageHead(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,needUnlock:true})),doc);
clientSeo.applyPageHead(page('/play/9/90/',seo.dramaSeoContent(data,{...chapter,videoUrl:''})),doc);
assert.equal(doc.value('meta[name="robots"]'),'noindex,follow');assert.equal(doc.value('link[rel="canonical"]'),undefined);
});
+9 -8
View File
@@ -18,7 +18,7 @@ const render=await import(renderUrl);
const channel=await import(await moduleUrl('client/channel.ts',{'../api/protocol':protocol,'./render':renderUrl}));
const search=await import(await moduleUrl('client/search.ts',{'../api/protocol':protocol,'./render':renderUrl}));
const drama={id:9,slug:'city-story',title:'都市故事',desc:'作品介绍',cover:'https://media.drama.test/poster.webp',onlineAt:'2026-10-20'};
const environment=seo.seoEnvironment({config:{foreverUrlAddr:'https://drama.test'}});
const environment=seo.seoEnvironment({config:{foreverUrlAddr:'https://huangguocg.com'}});
test('slug-first paths encode one segment once, preserve ID fallback and accept direct paginated requests',()=>{
for(const key of ['city-story','city_story-2026','dushiaiqing']){
@@ -26,6 +26,7 @@ test('slug-first paths encode one segment once, preserve ID fallback and accept
const path=routes.contentPath(kind,{id:9,slug:' '+key+' '});
assert.equal(path,`/${kind}/${encodeURIComponent(key)}/`);
assert.equal(routes.parseSitePath(path)[kind==='detail'?'key':'value'],key);
if(kind==='detail'){assert.equal(routes.parseSitePath(path+'page/2/'),null);continue;}
assert.equal(routes.paginationPath(path,2),path+'page/2/');
assert.equal(routes.parseSitePath(path+'page/2/').page,2);
}
@@ -45,13 +46,13 @@ test('Chinese upstream slugs use IDs in every generated link and SEO URL without
assert.ok(render.blockSection({id:31,slug:'必看好剧',name:'必看好剧',type:1,status:true,list:[item]}).includes('href="/config/31/"'));
const chapter={id:90,currentEpisode:3,canWatch:true,videoUrl:'https://media.drama.test/3.mp4'};
const head=seo.buildPageHead('/play/9/90/','',environment,seo.dramaSeoContent(item,chapter));
assert.equal(head.canonical,'https://drama.test/play/9/90/');
assert.equal(head.canonical,'https://huangguocg.com/play/9/90/');
assert.ok(!JSON.stringify(head.jsonLd).includes('%E9%83%BD'));
assert.equal(routes.matchesContentKey(item,'9'),true);
assert.deepEqual(routes.contentQuery('9'),{id:9});
// Previously issued Chinese slug links remain readable, but their canonical uses the ID.
assert.equal(routes.matchesContentKey(item,'都市爱情'),true);
assert.equal(seo.buildPageHead('/detail/都市爱情/','',environment,seo.dramaSeoContent(item)).canonical,'https://drama.test/detail/9/');
assert.equal(seo.buildPageHead('/detail/都市爱情/','',environment,seo.dramaSeoContent(item)).canonical,'https://huangguocg.com/detail/9/');
});
test('cards, carousel, topics, more lists and suggestions prefer slug while explicit moreUrl remains authoritative',()=>{
@@ -96,15 +97,15 @@ test('canonical, pagination, series/episode schema and homepage ItemList use the
const content=seo.dramaSeoContent(drama,chapter);
for(const key of ['9','city-story']){
const head=seo.buildPageHead(`/play/${key}/90/`,'',environment,content);
assert.equal(head.canonical,'https://drama.test/play/city-story/90/');
assert.equal(head.canonical,'https://huangguocg.com/play/city-story/90/');
const graph=head.jsonLd['@graph'];
assert.equal(graph.find(node=>node['@type']==='TVSeries').url,'https://drama.test/detail/city-story/');
assert.equal(graph.find(node=>node['@type']==='TVSeries').url,'https://huangguocg.com/detail/city-story/');
assert.equal(graph.find(node=>node['@type']==='TVEpisode').url,head.canonical);
assert.equal(graph.find(node=>node['@type']==='BreadcrumbList').itemListElement[2].item,'https://drama.test/detail/city-story/');
assert.equal(graph.find(node=>node['@type']==='BreadcrumbList').itemListElement[2].item,'https://huangguocg.com/detail/city-story/');
}
const list=seo.listSeoContent({id:7,slug:'city-collection',list:[drama],totalCount:72},{group:'page',key:'theme',value:'7',page:2});
const head=seo.buildPageHead('/theme/7/page/2/','',environment,list);
assert.equal(head.canonical,'https://drama.test/topics/city-collection/page/2/');assert.equal(head.previous,'https://drama.test/topics/city-collection/');
assert.equal(head.canonical,'https://huangguocg.com/topics/city-collection/page/2/');assert.equal(head.previous,'https://huangguocg.com/topics/city-collection/');
const home=seo.buildPageHead('/','',environment,seo.homeSeoContent({homeBannerData:[drama]}));
assert.equal(home.jsonLd['@graph'].find(node=>node['@type']==='ItemList').itemListElement[0].url,'https://drama.test/detail/city-story/');
assert.equal(home.jsonLd['@graph'].find(node=>node['@type']==='ItemList').itemListElement[0].url,'https://huangguocg.com/detail/city-story/');
});
+6 -7
View File
@@ -218,7 +218,7 @@ test('image loading supports cached images, errors, timeouts and cancellation',a
}finally{if(previous===undefined)delete globalThis.window;else globalThis.window=previous;}
});
test('dialog controls enforce age confirmation, advance ads on Escape, track clicks, and use the reference exit',async()=>{
test('dialog controls enforce age confirmation, advance ads on Escape, emit no tracking events, and use the reference exit',async()=>{
class Dialog extends FakeElement {
open=false;nodes=new Map();
showModal(){this.open=true;}
@@ -244,14 +244,14 @@ test('dialog controls enforce age confirmation, advance ads on Escape, track cli
assert.equal(age.open,true);assert.equal(popup.open,false);
const cancelAge=new Event('cancel',{cancelable:true});age.dispatchEvent(cancelAge);assert.equal(cancelAge.defaultPrevented,true);assert.equal(age.open,true);
enter.click();assert.equal(age.open,false);assert.equal(popup.open,true);assert.equal(media.href,'https://example.com/first');assert.equal(store.getItem('hg_age_ok'),'1');
media.click();assert.deepEqual(tracks,['show','click']);assert.equal(popup.open,true);
media.click();assert.equal(popup.open,true);
const cancelAd=new Event('cancel',{cancelable:true});popup.dispatchEvent(cancelAd);await flush();
assert.equal(cancelAd.defaultPrevented,true);assert.equal(media.href,'https://example.com/second');assert.equal(popup.open,true);
close.click();assert.equal(popup.open,false);assert.equal(store.getItem('dialogAdDismissed'),'2|first|second');
assert.deepEqual(tracks,['show','click','show']);
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);assert.equal(media.dataset.adPlacement,'startup-ad');
assert.deepEqual(tracks,[]);
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);
const link=promotions.children[0];assert.equal(link.href,'https://example.com/app');
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.equal(link.dataset.adType,'icon');assert.equal(link.dataset.trackAd,'external');assert.equal(link.dataset.adPlacement,'startup-grid');
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.deepEqual({...link.dataset},{});
assert.equal(link.children[0].alt,'app');assert.equal(link.children[1].textContent,'app');
const keyboard=new Event('keydown');keyboard.key='Tab';grid.dispatchEvent(keyboard);assert.equal(grid.classList.contains('startup-keyboard-focus'),true);
grid.dispatchEvent(new Event('pointerdown'));assert.equal(grid.classList.contains('startup-keyboard-focus'),false);
@@ -268,8 +268,7 @@ test('dialog controls enforce age confirmation, advance ads on Escape, track cli
};
controller.setAdvertisements([],[ad('broken'),{...ad('no-link'),link:'javascript:alert(1)',title:'<script>inert text</script>',advertiseCode:'grid-code',advertiseLocationCode:'slot-3',advertiseLocationName:'应用推广',location:3},ad('last')]);await flush();
assert.equal(grid.open,true);assert.equal(promotions.children.length,2);assert.equal(decoded.length,3);
const noLink=promotions.children[0];assert.equal(noLink.hasAttribute('href'),false);assert.equal(noLink.dataset.adClickable,'0');
assert.equal(noLink.dataset.adId,'grid-code');assert.equal(noLink.dataset.adSlotKey,'slot-3');assert.equal(noLink.dataset.adSlotName,'应用推广');
const noLink=promotions.children[0];assert.equal(noLink.hasAttribute('href'),false);assert.deepEqual({...noLink.dataset},{});
assert.equal(noLink.children[0].alt,'<script>inert text</script>');assert.equal(noLink.children[1].textContent,'<script>inert text</script>');
assert.equal(promotions.children[1].href,'https://example.com/last');
const inertClick=new Event('click');Object.defineProperty(inertClick,'target',{value:noLink.children[0]});promotions.dispatchEvent(inertClick);assert.equal(grid.open,true);
+9 -10
View File
@@ -4,7 +4,7 @@ import {readFile} from 'node:fs/promises';
import ts from 'typescript';
import {seo,structuredData,clientSeo,headDocument} from './helpers/seo.mjs';
const origin='https://drama.example.net';
const origin='https://huangguocg.com';
const environment=seo.seoEnvironment({seoInfo:{webSiteName:'91剧场',description:'本站介绍'},config:{foreverUrlAddr:origin,tgGroup:'https://t.me/official',twitterAddr:'https://x.com/official',telegram:'javascript:bad'}});
const head=(path,content={},search='')=>seo.buildPageHead(path,search,environment,content);
const nodes=(page,type)=>page.jsonLd?.['@graph'].filter(node=>node['@type']===type)||[];
@@ -21,7 +21,7 @@ test('home schema uses this site, actual assets, configured official profiles an
const organization=nodes(page,'Organization')[0],website=nodes(page,'WebSite')[0];
assert.equal(organization.name,'91剧场');assert.equal(organization.url,origin+'/');
assert.equal(organization.logo.url,origin+'/apple-touch-icon.png');assert.equal(organization.logo.width,180);
assert.deepEqual(organization.sameAs,['https://x.com/official','https://t.me/official']);
assert.deepEqual(organization.sameAs,['https://t.me/jbd125']);
assert.equal(website.publisher['@id'],organization['@id']);assert.equal(website.description,'本站介绍');
assert.equal(nodes(page,'BreadcrumbList').length,0);
const items=nodes(page,'ItemList')[0];assert.equal(items.numberOfItems,4);
@@ -53,13 +53,13 @@ test('detail describes the series; episode watch pages connect TVEpisode and Vid
assert.equal(paged.robots,'noindex,follow');assert.equal(paged.jsonLd,undefined);
});
test('missing media data and invalid dates do not produce fabricated VideoObject fields or leak locked playback URLs',()=>{
test('missing media data and invalid dates do not produce fabricated VideoObject fields, and payment flags never hide a supplied source',()=>{
for(const patch of [{onlineAt:undefined},{onlineAt:'0001-01-01'},{onlineAt:'not-a-date'},{cover:''},{cover:'javascript:bad'}]){
const page=head('/play/9/90/',seo.dramaSeoContent({...drama,...patch},chapter));
assert.equal(nodes(page,'VideoObject').length,0);assert.equal(nodes(page,'TVEpisode').length,1);
}
const locked=head('/play/9/90/',seo.dramaSeoContent(drama,{...chapter,canWatch:false,needUnlock:true}));
assert.equal(locked.robots,'noindex,follow');assert.equal(locked.jsonLd,undefined);
assert.ok(locked.robots.startsWith('index,'));assert.equal(nodes(locked,'VideoObject').length,1);
const earlier=head('/play/9/90/',seo.dramaSeoContent({...drama,mediaUpdatedAt:'2025-10-17 01:00:00'},chapter));
assert.equal(nodes(earlier,'VideoObject')[0].dateModified,undefined);
const missingName=head('/play/9/90/',seo.dramaSeoContent({...drama,title:''},chapter));assert.equal(nodes(missingName,'VideoObject').length,0);
@@ -69,13 +69,12 @@ test('missing media data and invalid dates do not produce fabricated VideoObject
assert.equal(structuredData.schemaDate('2024/02/29'),'2024-02-29T00:00:00+08:00');
});
test('paywalled playable chapters identify the restricted region and normalize canonical media IDs',()=>{
test('all chapters are marked free and normalize canonical media IDs',()=>{
const paid=head('/play/009/090/',seo.dramaSeoContent(drama,{...chapter,payType:1}));
const page=nodes(paid,'WebPage')[0],video=nodes(paid,'VideoObject')[0],episode=nodes(paid,'TVEpisode')[0];
assert.equal(paid.canonical,origin+'/play/9/90/');
assert.equal(page.isAccessibleForFree,false);
assert.deepEqual(page.hasPart,{'@type':'WebPageElement',isAccessibleForFree:false,cssSelector:'.play-paywalled-content'});
assert.equal(video.isAccessibleForFree,false);assert.equal(episode.isAccessibleForFree,false);
assert.equal(page.isAccessibleForFree,undefined);assert.equal(page.hasPart,undefined);
assert.equal(video.isAccessibleForFree,true);assert.equal(episode.isAccessibleForFree,true);
assert.equal(video['@id'],origin+'/play/9/90/#video');assert.equal(episode.url,paid.canonical);
const hashed=head('/play/9/90/',seo.dramaSeoContent(drama,{...chapter,videoUrl:'signed/media.m3u8?token=a%2Fb'}));
assert.equal(nodes(hashed,'VideoObject')[0].contentUrl,origin+'/h5/m3u8/signed/media.m3u8?token=a%2Fb');
@@ -89,11 +88,11 @@ test('removed standalone video routes have no canonical or structured data',()=>
});
test('about and FAQ use visible local content, while private/search/tools/failed pages output no graph',async()=>{
const code=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const code=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText.replace('"./site"',JSON.stringify('data:text/javascript;base64,'+Buffer.from(ts.transpileModule(await readFile(new URL('../src/lib/site.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText).toString('base64')));
const info=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
const about=head('/info/about-us/',seo.infoSeoContent(info.getInfoPage('about-us')));assert.equal(nodes(about,'AboutPage').length,1);
const faqPage=info.getInfoPage('help-center'),content=seo.infoSeoContent(faqPage),page=head('/info/help-center/',content);
assert.equal(nodes(page,'FAQPage')[0].mainEntity.length,6);
assert.equal(nodes(page,'FAQPage')[0].mainEntity.length,5);
for(const question of nodes(page,'FAQPage')[0].mainEntity){assert.ok(faqPage.html.includes(question.name));assert.ok(question.acceptedAnswer.text);}
for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/member/favorites/','/info/deposit/','/unknown/'])assert.equal(head(path,content).jsonLd,undefined,path);
assert.equal(head('/shorts/',content,'?sort=2').jsonLd,undefined);assert.equal(head('/detail/9/',{...content,failed:true}).jsonLd,undefined);