feat: 片名输入加下拉联想,不必把片名打全就能选片

输入片名时实时下拉候选(海报缩略图 + 片名 + 原名 + 年份 + 电影/剧集标注),
选中即自动填充影片信息,省去「打全片名再点按钮」这一步。

fetch_movie.php
- 新增 action=suggest:只回候选列表,不取详情、不下载海报、不写任何文件,
  可随输入安全地频繁调用;会过滤「影人」类条目、按豆瓣条目 id 去重、默认限 8 条。
- 新增 action=thumb:服务端代取豆瓣缩略图。豆瓣图片有防盗链(只有 Referer 为
  豆瓣域名才返回 200,其它一律 403),浏览器直连必然被拒,只能由服务端带
  Referer 代取;并加 Cache-Control 让浏览器缓存,重复展开不重复回源。
- 新增 ?id=豆瓣条目id:跳过搜索直接取详情。下拉选中后走这条路,既省一次搜索,
  也避免「再按片名搜一次、结果选错条目」。
- 安全:新增 validateDoubanImageUrl() 严格白名单(只允许
  https://img*.doubanio.com/view/photo/…),否则 thumb 就成了 SSRF 跳板;
  接口地址的测试用覆盖项一并写进注释。

add.php
- 片名输入框改为「输入即联想」:320ms 防抖、会话内结果缓存、用 AbortController
  丢弃过期在途请求、↑↓ 选择 / Enter 确认 / Esc 关闭、点击外部关闭、
  中文输入法组字期间不发请求也不劫持 Enter(此时 Enter 是选字)。
- 候选里的豆瓣文本一律转义后再插入,避免把对方内容当 HTML 执行。
- 「📡 自动获取」按钮保留:候选里没有想要的条目时仍可按回车或点按钮,由服务端
  挑最匹配的条目;按钮与下拉选中复用同一套获取逻辑(runFetch)。

README.md
- 接口文档改为四种模式(q / id / suggest / thumb),补上参数、响应样例、
  防盗链原因与白名单说明;使用说明与安全注意事项同步更新。

验证
- fetch_movie_test.py 端到端 79 项全通过(新增 suggest / thumb / id 三组用例,
  含「suggest 与 thumb 不写任何文件」「非白名单地址一律 400」)。
- storage_test.py 回归 196 项全通过。
- A/B 渲染比对 47 个响应:仅 add.php 的两个页面有差异,且差异全部落在下拉联想
  相关代码上,其余响应逐字节一致。
- 已部署到线上,双 PHP 版本(7.4 / 8.1)真实抓取通过:suggest 返回候选、
  thumb 代取回 27KB JPEG、非白名单地址被拒、id= 直连取详情成功。
This commit is contained in:
2026-09-22 07:36:05 +08:00
parent ddf2eeafaa
commit 99c22d17bd
3 changed files with 522 additions and 81 deletions
+61 -9
View File
@@ -30,7 +30,7 @@
- **两种图片访问方式**:**直连存储**(浏览器直接访问对象存储)与**服务端代理**(经本站 `media.php` 鉴权后由服务器读取回吐,**桶可保持私有**)。两者的取舍与配置见下文《图片存储与对象存储》。
### 信息辅助
- **豆瓣自动获取**:在添加页输入片名(中文即可,英文片名也能命中对应条目)后点击「📡 自动获取」,自动填充年份、地区、类型、片长、导演、主演、豆瓣评分、又名与剧情简介,**并把海报下载入库**——提交时若未手动上传图片,就直接采用自动获取的海报。
- **豆瓣自动获取**:在添加页输入片名时会**实时下拉联想候选**(带海报缩略图、年份、电影/剧集标注),**不必把片名打全**——从候选里点一条即可;也可以直接按回车或点「📡 自动获取」由服务端挑最匹配的条目。自动填充年份、地区、类型、片长、导演、主演、豆瓣评分、又名与剧情简介,**并把海报下载入库**——提交时若未手动上传图片,就直接采用自动获取的海报。
- **CSV 批量导入**:一次性导入历史数据,自动识别 UTF-8 / GBK / GB2312 编码,自动创建不存在的影院,影院名去重,全程事务保护(出错自动回滚),并输出逐行错误报告。
### 检索与统计
@@ -112,7 +112,7 @@ MovieLog_Server/
├── settings.php # 系统设置页:存储后端选择、图片访问方式、连接测试、迁移
├── storage_api.php # 存储维护接口(stat / migrate / test),返回 JSON
├── media.php # 服务端图片代理:鉴权后由服务器读取对象并同源回吐
├── fetch_movie.php # 豆瓣抓取接口(搜索 + 详情 + 海报入库),返回 JSON
├── fetch_movie.php # 豆瓣接口(片名候选 / 详情 + 海报入库 / 缩略图代理),返回 JSON
├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
├── css/style.css # 全局样式
├── js/main.js # 公共脚本(含各页面共用的表单联动函数、破图兜底)
@@ -230,7 +230,7 @@ server {
### 添加一部电影
1. 顶部导航点击「➕ 添加电影」。
2. 输入片名后点「📡 自动获取」可自动填充影片信息(可选)。
2. 在「电影名称」里输入片名的**前几个字**,下方会实时弹出候选列表(海报缩略图 + 片名 + 原名 + 年份 + 电影/剧集)。可以用 ↑ / ↓ 选择、回车确认,也可以直接用鼠标点选——**不必把片名打全**,选中后会自动填充影片信息。若候选里没有想要的条目,直接点「📡 自动获取」(或按回车)由服务端挑最匹配的条目(可选步骤)。
3. 填写观影日期(必填)、选择或新建电影院。
4. 「一起看的人」逐行添加:选择已有观影人,或在下拉框中选「➕ 新增」并输入姓名,新姓名会自动进入观影人库。
5. 上传海报(推荐 2:3);上传票根会弹出裁剪框,裁剪结果以 Base64 提交。
@@ -283,12 +283,22 @@ CSV **共 9 列,顺序固定**:
供添加页前端调用,服务端代请求豆瓣(既避免浏览器跨域,也复用服务器的出网能力)。
- **请求**:`GET fetch_movie.php?q=片名[&old_poster=上一次抓取的海报键]`
共 **4 种模式**,全部**需要登录**(未登录一律返回 `HTTP 401` + `{"error": "…"}`):
| 模式 | 请求 | 作用 | 是否写文件 |
| --- | --- | --- | --- |
| 1. 按片名抓取 | `?q=片名[&old_poster=键]` | 搜索 → 取详情 → 下载海报入库 | 是(写海报) |
| 2. 按条目 id 抓取 | `?id=豆瓣条目id[&old_poster=键]` | 跳过搜索,直接取详情 | 是(写海报) |
| 3. 下拉候选 | `?action=suggest&q=关键词` | 只回候选列表 | **否** |
| 4. 缩略图代理 | `?action=thumb&u=图片地址` | 代取豆瓣缩略图字节 | **否** |
**模式 1 / 2**(抓取详情并把海报入库)
- **参数**
- `q`:片名,中文或英文均可(英文名会命中对应的中文条目)
- `old_poster`:可选。用于清理「上一次抓取、但本次未采用」的海报文件,前端在重复点击时自动回传
- **需要登录**:未登录返回 `HTTP 401` + `{"error": "…"}`
- **响应**:`Content-Type: application/json`
- **成功**:
- `id`:豆瓣条目 id(纯数字)。下拉选中后走这条路,**省一次搜索,也不会「再搜一次选错条目」**
- `old_poster`:可选。用于清理「上一次抓取、但本次未采用」的海报文件,前端在重复获取时自动回传
- **成功响应**:
```json
{
"ok": true,
@@ -315,7 +325,45 @@ CSV **共 9 列,顺序固定**:
- `poster_key` 是提交表单时应写入 `movies.poster_path` 的存储键。前端把它放进隐藏域 `fetched_poster_key` 回传;
提交时**手动上传的海报优先**,没有才采用该键。键的目录与文件名、以及文件是否真实存在,都由服务端校验。
- `rating` 为豆瓣评分(保留一位小数)。无评分、或该片暂无海报时,对应字段为空串。
- **失败**:`{"error": "错误说明"}`(如「豆瓣没有找到「xxx」,换个更准确的片名试试」)
**模式 3**(片名下拉联想的候选列表;**不取详情、不写任何文件**,可随输入频繁调用)
- **请求**:`GET fetch_movie.php?action=suggest&q=关键词`
- **成功响应**:
```json
{
"ok": true,
"items": [
{
"id": "34780991",
"title": "哪吒之魔童闹海",
"sub_title": "哪吒之魔童闹海",
"year": "2025",
"episode": "",
"kind": "电影",
"thumb": "fetch_movie.php?action=thumb&u=https%3A%2F%2Fimg9.doubanio.com%2F..."
}
]
}
```
- **说明**:
- `q` 为空时直接返回 `{"ok": true, "items": []}`,**不会请求上游**;上游无结果时同样返回空数组(不算错误)
- 会过滤掉「影人」类条目、按条目 id 去重,默认**最多 8 条**
- `kind` 由豆瓣的 `episode`(集数)推断:有集数即「剧集」,否则「电影」
- `thumb` 是**本站代理地址**(见模式 4),不是豆瓣外链;该候选没有海报时为空串
- **失败**:上游请求失败时为 `{"error": "搜索失败:…"}`
**模式 4**(缩略图服务端代取)
- **请求**:`GET fetch_movie.php?action=thumb&u=<图片地址>`(`u` 需 URL 编码)
- **成功**:直接回吐图片字节,带 `Content-Type: image/*` 与 `Cache-Control: public, max-age=86400`
- **为什么要代理**:豆瓣图片有**防盗链**——只有 `Referer` 为豆瓣域名时才返回 200,其它一律 403。
浏览器直连(本站 `Referrer-Policy: same-origin`,跨域图片不带 Referer)必然被拒,所以只能由服务端带 Referer 代取。
- **安全**:该地址做了**严格白名单**(只允许 `https://img*.doubanio.com/view/photo/…jpg|png|webp`),
否则就成了 SSRF 跳板。非白名单地址返回 `HTTP 400`;目标不是图片返回 `HTTP 404`。
- **失败**:`400`(地址不合法)/ `404`(取不到或非图片)
**模式 1 / 2 的失败响应**:`{"error": "错误说明"}`(如「豆瓣没有找到「xxx」,换个更准确的片名试试」);未知 `action` 返回 `{"error": "不支持的 action:…"}`。
> **关于数据来源与合规**:本功能使用的是豆瓣的**公开网页接口**(非官方开放 API,也不需要 API Key),
> 按需抓取少量条目用于个人观影记录,并已加入登录鉴权。请自行评估使用场景与豆瓣的服务条款;
@@ -562,6 +610,8 @@ curl -sk -H "Host: 你的域名" -H "Cookie: PHPSESSID=xxxx" -o /dev/null \
1. **修改默认口令**:默认账号 `admin / 123456` 必须在首次登录后立即更换。
2. **「自动获取」会写入存储**:`fetch_movie.php` 已要求登录后才能调用,且只请求固定的豆瓣域名、只把文件写入 `uploads/posters/`;表单回传的 `fetched_poster_key` 与 `old_poster` 都要通过「目录前缀 + 文件名 + 数据库引用」三重校验才会被采用或删除。若你的部署不需要该功能,可直接删除 `fetch_movie.php` 并移除 `add.php` 里的按钮。
- **`action=thumb` 是服务端代取,必须保持白名单**:它以服务端身份去取 `u` 指定的图片,若放开任意 URL 就是**SSRF 跳板**。代码里 `validateDoubanImageUrl()` 只放行 `https://img*.doubanio.com/view/photo/…`,改动时**务必保留**。环境变量 `MOVIELOG_DOUBAN_IMG_ALLOW` 是给本地 mock 测试留的豁免口子(默认不设置即完全关闭),**生产环境不要设置**。
- **下拉联想的候选文本来自豆瓣,前端一律转义后再插入**:不要改成直接拼 `innerHTML`,否则豆瓣侧内容可注入脚本。
3. **删除或保护 `test_crop.php`**:该文件是开发调试页,**没有登录校验**,仅用于验证 Base64 图片能否落盘,生产环境应直接删除。
4. **关闭调试输出**:`config.php` 顶部当前为 `error_reporting(E_ALL)` + `display_errors=1`,上线前请改为关闭或仅写日志,避免泄露路径与数据库信息。
5. **保证 CA 证书可用**:图片下载与「自动获取」都依赖 `curl`,且 `fetch_movie.php` 已启用证书校验(`CURLOPT_SSL_VERIFYPEER = true`)。若服务器缺少 CA 证书束(`curl.cainfo` 为空,常见于最小化安装),请求会失败,请安装 `ca-certificates` 包。
@@ -593,6 +643,8 @@ curl -sk -H "Host: 你的域名" -H "Cookie: PHPSESSID=xxxx" -o /dev/null \
- **服务端代理模式下所有图片流量经服务器中转**,会占用服务器出网带宽,并给每张图增加一次服务器↔存储的往返(同地域约 10–30 ms,跨地域约 60–100 ms)。图片量大且可公开时可考虑改回直连。
- **内网 endpoint 只在服务器与桶同地域时生效**。跨地域部署下这条免费通道用不了,程序会自动跳过并走公网(详见 §4)。
- 迁移到云端后**本地文件不会自动删除**,需要自行确认后再清理;本地优先策略意味着本地文件存在时始终读本地,云端副本仅作备份。
- **下拉联想会随输入向豆瓣实时发请求**(已做 320 ms 防抖 + 会话内结果缓存 + 丢弃过期请求,同一关键词只查一次),但**服务端本身没有做接口级限流**。若担心被豆瓣限流,可把 `add.php` 里的 `DEBOUNCE` 调大,或让下拉只在按下「📡 自动获取」时才工作。
- **缩略图代理(`action=thumb`)每张图都要经服务器回源一次**。已加 `Cache-Control: max-age=86400`,同一张图在浏览器侧一天内只回源一次,正常使用量很小;若把下拉列表做得很大、或候选很多,建议改成在服务端落小图缓存。
---