# BrowserTV TV仓库站点规则

TV仓库用同一套声明式规则描述站点：规则只包含数据，BrowserTV 的公共列表、虚拟化、焦点、
搜索、播放器与缓存流程负责真正的显示、解析和播放。新增站点不需要改动 App 代码。

配套文件：

- [`browsertv-tv-site.schema.json`](browsertv-tv-site.schema.json)：JSON Schema（字段级说明写在 `description`）。
- [`browsertv-tv-site.example.json`](browsertv-tv-site.example.json)：可复制修改的完整示例。

服务器在 Schema 之外还会校验字段白名单、URL、域名、占位符、重复 key、列表数量与文本长度；
保存失败时返回错误码和字段路径，按本页说明修正即可。

## 一、站点对象

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `schema` | **是** | 固定为 `browsertv-tv-site-v1`。 |
| `key` | **是** | 站点稳定标识，只允许小写字母、数字和连字符，首字符必须是字母。显示名变化不要改 key。 |
| `name` | **是** | 电视端显示名称，最长 64 字符。 |
| `version` | **是** | 从 1 开始的整数。替换规则时必须大于仓库当前版本；启停不需要改版本。 |
| `homepage` | **是** | HTTPS 首页 URL，主机名必须命中 `hosts`。 |
| `hosts` | **是** | 1~16 个小写 DNS 主机名，不带协议、端口、路径和通配符。客户端按域名边界匹配，`bad-example.com` 不匹配 `example.com`。 |
| `runtime` | **是** | 运行声明对象，包含 `script`。 |
| `runtime.script` | **是** | 声明式模板对象；受信任核心站点可为空对象 `{}`。 |

可选顶层字段：

- `enabled`：是否进入客户端公开清单，缺省 `true`；管理员可单独启停。
- `description`：站点说明，最长 240 字符。
- `sourceId`：与既有来源配置对齐的兼容标识。
- `tags`：最多 20 个不重复标签，每个最长 32 字符。
- `capabilities`：`list`、`search`、`detail`、`playback`、`recommendation`、`account`、`danmaku`、`subtitle`，只声明真实支持的能力。
- `display`：`title`（≤64）、`order`（-10000~10000）、`theme`（`system`/`dark`/`light`）、`accent`、`listColumns`（3~6，缺省走全局四列）、`coverAspect`（`landscape`/`portrait`）。
- `configurations`：最多 32 项站点特有配置。每项 `key`、`label`、`type` 必填，`type` 为 `text`、`url`、`boolean`、`select`；`select` 必须给 `options`；`default` 类型必须匹配。用户在电视上填写的内容保存在本地，规则文件不放凭据。
- `examples`：最多 32 个站内入口示例。每项 `key`、`label`、`url` 必填，URL 必须属于 `hosts`。

## 二、`runtime`

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `runtime.adapter` | 否 | `bilibili`、`youtube` 表示使用 App 内的受信任公共适配器；`rules` 表示完全由本规则驱动。省略时等同 `rules`。 |
| `runtime.script` | **是** | 站点行为模板，见下文。核心站点写 `{}`。 |

`runtime.script` 字段：

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `sourceId` | 否 | 数据来源标识，缺省取 `key`；用于列表缓存键与统一 provider 注册。 |
| `detailPattern` | 否 | 详情页路径的正则，例如 `^/video/\d+$`。命中时按详情模板解析播放地址。 |
| `idPattern` | 否 | 从当前路径提取 ID 的正则，必须至少包含一个捕获组；结果供 `{{id}}` 请求占位符使用。 |
| `list` | 条件必填 | 列表模板；`adapter` 为 `rules` 时至少要声明 `list`、`search`、`detail` 之一。 |
| `search` | 否 | 搜索模板，结构与 `list` 相同。 |
| `detail` | 否 | 详情与播放源模板。 |
| `related` | 否 | 详情页相关条目与演员/分类抽取器，继续使用列表条目字段映射。 |
| `recommendation` | 否 | 推荐来源声明；当前支持 `type: "recombee"`，通过共享 provider 请求和分页。 |
| `playback` | 否 | 播放参数：`preferAvc`（优先 AVC 编码）、`playerFrames`（跨域播放器 iframe 选择器）。 |
| `danmaku` | 否 | 弹幕模板：`request` + `items`。 |
| `lite` | 否 | 轻量页面模式：`pathPattern` 命中时走简化页面外壳。 |
| `coverHosts` | 否 | 允许持久化缓存封面的域名白名单。 |
| `requestHosts` | 否 | 规则发起的请求（列表、详情、播放器页）除站点自身 `hosts` 外还允许访问的域名白名单。 |

## 三、请求与响应模板

`request`：

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `url` | **是** | HTTPS URL 模板，可含占位符。 |
| `method` | 否 | `GET`（默认）或 `POST`。 |
| `headers` | 否 | 普通请求头；`Cookie`、`Authorization`、`X-Api-Key` 等认证头会被拒绝。 |
| `body` | 否 | POST 请求体对象。 |

URL 占位符：`{{page}}`、`{{keyword}}`、`{{category}}`、`{{cursor}}`、`{{detailUrl}}`、`{{id}}`、`{{origin}}`、
`{{field.<命名取值>}}`（取 `detail.fields` 里的命名取值）、
以及 `{{config.<配置键>}}`（取电视上填写的站点配置值）。占位符值会自动做 URL 编码。

`response`：

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `type` | **是** | `html` 或 `json`。 |
| `root` | 否 | JSON 响应的根路径；`items.path` 缺省时使用该路径。 |

## 四、列表与搜索模板

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `request` | **是** | 请求模板。 |
| `items` | **是** | 条目模板，见下。 |
| `response` | 否 | 响应模板，缺省按 HTML 解析。 |
| `tabs` | 否 | 页签数组，每项 `key`、`title`、`url` 必填。 |
| `defaultTab` | 否 | 默认页签 key。 |
| `categoryTabKey` | 否 | 分类页签的 key。 |
| `categories` | 否 | 分类抽取器（`selector` + `attribute` + `titleSelector`/`titleAttribute`）。 |
| `next` | 否 | 下一页地址抽取器；URL 含 `{{page}}` 时可省略，客户端自动递增。 |
| `pageStart` | 否 | 首页页码，默认 1。 |
| `keywordSource` | 否 | 关键词来源：`query`（查询参数）、`path`（路径段）、`hash`。 |

`items`：

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `fields` | **是** | 字段映射，至少一项，键名匹配 `[a-z][a-zA-Z0-9_]*`。 |
| `selector` | 否 | HTML 响应的条目选择器。 |
| `path` | 否 | JSON 响应的条目数组路径。 |
| `limit` | 否 | 单页最多读取的条目数（1~10000）。 |

常用字段名：`url`（条目链接，必填或配合 `itemId`）、`title`、`cover`、`duration`、`publishedAt`、
`ownerName`、`viewCount`、`category`、`itemId`。

## 五、抽取器（Extractor）

抽取器用于读取一个值，HTML 响应走 `selector`，JSON 响应走 `path`：

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `selector` | 条件必填 | HTML 选择器。 |
| `path` | 条件必填 | JSON 路径，支持 `a.b[0].c`；`a.b[]` 表示遍历数组并把结果摊平。 |
| `attribute` | 否 | 读取的属性名，缺省读取文本。 |
| `transform` | 否 | 见下表，缺省 `text`。 |
| `options` | 否 | 变换参数：`regex` 用 `pattern`/`group`/`flags`，`date` 用 `pattern`，`duration` 用 `unit`，`base64` 用 `encoding`，`json` 用 `path`/`each`，`path` 用 `path`，`chunks` 用 `size`/`skips`，`modulo` 用 `modulo`/`offset`。 |
| `default` | 否 | 取不到值时的默认值。 |
| `multiple` | 否 | 为 `true` 时返回全部匹配值。 |
| `titleSelector` / `titleAttribute` | 否 | 分类项标题的读取方式。 |
| `transforms` | 否 | 变换链：最多 8 步，按顺序作用在抽取到的原始值上。与 `transform` 互斥。 |

同一个位置可以写多个候选抽取器（数组，最多 8 个），按顺序尝试，取第一个非空结果。
`fields` 的每个字段、`next`、`categories`、`detail.title`、`detail.cover`、
`detail.sources[]` 和 `detail.player` 都支持这种写法。

`transform` 白名单：

| 值 | 说明 |
| --- | --- |
| `text` | 原样字符串（默认）。 |
| `trim` | 去掉首尾空白。 |
| `absolute` | 相对地址转绝对地址。 |
| `duration` | `12:30`、`1小时2分钟` 等时长转秒；`options.unit=minutes` 时取整分钟。 |
| `date` | 日期文本转毫秒时间戳。 |
| `number` | 转数字。 |
| `boolean` | 转布尔值。 |
| `base64` | Base64 解码；`options.encoding` 可为 `percent` 或 `base64Percent`。 |
| `regex` | 正则提取，`options.pattern` 必填。 |
| `jsonVar` | 从脚本文本里取 `name={...}` 的 JSON，配合 `options.name` 与 `path`。 |
| `json` | 把输入当 JSON 文本解析，`options.path` 取值。 |
| `global` | 读取页面全局变量，`options.name` 必填，`options.path` 可选。 |
| `rewrite` | 正则替换，`options.pattern` 必填，`options.replacement` 可选。 |
| `path` | 对当前值再取一次路径；`options.path` 必填，路径里可以用 `[]` 遍历数组。 |
| `chunks` | 按每段跳过字符数依次截取定长片段；`options.size` 是片段长度，`options.skips` 是逐位跳步长度。 |
| `modulo` | 逐位取模映射，数字字符按数字值计算；`options.modulo` 必填，`options.offset` 可选。 |

`base64` 的 `options.encoding` 还支持 `auto`：按百分号转义、Base64、原文的顺序自动识别，
用于同一接口按 `encrypt` 字段切换转义方式的站点。

`text`、`trim`、`absolute`、`rewrite`、`regex`、`base64` 作用在数组上时逐元素处理，返回同样长度的数组。

`jsonVar` 作为变换链的首步时，会依次尝试 `selector` 匹配到的每个节点（缺省 `script`）的文本，
取第一个能解析出 `options.path` 的结果；`options.path` 缺省时返回整个 JSON 对象。

## 六、详情与播放模板

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `detail.sources` | **是** | 播放地址抽取器数组，按顺序尝试。 |
| `detail.request` | 否 | 详情页请求模板，缺省直接读当前页面。 |
| `detail.response` | 否 | 详情响应模板。 |
| `detail.title` | 否 | 标题抽取器。 |
| `detail.cover` | 否 | 封面抽取器。 |
| `detail.subtitles` | 否 | 字幕条目声明数组；可读取页面 track 或 JSON 中的 tracks、subtitles。 |
| `detail.collection` | 否 | 使用 `items` 读取当前页面已有的合集成员；`reverse` 可反转显示顺序，须包含当前影片。 |
| `detail.headers` | 否 | 播放请求头。 |
| `detail.fields` | 否 | 详情页命名取值，最多 16 个，按字段引用依赖求值，不依赖 JSON 键顺序。 |
| `detail.lines` | 否 | 多线路与分集声明，字段为 `items`、`name`、`episodes`、`url`，可选 `title`、`episodeSeparator`、`titleSeparator`。 |

`detail.fields` 给命名取值起名，例如 `videoId`、`token`；取值本身仍然是普通抽取器。
命名取值可以写进 `detail.request.url`（占位符 `{{field.<name>}}`），也可以写进变换选项
（例如 `chunks` 的 `options.skips`）。后一个取值可以引用前面已经取到的取值，
所以引用它的取值必须声明在后面。

`detail.player` 用于“详情页只给出播放器页地址”的站点：

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `detail.player.url` | **是** | 抽取器：从详情文档读出播放器页地址。 |
| `detail.player.sources` | **是** | 抽取器数组：从播放器页响应里读播放地址。 |
| `detail.player.subtitles` | 否 | 播放器页的字幕条目声明，与详情页字幕合并去重。 |
| `detail.player.response` | 否 | 播放器页响应类型，缺省 `html`。 |

详情文档里没有读到播放地址时，才会请求播放器页并从响应里继续读取。

字幕和合集复用普通 `items.fields`；不写 `selector` 或 `path` 时读取条目元素自身，`items.source` 可用抽取器从 HTML 取出 JSON 数组。字幕只接受站点或 `requestHosts` 域名的 VTT/SRT 地址，轨道类型为 subtitles 或 captions。

## 七、弹幕模板

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `danmaku.request` | **是** | 弹幕请求模板。 |
| `danmaku.items.path` | **是** | 弹幕数组 JSON 路径。每行至少 5 列：`[时间, 模式, 颜色, , 文本]`；数组协议可省略 `fields` 或使用空对象。 |
| `danmaku.success` | 否 | 成功条件 `{ "path": "code", "value": 23 }`；路径值不匹配时按请求失败处理。 |

## 八、推荐、相关条目与分集

`related.items` 与普通列表使用相同的 `selector` / `path` / `fields` 结构；`related.categories` 可读取详情页上的演员或分类链接。推荐和普通列表最终都返回统一媒体条目，继续使用同一分页、卡片、焦点和播放器流程。

`list.categoryFilter`、`search.categoryFilter` 和 `related.categoryFilter` 共用分类过滤：`urlPattern` 匹配 URL 的 pathname，`excludeTitlePattern` 排除排行等导航标题（两者均忽略大小写），`excludeClosest` 排除命中 CSS 选择器的节点及其后代。未声明时保持原有抽取行为。模板内尚未渲染的条目和分类不作为实际内容。

`recommendation.type` 当前为 `recombee`。声明须包含：

- `bootstrap.asset`：从当前详情或列表文档抽取同源应用 bundle 地址；`pattern` 与 `groups` 声明从 bundle 中读取数据库、公开签名密钥和 API host 的捕获组编号。服务器会限制 host，App 还会只接受 `client-rapi*.recombee.com`。
- `identity`：站点页面全局变量名、cookie 名和 `generateUuid` 开关。身份值只在站点页面内使用，不写入规则或服务端日志。
- `pageSize`、`scenario`、`logic`、`properties`：推荐请求参数。`properties` 必须列出要返回的原始 property 名称。
- `mapping`：从返回条目读取 `idPath`、`titlePaths`、`dmPath`、`durationPath` 与可选 premium 标记；`titlePaths.default` 是回退标题路径。`supportedLocales`、`defaultLocale` 和带捕获组的 `localePattern` 决定语言映射。
- `detailUrlTemplate`、`coverUrlTemplate`、`previewUrlTemplate`：安全 URL 模板。令牌只允许对应映射字段；详情 URL 可用 `{{origin}}/` 生成当前站点同源地址，媒体 CDN 必须列在 `coverHosts` 或站点 `hosts` 中。

`detail.lines` 用于站点接口返回多条线路及各线路分集时。`items` 选取线路数组，`name` 读取线路名，`episodes` 读取分集数组，`url` 读取分集地址；一个地址中多集可按 `episodeSeparator` 拆分，`titleSeparator` 前后分别为分集标题和地址。分集仍使用公共播放器和返回状态处理。

## 九、示例

完整示例见 [`browsertv-tv-site.example.json`](browsertv-tv-site.example.json)。最小规则站点：

```json
{
  "schema": "browsertv-tv-site-v1",
  "key": "my-site",
  "name": "我的站点",
  "version": 1,
  "homepage": "https://media.example/",
  "hosts": ["media.example"],
  "runtime": {
    "adapter": "rules",
    "script": {
      "list": {
        "request": { "url": "https://media.example/api/list?page={{page}}" },
        "response": { "type": "json" },
        "items": {
          "path": "items",
          "fields": {
            "url": { "path": "url", "transform": "absolute" },
            "title": { "path": "title" },
            "cover": { "path": "cover", "transform": "absolute" }
          }
        }
      },
      "detail": {
        "sources": [{ "path": "playUrl", "transform": "absolute" }]
      }
    }
  }
}
```

## 十、安全与更新

- 规则只允许数据，不允许任意 JavaScript、表达式或远程脚本。
- 规则不放账号、密码、Cookie、签名参数、私钥等认证信息；站点会话由站点自身和 App 受信任侧管理。
- 规则不得用于绕过登录、会员、付费墙、地区限制或站点 DRM。
- TV仓库通过 HTTPS 提供启用规则、版本和内容摘要。客户端校验 schema、域名边界和摘要，只有远程版本更高时才原子替换本地副本；请求、校验或写入失败时继续使用已保存版本。
- 编辑规则必须递增版本；删除规则后客户端在成功同步时移除对应站点。
