# 新增一路上海 / 南京实时摄像头

这份文档写给未来的 agent，也写给未来的你。按顺序做，不要跳步。

## 0. 先判断类型

| 类型 | 用什么 | 适合 |
|---|---|---|
| `hls` | 直接播放 `.m3u8` | 官方公开流、CORS 开放、能自定义播放器 |
| `iframe` | 嵌入官方页面 | 官方页面允许 iframe，但拿不到/不想拿 m3u8 |
| 外链 | 只放 `source_page` | 不能嵌入、但有公开页面；不作为播放器条目 |

优先 HLS。HLS 的体验、可控性、可检测性都更好。

## 1. 找候选

优先找这些来源：

- 媒体机构的慢直播专题（澎湃、看看新闻、央视网等）
- 景区/地标/博物馆的官方慢直播
- 交通/路桥的公开路况画面
- 政府/文旅部门的公开直播页

**不要**收：私人住宅、小区、单位内部、安防监控、需要登录/付费/破解的流、网友转播当主源。

## 2. 探针

```bash
npm run probe -- 'https://example.com/live.m3u8'
npm run probe -- 'https://example.com/live/page'
```

HLS 期望：

```text
HTTP               200 OK
Content-Type       application/x-mpegURL
CORS               *
媒体序列           12345
切片数量           4
是否点播           否，看起来是直播
```

iframe 期望：

```text
HTTP               200 OK
X-Frame-Options    (空)
CSP                (没有 frame-ancestors 限制)
```

如果 403/401：不要绕过 Referer/Token 校验，换源。

## 3. 确认画面在动

```bash
npm run verify -- 'https://example.com/live.m3u8'
```

输出里看：

```text
两帧 SSIM   0.96xx  (越低说明画面变化越大)
✅ 画面在动，符合直播特征。
```

SSIM 接近 `1.0000` 说明两帧几乎一样：可能是静止镜头、画面卡住，或播的是回放。换一路，或明确记录“静态镜头”。

## 4. 抓海报

```bash
npm run posters -- --only <camera-id>
```

需要 `ffmpeg`。会生成 `assets/posters/<id>.jpg`（默认宽 1280）。打开看一眼：

- 画面可辨认
- 有来源水印/时间戳没关系
- 不是黑屏/纯灰/严重起雾

## 5. 写数据

打开 `data/cameras.json`，在 `cameras` 数组末尾追加。

每条机位必须写 `city`（`shanghai` / `nanjing`）。默认机位在各城市的 `cities[].default_camera` 里：

- 上海：`yanan`（延安高架路，现有 10 路里离华东师大普陀校区最近，约 4.2 km）。
- 南京：`nj-zifeng`（紫峰眺望）。

新增的机位如果更适合当默认，改对应城市的 `default_camera`，不做动态距离计算。`order` 全局唯一，只用于排序；界面显示城市内序号。

### HLS 示例

```json
{
  "id": "example-cam",
  "city": "shanghai",
  "name": "示例机位",
  "name_en": "Example Camera",
  "category": "skyline",
  "district": "黄浦区",
  "location": { "lat": 31.2304, "lng": 121.4737, "label": "外滩 · 示例视角" },
  "vantage": "示例大厦",
  "description": "一句话说清楚画面里能看到什么。",
  "provider": "来源机构",
  "source_page": "https://example.com/source-page",
  "stream_url": "https://example.com/live/example.m3u8",
  "stream_type": "hls",
  "poster": "assets/posters/example-cam.jpg",
  "aspect_ratio": "16:9",
  "resolution": "1920×1080",
  "tags": ["天际线", "外滩"],
  "attribution": "来源机构 · 慢直播｜画面版权归原来源所有",
  "verified_at": "2026-10-24T10:45:00+08:00",
  "verified_method": "ffmpeg-frame-diff",
  "order": 12
}
```

### iframe 示例

```json
{
  "id": "example-iframe",
  "city": "shanghai",
  "name": "示例多机位",
  "name_en": "Example Multi-cam",
  "category": "multi",
  "district": "黄浦区",
  "location": { "lat": 31.24, "lng": 121.49, "label": "外滩" },
  "vantage": "官方慢直播页",
  "description": "官方页面内含多路画面，可在源站切换。",
  "provider": "来源机构",
  "source_page": "https://example.com/live-page",
  "stream_url": "https://example.com/live-page",
  "stream_type": "iframe",
  "poster": "assets/posters/example-iframe.jpg",
  "aspect_ratio": "16:9",
  "resolution": "多机位",
  "tags": ["多机位", "官方"],
  "attribution": "来源机构｜版权归原来源所有",
  "verified_at": "2026-10-24T10:45:00+08:00",
  "verified_method": "playwright-iframe",
  "note": "移动端源站可能有试看限制。",
  "order": 13
}
```

### 字段速查

| 字段 | 必填 | 说明 |
|---|---|---|
| `id` | ✅ | kebab-case，唯一 |
| `city` | ✅ | `shanghai` / `nanjing`（必须是 `cities[].id`） |
| `name` / `name_en` | ✅ | 中/英文名 |
| `category` | ✅ | `skyline` / `bridge` / `landmark` / `traffic` / `waterfront` / `multi` |
| `district` | ✅ | 区县 |
| `location.lat/lng` | ✅ | 画面主体坐标，上海/南京对应 bbox 内 |
| `location.label` | ✅ | 人话位置 |
| `vantage` | 建议 | 机位/观景点 |
| `description` | ✅ | 能看到什么 |
| `provider` | ✅ | 来源机构 |
| `source_page` | ✅ | 官方页面 https |
| `stream_url` | ✅ | HLS 是 m3u8；iframe 是页面 |
| `stream_type` | ✅ | `hls` / `iframe` |
| `poster` | ✅ | `assets/posters/<id>.jpg` |
| `resolution` | 建议 | 例如 `1920×1080` |
| `tags` | ✅ | 至少 1 个 |
| `attribution` | ✅ | 来源 + 版权 |
| `verified_at` | ✅ | 带时区 ISO |
| `verified_method` | ✅ | `ffmpeg-frame-diff` / `playwright-iframe` / `probe` |
| `note` | 可选 | iframe 限制等 |
| `order` | ✅ | 全局唯一正整数；只用于排序 |

## 6. 校验 + 检测

```bash
npm run validate
npm run check
```

- `validate` 会检查字段、`city`、id/order 重复、坐标是否在对应城市 bbox、海报文件是否存在。
- `check` 会请求每路 m3u8，写 `data/health.json`。新流失败就先修，别提交坏条目。

## 7. 浏览器验收

```bash
npm run dev
```

打开 <http://127.0.0.1:4321>，确认：

- [ ] 首屏能播放（默认静音，点声音能开）
- [ ] 城市标签能在上海/南京之间切换，新机位出现在正确城市
- [ ] 地图上有新光点，点击能切换首屏
- [ ] 卡片能打开灯箱，视频能播放
- [ ] 手机 390px 宽度不溢出；地图按城市切换
- [ ] 控制台没有报错
- [ ] 新海报显示正常

## 8. 记录 + 部署

- 在 `research/` 追加一份验证记录：日期、来源、URL、截图、SSIM、结论。
- `./scripts/deploy.sh`（先按 `docs/DEPLOY.md` 配好 SSH）。

## 南京源特别注意

- **参考页**：<https://m2.nbs.cn/eventlive/280714.html>（Live南京多机位）和 <https://www.nbs.cn/subject/2021cityGuard/3/>（南京此刻）。
- **Referer 防盗链**：`nklive*.nbs.cn` 只接受无 Referer 或 `m2.nbs.cn` Referer。项目已用 `<meta name="referrer" content="no-referrer">` + Caddy `Referrer-Policy: no-referrer` 处理；不要删。
- **HTTPS**：`nklive*.nbs.cn` 的 HTTPS 可用且 CORS `*`；`live4.nbs.cn` 只有 HTTP，不能用。
- **标题可能过期**：页面标题和实际画面不一定一致，以画面时间戳/内容为准。比如 2021 年的「夫子庙」页面实际是「紫峰眺望」；「南京眼」是 2020 年回放，不能当实时源。
- **央视玄武湖**：`https://gcalic.v.myalicdn.com/gc/xwh01_1/index.m3u8`，CORS `*`，带天气信息条。

## 常见问题

| 现象 | 处理 |
|---|---|
| `probe` 403 | 源站校验 Referer/Token，不要绕过，换源 |
| `#EXT-X-ENDLIST` | 点播/回放，不能当直播 |
| CORS 失败 | 改用 iframe，或放弃 |
| iframe 空白 | 源站 `X-Frame-Options`/CSP 禁止嵌入，只做外链 |
| SSIM 接近 1 | 静态镜头/回放/卡住，换源或记录说明 |
| 坐标校验失败 | 检查 `city` 与经纬度是否匹配（上海/南京 bbox 不同） |
| 海报抓取失败 | 安装 ffmpeg、重试、换流 |
| 播放器黑屏但清单正常 | 编码/浏览器问题；看 console 的 hls.js 错误 |
| 南京源 403（带 Referer） | 南京广电防盗链；保留 `<meta name="referrer" content="no-referrer">` 和 Caddy `Referrer-Policy: no-referrer` |
| 南京源 HTTPS 失败 | `live4.nbs.cn` 只有 HTTP；在 HTTPS 页面不可用，换 HTTPS 源或放弃 |
EOF
