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:
@@ -30,7 +30,7 @@
|
|||||||
- **两种图片访问方式**:**直连存储**(浏览器直接访问对象存储)与**服务端代理**(经本站 `media.php` 鉴权后由服务器读取回吐,**桶可保持私有**)。两者的取舍与配置见下文《图片存储与对象存储》。
|
- **两种图片访问方式**:**直连存储**(浏览器直接访问对象存储)与**服务端代理**(经本站 `media.php` 鉴权后由服务器读取回吐,**桶可保持私有**)。两者的取舍与配置见下文《图片存储与对象存储》。
|
||||||
|
|
||||||
### 信息辅助
|
### 信息辅助
|
||||||
- **豆瓣自动获取**:在添加页输入片名(中文即可,英文片名也能命中对应条目)后点击「📡 自动获取」,自动填充年份、地区、类型、片长、导演、主演、豆瓣评分、又名与剧情简介,**并把海报下载入库**——提交时若未手动上传图片,就直接采用自动获取的海报。
|
- **豆瓣自动获取**:在添加页输入片名时会**实时下拉联想候选**(带海报缩略图、年份、电影/剧集标注),**不必把片名打全**——从候选里点一条即可;也可以直接按回车或点「📡 自动获取」由服务端挑最匹配的条目。自动填充年份、地区、类型、片长、导演、主演、豆瓣评分、又名与剧情简介,**并把海报下载入库**——提交时若未手动上传图片,就直接采用自动获取的海报。
|
||||||
- **CSV 批量导入**:一次性导入历史数据,自动识别 UTF-8 / GBK / GB2312 编码,自动创建不存在的影院,影院名去重,全程事务保护(出错自动回滚),并输出逐行错误报告。
|
- **CSV 批量导入**:一次性导入历史数据,自动识别 UTF-8 / GBK / GB2312 编码,自动创建不存在的影院,影院名去重,全程事务保护(出错自动回滚),并输出逐行错误报告。
|
||||||
|
|
||||||
### 检索与统计
|
### 检索与统计
|
||||||
@@ -112,7 +112,7 @@ MovieLog_Server/
|
|||||||
├── settings.php # 系统设置页:存储后端选择、图片访问方式、连接测试、迁移
|
├── settings.php # 系统设置页:存储后端选择、图片访问方式、连接测试、迁移
|
||||||
├── storage_api.php # 存储维护接口(stat / migrate / test),返回 JSON
|
├── storage_api.php # 存储维护接口(stat / migrate / test),返回 JSON
|
||||||
├── media.php # 服务端图片代理:鉴权后由服务器读取对象并同源回吐
|
├── media.php # 服务端图片代理:鉴权后由服务器读取对象并同源回吐
|
||||||
├── fetch_movie.php # 豆瓣抓取接口(搜索 + 详情 + 海报入库),返回 JSON
|
├── fetch_movie.php # 豆瓣接口(片名候选 / 详情 + 海报入库 / 缩略图代理),返回 JSON
|
||||||
├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
|
├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
|
||||||
├── css/style.css # 全局样式
|
├── css/style.css # 全局样式
|
||||||
├── js/main.js # 公共脚本(含各页面共用的表单联动函数、破图兜底)
|
├── js/main.js # 公共脚本(含各页面共用的表单联动函数、破图兜底)
|
||||||
@@ -230,7 +230,7 @@ server {
|
|||||||
### 添加一部电影
|
### 添加一部电影
|
||||||
|
|
||||||
1. 顶部导航点击「➕ 添加电影」。
|
1. 顶部导航点击「➕ 添加电影」。
|
||||||
2. 输入片名后点「📡 自动获取」可自动填充影片信息(可选)。
|
2. 在「电影名称」里输入片名的**前几个字**,下方会实时弹出候选列表(海报缩略图 + 片名 + 原名 + 年份 + 电影/剧集)。可以用 ↑ / ↓ 选择、回车确认,也可以直接用鼠标点选——**不必把片名打全**,选中后会自动填充影片信息。若候选里没有想要的条目,直接点「📡 自动获取」(或按回车)由服务端挑最匹配的条目(可选步骤)。
|
||||||
3. 填写观影日期(必填)、选择或新建电影院。
|
3. 填写观影日期(必填)、选择或新建电影院。
|
||||||
4. 「一起看的人」逐行添加:选择已有观影人,或在下拉框中选「➕ 新增」并输入姓名,新姓名会自动进入观影人库。
|
4. 「一起看的人」逐行添加:选择已有观影人,或在下拉框中选「➕ 新增」并输入姓名,新姓名会自动进入观影人库。
|
||||||
5. 上传海报(推荐 2:3);上传票根会弹出裁剪框,裁剪结果以 Base64 提交。
|
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`:片名,中文或英文均可(英文名会命中对应的中文条目)
|
- `q`:片名,中文或英文均可(英文名会命中对应的中文条目)
|
||||||
- `old_poster`:可选。用于清理「上一次抓取、但本次未采用」的海报文件,前端在重复点击时自动回传
|
- `id`:豆瓣条目 id(纯数字)。下拉选中后走这条路,**省一次搜索,也不会「再搜一次选错条目」**
|
||||||
- **需要登录**:未登录返回 `HTTP 401` + `{"error": "…"}`
|
- `old_poster`:可选。用于清理「上一次抓取、但本次未采用」的海报文件,前端在重复获取时自动回传
|
||||||
- **响应**:`Content-Type: application/json`
|
- **成功响应**:
|
||||||
- **成功**:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -315,7 +325,45 @@ CSV **共 9 列,顺序固定**:
|
|||||||
- `poster_key` 是提交表单时应写入 `movies.poster_path` 的存储键。前端把它放进隐藏域 `fetched_poster_key` 回传;
|
- `poster_key` 是提交表单时应写入 `movies.poster_path` 的存储键。前端把它放进隐藏域 `fetched_poster_key` 回传;
|
||||||
提交时**手动上传的海报优先**,没有才采用该键。键的目录与文件名、以及文件是否真实存在,都由服务端校验。
|
提交时**手动上传的海报优先**,没有才采用该键。键的目录与文件名、以及文件是否真实存在,都由服务端校验。
|
||||||
- `rating` 为豆瓣评分(保留一位小数)。无评分、或该片暂无海报时,对应字段为空串。
|
- `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),
|
> **关于数据来源与合规**:本功能使用的是豆瓣的**公开网页接口**(非官方开放 API,也不需要 API Key),
|
||||||
> 按需抓取少量条目用于个人观影记录,并已加入登录鉴权。请自行评估使用场景与豆瓣的服务条款;
|
> 按需抓取少量条目用于个人观影记录,并已加入登录鉴权。请自行评估使用场景与豆瓣的服务条款;
|
||||||
@@ -562,6 +610,8 @@ curl -sk -H "Host: 你的域名" -H "Cookie: PHPSESSID=xxxx" -o /dev/null \
|
|||||||
|
|
||||||
1. **修改默认口令**:默认账号 `admin / 123456` 必须在首次登录后立即更换。
|
1. **修改默认口令**:默认账号 `admin / 123456` 必须在首次登录后立即更换。
|
||||||
2. **「自动获取」会写入存储**:`fetch_movie.php` 已要求登录后才能调用,且只请求固定的豆瓣域名、只把文件写入 `uploads/posters/`;表单回传的 `fetched_poster_key` 与 `old_poster` 都要通过「目录前缀 + 文件名 + 数据库引用」三重校验才会被采用或删除。若你的部署不需要该功能,可直接删除 `fetch_movie.php` 并移除 `add.php` 里的按钮。
|
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 图片能否落盘,生产环境应直接删除。
|
3. **删除或保护 `test_crop.php`**:该文件是开发调试页,**没有登录校验**,仅用于验证 Base64 图片能否落盘,生产环境应直接删除。
|
||||||
4. **关闭调试输出**:`config.php` 顶部当前为 `error_reporting(E_ALL)` + `display_errors=1`,上线前请改为关闭或仅写日志,避免泄露路径与数据库信息。
|
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` 包。
|
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)。图片量大且可公开时可考虑改回直连。
|
- **服务端代理模式下所有图片流量经服务器中转**,会占用服务器出网带宽,并给每张图增加一次服务器↔存储的往返(同地域约 10–30 ms,跨地域约 60–100 ms)。图片量大且可公开时可考虑改回直连。
|
||||||
- **内网 endpoint 只在服务器与桶同地域时生效**。跨地域部署下这条免费通道用不了,程序会自动跳过并走公网(详见 §4)。
|
- **内网 endpoint 只在服务器与桶同地域时生效**。跨地域部署下这条免费通道用不了,程序会自动跳过并走公网(详见 §4)。
|
||||||
- 迁移到云端后**本地文件不会自动删除**,需要自行确认后再清理;本地优先策略意味着本地文件存在时始终读本地,云端副本仅作备份。
|
- 迁移到云端后**本地文件不会自动删除**,需要自行确认后再清理;本地优先策略意味着本地文件存在时始终读本地,云端副本仅作备份。
|
||||||
|
- **下拉联想会随输入向豆瓣实时发请求**(已做 320 ms 防抖 + 会话内结果缓存 + 丢弃过期请求,同一关键词只查一次),但**服务端本身没有做接口级限流**。若担心被豆瓣限流,可把 `add.php` 里的 `DEBOUNCE` 调大,或让下拉只在按下「📡 自动获取」时才工作。
|
||||||
|
- **缩略图代理(`action=thumb`)每张图都要经服务器回源一次**。已加 `Cache-Control: max-age=86400`,同一张图在浏览器侧一天内只回源一次,正常使用量很小;若把下拉列表做得很大、或候选很多,建议改成在服务端落小图缓存。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -315,11 +315,64 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
/* ---- 片名下拉联想 ---- */
|
||||||
|
.ac-wrap { flex: 1; position: relative; min-width: 0; }
|
||||||
|
.ac-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 60;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
max-height: 328px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.ac-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ac-item:hover, .ac-item.active { background: rgba(127, 127, 127, 0.14); }
|
||||||
|
.ac-thumb {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 38px;
|
||||||
|
height: 54px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(127, 127, 127, 0.15);
|
||||||
|
}
|
||||||
|
.ac-meta { min-width: 0; }
|
||||||
|
.ac-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ac-sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ac-hint { padding: 8px; font-size: 13px; color: var(--text-secondary); }
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.form-card { padding: 20px 16px; }
|
.form-card { padding: 20px 16px; }
|
||||||
.form-row { grid-template-columns: 1fr; }
|
.form-row { grid-template-columns: 1fr; }
|
||||||
.form-actions { flex-direction: column; }
|
.form-actions { flex-direction: column; }
|
||||||
.form-actions .btn { width: 100%; text-align: center; }
|
.form-actions .btn { width: 100%; text-align: center; }
|
||||||
|
.ac-list { max-height: 240px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -337,11 +390,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="POST" action="" enctype="multipart/form-data" id="addForm">
|
<form method="POST" action="" enctype="multipart/form-data" id="addForm">
|
||||||
<!-- 电影名称 + 自动获取按钮 -->
|
<!-- 电影名称 + 下拉联想 + 自动获取按钮 -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>电影名称 <span class="hint">*必填</span></label>
|
<label>电影名称 <span class="hint">*必填(输入几个字即可,从下拉里选)</span></label>
|
||||||
<div style="display: flex; gap: 10px;">
|
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
||||||
<input type="text" name="title" id="movieTitle" value="<?= h($_POST['title'] ?? '') ?>" placeholder="请输入电影名称" required style="flex:1;">
|
<div class="ac-wrap">
|
||||||
|
<input type="text" name="title" id="movieTitle"
|
||||||
|
value="<?= h($_POST['title'] ?? '') ?>"
|
||||||
|
placeholder="输入片名(中文即可),下方会给出候选"
|
||||||
|
required autocomplete="off"
|
||||||
|
role="combobox" aria-expanded="false"
|
||||||
|
aria-controls="titleSuggest" aria-autocomplete="list">
|
||||||
|
<div id="titleSuggest" class="ac-list" role="listbox" hidden></div>
|
||||||
|
</div>
|
||||||
<button type="button" class="btn btn-primary" id="fetchBtn" style="white-space:nowrap;">📡 自动获取</button>
|
<button type="button" class="btn btn-primary" id="fetchBtn" style="white-space:nowrap;">📡 自动获取</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="fetchStatus" style="font-size:13px;color:var(--text-secondary);margin-top:4px;"></div>
|
<div id="fetchStatus" style="font-size:13px;color:var(--text-secondary);margin-top:4px;"></div>
|
||||||
@@ -580,39 +641,48 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 豆瓣自动获取 ----------
|
// ---------- 豆瓣自动获取(「按钮」与「下拉选中」共用同一套逻辑)----------
|
||||||
document.getElementById('fetchBtn').addEventListener('click', function() {
|
const movieTitleEl = document.getElementById('movieTitle');
|
||||||
const title = document.getElementById('movieTitle').value.trim();
|
const movieInfoEl = document.getElementById('movieInfo');
|
||||||
const status = document.getElementById('fetchStatus');
|
const fetchBtnEl = document.getElementById('fetchBtn');
|
||||||
const btn = this;
|
const fetchStatusEl = document.getElementById('fetchStatus');
|
||||||
|
const fetchedKeyEl = document.getElementById('fetchedPosterKey');
|
||||||
|
const posterPreviewEl = document.getElementById('posterPreview');
|
||||||
|
const posterWrapEl = document.getElementById('posterPreviewWrap');
|
||||||
|
const posterHintEl = document.getElementById('posterPreviewHint');
|
||||||
|
|
||||||
if (!title) {
|
// 豆瓣返回的文本一律先转义再插入,避免把对方内容当 HTML 执行
|
||||||
status.style.color = '#e74c3c';
|
function acEsc(s) {
|
||||||
status.textContent = '⚠️ 请先输入电影名称';
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||||
return;
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
function setFetchStatus(text, color) {
|
||||||
status.style.color = 'var(--text-secondary)';
|
fetchStatusEl.style.color = color || 'var(--text-secondary)';
|
||||||
status.textContent = '⏳ 正在获取…';
|
fetchStatusEl.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// query 形如 'q=片名' 或 'id=豆瓣条目id'
|
||||||
|
function runFetch(query) {
|
||||||
|
fetchBtnEl.disabled = true;
|
||||||
|
setFetchStatus('⏳ 正在获取…');
|
||||||
|
|
||||||
// 上一次抓取但仍未采用的海报键,交给服务端清理,避免重复点击堆积文件
|
// 上一次抓取但仍未采用的海报键,交给服务端清理,避免重复点击堆积文件
|
||||||
const oldKey = document.getElementById('fetchedPosterKey').value;
|
let url = 'fetch_movie.php?' + query;
|
||||||
let url = 'fetch_movie.php?q=' + encodeURIComponent(title);
|
const oldKey = fetchedKeyEl.value;
|
||||||
if (oldKey) url += '&old_poster=' + encodeURIComponent(oldKey);
|
if (oldKey) url += '&old_poster=' + encodeURIComponent(oldKey);
|
||||||
|
|
||||||
fetch(url)
|
return fetch(url)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
status.style.color = '#e74c3c';
|
setFetchStatus('❌ ' + data.error, '#e74c3c');
|
||||||
status.textContent = '❌ ' + data.error;
|
|
||||||
btn.disabled = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 片名以豆瓣的正名称为准(自动补全/纠正)
|
// 片名以豆瓣的正名称为准(自动补全/纠正)
|
||||||
if (data.title) document.getElementById('movieTitle').value = data.title;
|
if (data.title) movieTitleEl.value = data.title;
|
||||||
|
|
||||||
// 组装电影信息文本
|
// 组装电影信息文本
|
||||||
let infoText = '';
|
let infoText = '';
|
||||||
@@ -625,44 +695,195 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if (data.rating) infoText += '⭐ 豆瓣评分:' + data.rating + '\n';
|
if (data.rating) infoText += '⭐ 豆瓣评分:' + data.rating + '\n';
|
||||||
if (data.alias) infoText += '🔖 又名:' + data.alias + '\n';
|
if (data.alias) infoText += '🔖 又名:' + data.alias + '\n';
|
||||||
if (data.plot) infoText += '\n📝 ' + data.plot;
|
if (data.plot) infoText += '\n📝 ' + data.plot;
|
||||||
|
movieInfoEl.value = infoText;
|
||||||
document.getElementById('movieInfo').value = infoText;
|
|
||||||
|
|
||||||
// 海报:已由服务端下载入库。这里记录键值(提交时写入 poster_path)并预览。
|
// 海报:已由服务端下载入库。这里记录键值(提交时写入 poster_path)并预览。
|
||||||
const keyInput = document.getElementById('fetchedPosterKey');
|
|
||||||
const preview = document.getElementById('posterPreview');
|
|
||||||
const wrap = document.getElementById('posterPreviewWrap');
|
|
||||||
const hint = document.getElementById('posterPreviewHint');
|
|
||||||
|
|
||||||
if (data.poster && data.poster_key) {
|
if (data.poster && data.poster_key) {
|
||||||
keyInput.value = data.poster_key;
|
fetchedKeyEl.value = data.poster_key;
|
||||||
preview.src = data.poster;
|
posterPreviewEl.src = data.poster;
|
||||||
hint.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
|
posterHintEl.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
|
||||||
wrap.style.display = 'block';
|
posterWrapEl.style.display = 'block';
|
||||||
status.textContent = '✅ 已获取信息与海报';
|
setFetchStatus('✅ 已获取信息与海报', '#27ae60');
|
||||||
} else {
|
} else {
|
||||||
keyInput.value = '';
|
fetchedKeyEl.value = '';
|
||||||
wrap.style.display = 'none';
|
posterWrapEl.style.display = 'none';
|
||||||
status.textContent = '✅ 已获取基本信息(该片暂无海报)';
|
setFetchStatus('✅ 已获取基本信息(该片暂无海报)', '#27ae60');
|
||||||
}
|
}
|
||||||
status.style.color = '#27ae60';
|
|
||||||
btn.disabled = false;
|
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
status.style.color = '#e74c3c';
|
setFetchStatus('❌ 网络错误,请稍后重试', '#e74c3c');
|
||||||
status.textContent = '❌ 网络错误,请稍后重试';
|
|
||||||
btn.disabled = false;
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
fetchBtnEl.disabled = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchBtnEl.addEventListener('click', function () {
|
||||||
|
const title = movieTitleEl.value.trim();
|
||||||
|
if (!title) {
|
||||||
|
setFetchStatus('⚠️ 请先输入电影名称', '#e74c3c');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runFetch('q=' + encodeURIComponent(title));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 回车触发获取
|
// ---------- 片名下拉联想 ----------
|
||||||
document.getElementById('movieTitle').addEventListener('keypress', function(e) {
|
// 输入几个字 → 服务端只做一次搜索(不取详情、不下载海报)→ 下拉展示候选。
|
||||||
|
// 选中后按豆瓣条目 id 直达详情,省一次搜索,也不会「重新搜一次选错条目」。
|
||||||
|
(function () {
|
||||||
|
const listEl = document.getElementById('titleSuggest');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
const CACHE = new Map(); // 关键词 -> items,同一会话内不重复请求
|
||||||
|
const DEBOUNCE = 320;
|
||||||
|
let timer = null;
|
||||||
|
let ctrl = null; // 用于丢弃过期的在途请求
|
||||||
|
let items = [];
|
||||||
|
let active = -1;
|
||||||
|
let composing = false; // 中文输入法组字中
|
||||||
|
|
||||||
|
function closeList() {
|
||||||
|
listEl.hidden = true;
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
movieTitleEl.setAttribute('aria-expanded', 'false');
|
||||||
|
items = [];
|
||||||
|
active = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(rows, hintText) {
|
||||||
|
if (!rows.length) {
|
||||||
|
if (!hintText) { closeList(); return; }
|
||||||
|
listEl.innerHTML = '<div class="ac-hint">' + acEsc(hintText) + '</div>';
|
||||||
|
listEl.hidden = false;
|
||||||
|
movieTitleEl.setAttribute('aria-expanded', 'true');
|
||||||
|
items = [];
|
||||||
|
active = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items = rows;
|
||||||
|
active = -1;
|
||||||
|
listEl.innerHTML = rows.map(function (r, i) {
|
||||||
|
const thumb = r.thumb
|
||||||
|
? '<img class="ac-thumb" src="' + acEsc(r.thumb) + '" alt="" loading="lazy">'
|
||||||
|
: '<div class="ac-thumb"></div>';
|
||||||
|
// 副标题:原名(与正名不同才显示)、年份、电影/剧集、集数
|
||||||
|
const sub = [
|
||||||
|
(r.sub_title && r.sub_title !== r.title) ? r.sub_title : '',
|
||||||
|
r.year,
|
||||||
|
r.kind,
|
||||||
|
(parseInt(r.episode, 10) > 1) ? (r.episode + ' 集') : ''
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
return '<div class="ac-item" role="option" data-i="' + i + '">' + thumb +
|
||||||
|
'<div class="ac-meta">' +
|
||||||
|
'<div class="ac-title">' + acEsc(r.title) + '</div>' +
|
||||||
|
'<div class="ac-sub">' + acEsc(sub) + '</div>' +
|
||||||
|
'</div></div>';
|
||||||
|
}).join('');
|
||||||
|
listEl.hidden = false;
|
||||||
|
movieTitleEl.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight() {
|
||||||
|
Array.prototype.forEach.call(listEl.querySelectorAll('.ac-item'), function (el, i) {
|
||||||
|
el.classList.toggle('active', i === active);
|
||||||
|
});
|
||||||
|
const cur = listEl.querySelector('.ac-item.active');
|
||||||
|
if (cur) cur.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(delta) {
|
||||||
|
if (!items.length) return;
|
||||||
|
active = (active + delta + items.length) % items.length;
|
||||||
|
highlight();
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(i) {
|
||||||
|
const it = items[i];
|
||||||
|
if (!it) return;
|
||||||
|
closeList();
|
||||||
|
movieTitleEl.value = it.title;
|
||||||
|
runFetch('id=' + encodeURIComponent(it.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(kw) {
|
||||||
|
if (CACHE.has(kw)) {
|
||||||
|
render(CACHE.get(kw));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctrl) ctrl.abort(); // 丢掉上一次在途请求
|
||||||
|
ctrl = new AbortController();
|
||||||
|
const mine = ctrl;
|
||||||
|
|
||||||
|
render([], '⏳ 搜索中…');
|
||||||
|
|
||||||
|
fetch('fetch_movie.php?action=suggest&q=' + encodeURIComponent(kw),
|
||||||
|
{ signal: mine.signal })
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (movieTitleEl.value.trim() !== kw) return; // 期间又输入了,结果作废
|
||||||
|
const rows = (data && data.items) ? data.items : [];
|
||||||
|
CACHE.set(kw, rows);
|
||||||
|
render(rows, '没有找到相关影视,试试补充更完整的片名');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (err && err.name === 'AbortError') return;
|
||||||
|
render([], '联想暂时不可用,可直接点「📡 自动获取」');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
const kw = movieTitleEl.value.trim();
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!kw) { closeList(); return; }
|
||||||
|
timer = setTimeout(function () { request(kw); }, DEBOUNCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文输入法:组字期间不请求(拼音串没意义),也不劫持 Enter(此时 Enter 是选字)
|
||||||
|
movieTitleEl.addEventListener('compositionstart', function () { composing = true; });
|
||||||
|
movieTitleEl.addEventListener('compositionend', function () {
|
||||||
|
composing = false;
|
||||||
|
schedule();
|
||||||
|
});
|
||||||
|
movieTitleEl.addEventListener('input', function () {
|
||||||
|
if (!composing) schedule();
|
||||||
|
});
|
||||||
|
|
||||||
|
movieTitleEl.addEventListener('keydown', function (e) {
|
||||||
|
if (e.isComposing || composing) return; // 交给输入法处理
|
||||||
|
|
||||||
|
if (!listEl.hidden && items.length) {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); return; }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); return; }
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.getElementById('fetchBtn').click();
|
choose(active >= 0 ? active : 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') { closeList(); return; }
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeList();
|
||||||
|
fetchBtnEl.click(); // 无候选时,Enter 等价于点「自动获取」
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 用 mousedown + preventDefault:否则会先失焦关掉列表,导致点不中
|
||||||
|
listEl.addEventListener('mousedown', function (e) {
|
||||||
|
const el = e.target.closest ? e.target.closest('.ac-item') : null;
|
||||||
|
if (!el) return;
|
||||||
|
e.preventDefault();
|
||||||
|
choose(parseInt(el.dataset.i, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点到别处则关闭
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (listEl.hidden) return;
|
||||||
|
if (e.target === movieTitleEl || listEl.contains(e.target)) return;
|
||||||
|
closeList();
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+183
-15
@@ -10,10 +10,20 @@
|
|||||||
// 2) 详情 https://m.douban.com/rexxar/api/v2/movie/<id>
|
// 2) 详情 https://m.douban.com/rexxar/api/v2/movie/<id>
|
||||||
// 3) 海报 https://imgN.doubanio.com/...(需带 Referer)
|
// 3) 海报 https://imgN.doubanio.com/...(需带 Referer)
|
||||||
//
|
//
|
||||||
// 用法: GET ?q=片名[&old_poster=上一次抓取、尚未采用的海报键]
|
// 用法(三种模式,均需登录):
|
||||||
// 返回: JSON 成功 -> { ok, source, title, year, genre, runtime, director,
|
// 1) ?q=片名[&old_poster=键] 按片名搜索 → 取详情 → 下载海报入库
|
||||||
|
// 2) ?id=豆瓣条目id[&old_poster=键]
|
||||||
|
// 跳过搜索,直接取该条目详情(下拉选中后用它,
|
||||||
|
// 既省一次搜索,也避免「再搜一次选错条目」)
|
||||||
|
// 3) ?action=suggest&q=关键词 只返回候选列表,不取详情、不下载海报、不写任何文件
|
||||||
|
// 4) ?action=thumb&u=<图片地址> 代理豆瓣缩略图(豆瓣图片有防盗链,浏览器直连会 403)
|
||||||
|
//
|
||||||
|
// 返回: JSON 1/2 成功 -> { ok, source, title, year, genre, runtime, director,
|
||||||
// actors, plot, rating, country, alias,
|
// actors, plot, rating, country, alias,
|
||||||
// poster, poster_key, douban_id, douban_url }
|
// poster, poster_key, douban_id, douban_url }
|
||||||
|
// 3 成功 -> { ok, items: [ { id, title, sub_title, year,
|
||||||
|
// episode, kind, thumb } ] }
|
||||||
|
// 4 成功 -> 直接回吐图片字节(Content-Type: image/*)
|
||||||
// 失败 -> { error: "..." }
|
// 失败 -> { error: "..." }
|
||||||
//
|
//
|
||||||
// 说明:
|
// 说明:
|
||||||
@@ -21,6 +31,10 @@
|
|||||||
// - poster_key 是提交表单时应写入 movies.poster_path 的存储键
|
// - poster_key 是提交表单时应写入 movies.poster_path 的存储键
|
||||||
// - 每次抓取都会把海报下载到 uploads/posters/,因此重复点击需要清理上一张
|
// - 每次抓取都会把海报下载到 uploads/posters/,因此重复点击需要清理上一张
|
||||||
// (前端把上一张的键回传为 old_poster,由本接口删除,避免堆积垃圾文件)
|
// (前端把上一张的键回传为 old_poster,由本接口删除,避免堆积垃圾文件)
|
||||||
|
// - action=suggest / action=thumb 都**不写任何文件**,可安全地随输入频繁调用
|
||||||
|
// - 网页接口地址可用环境变量覆盖,便于本地 mock 测试:
|
||||||
|
// MOVIELOG_DOUBAN_SEARCH / MOVIELOG_DOUBAN_DETAIL / MOVIELOG_DOUBAN_DETAIL_REF
|
||||||
|
// MOVIELOG_DOUBAN_IMG_ALLOW(仅测试用,逗号分隔的完整来源白名单)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
@@ -194,6 +208,95 @@ function imageMimeFromMagic($binary) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 图片地址白名单 ----------
|
||||||
|
//
|
||||||
|
// action=thumb 是「服务端代取」,如果放任它取任意 URL 就成了 SSRF 跳板。
|
||||||
|
// 因此这里对地址做严格白名单:只允许豆瓣图片 CDN(img*.doubanio.com)下的图片路径。
|
||||||
|
//
|
||||||
|
// MOVIELOG_DOUBAN_IMG_ALLOW 是给本地端到端测试留的口子:只有运维显式设置它
|
||||||
|
// (逗号分隔的完整来源,如 "http://127.0.0.1:8198")才生效,用来把 mock 服务放行。
|
||||||
|
// 默认不设置时行为与纯豆瓣白名单完全一致。
|
||||||
|
function doubanImageAllowedOrigins() {
|
||||||
|
$raw = getenv('MOVIELOG_DOUBAN_IMG_ALLOW');
|
||||||
|
if (!$raw) return [];
|
||||||
|
$out = [];
|
||||||
|
foreach (explode(',', $raw) as $o) {
|
||||||
|
$o = rtrim(trim($o), '/');
|
||||||
|
if ($o !== '') $out[] = $o;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function originOfUrl(array $p) {
|
||||||
|
$scheme = isset($p['scheme']) ? strtolower($p['scheme']) : '';
|
||||||
|
$host = isset($p['host']) ? strtolower($p['host']) : '';
|
||||||
|
if ($scheme === '' || $host === '') return '';
|
||||||
|
$port = isset($p['port']) ? ':' . $p['port'] : '';
|
||||||
|
return $scheme . '://' . $host . $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDoubanImageUrl($url) {
|
||||||
|
if (!is_string($url) || $url === '') return false;
|
||||||
|
$p = parse_url($url);
|
||||||
|
if (!$p || empty($p['host']) || empty($p['path'])) return false;
|
||||||
|
|
||||||
|
$origin = originOfUrl($p);
|
||||||
|
foreach (doubanImageAllowedOrigins() as $allowed) {
|
||||||
|
if ($origin !== '' && strcasecmp($origin, strtolower($allowed)) === 0) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($p['scheme'] ?? '') !== 'https') return false;
|
||||||
|
if (!preg_match('/^img\d*\.doubanio\.com$/i', $p['host'])) return false;
|
||||||
|
if (!preg_match('#^/view/photo/[A-Za-z0-9_/]+\.(?:jpg|jpeg|png|webp)$#i', $p['path'])) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把豆瓣图片地址转成「本站代理地址」。不能把豆瓣地址直接交给 <img>:
|
||||||
|
// 豆瓣图片有防盗链,浏览器直连一律 403(本站 Referrer-Policy: same-origin,
|
||||||
|
// 跨域图片不带 Referer,同样被拒)。
|
||||||
|
function thumbProxyUrl($img) {
|
||||||
|
if (!validateDoubanImageUrl($img)) return '';
|
||||||
|
return 'fetch_movie.php?action=thumb&u=' . rawurlencode($img);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断候选是电影还是剧集:豆瓣的 suggest 里两者 type 都是 "movie",
|
||||||
|
// 只能靠 episode(集数)区分——有集数就是剧集。
|
||||||
|
function doubanKindLabel($type, $episode) {
|
||||||
|
if ((int)$episode > 1) return '剧集';
|
||||||
|
if ($type === '' || $type === 'movie') return '电影';
|
||||||
|
return (string)$type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把 suggest 的原始结果整理成前端下拉要用的字段(过滤影人、去重、限量)
|
||||||
|
function buildSuggestItems(array $list, $limit = 8) {
|
||||||
|
$items = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($list as $it) {
|
||||||
|
if (!is_array($it) || empty($it['id'])) continue;
|
||||||
|
$type = isset($it['type']) ? (string)$it['type'] : 'movie';
|
||||||
|
if (in_array($type, ['celebrity', 'person', 'actor', 'director'], true)) continue;
|
||||||
|
|
||||||
|
$id = (string)$it['id'];
|
||||||
|
if (isset($seen[$id])) continue;
|
||||||
|
$seen[$id] = true;
|
||||||
|
|
||||||
|
$img = isset($it['img']) ? (string)$it['img'] : '';
|
||||||
|
$episode = isset($it['episode']) ? (string)$it['episode'] : '';
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'title' => isset($it['title']) ? (string)$it['title'] : '',
|
||||||
|
'sub_title' => isset($it['sub_title']) ? (string)$it['sub_title'] : '',
|
||||||
|
'year' => isset($it['year']) ? (string)$it['year'] : '',
|
||||||
|
'episode' => $episode,
|
||||||
|
'kind' => doubanKindLabel($type, $episode),
|
||||||
|
'thumb' => $img !== '' ? thumbProxyUrl($img) : '',
|
||||||
|
];
|
||||||
|
if (count($items) >= $limit) break;
|
||||||
|
}
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
// 删除上一次抓取但未被采用的海报。
|
// 删除上一次抓取但未被采用的海报。
|
||||||
// 只删 uploads/posters/ 下的文件,且必须确认没有任何电影记录引用它,才动手。
|
// 只删 uploads/posters/ 下的文件,且必须确认没有任何电影记录引用它,才动手。
|
||||||
function cleanupOldPoster($key) {
|
function cleanupOldPoster($key) {
|
||||||
@@ -243,30 +346,95 @@ if (!isset($_SESSION['admin_id'])) {
|
|||||||
fetchFail('未登录或登录已过期,请重新登录后再试', 401);
|
fetchFail('未登录或登录已过期,请重新登录后再试', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$action = isset($_GET['action']) ? (string)$_GET['action'] : '';
|
||||||
|
|
||||||
|
// ---- 模式 3:片名候选列表(供下拉联想;只读,不取详情、不写任何文件)----
|
||||||
|
if ($action === 'suggest') {
|
||||||
|
$kw = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
|
||||||
|
if ($kw === '') {
|
||||||
|
echo json_encode(['ok' => true, 'items' => []], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$res = getJson(doubanSearchEndpoint($kw));
|
||||||
|
if (!$res['ok']) {
|
||||||
|
fetchFail('搜索失败:' . $res['err']);
|
||||||
|
}
|
||||||
|
$list = is_array($res['data']) ? $res['data'] : [];
|
||||||
|
echo json_encode(
|
||||||
|
['ok' => true, 'items' => buildSuggestItems($list)],
|
||||||
|
JSON_UNESCAPED_UNICODE
|
||||||
|
);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 模式 4:代理豆瓣缩略图 ----
|
||||||
|
// 浏览器不能直连豆瓣图片(防盗链,见 validateDoubanImageUrl 注释),只能由服务端代取。
|
||||||
|
// 只读、不落盘;配 Cache-Control 让浏览器缓存,重复展开下拉不再回源。
|
||||||
|
if ($action === 'thumb') {
|
||||||
|
$u = isset($_GET['u']) ? (string)$_GET['u'] : '';
|
||||||
|
if (!validateDoubanImageUrl($u)) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$res = httpGet($u, 'https://movie.douban.com/');
|
||||||
|
$binary = $res['ok'] ? $res['body'] : '';
|
||||||
|
if ($binary === '') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$mime = storageMimeFromContents($binary);
|
||||||
|
if (strpos($mime, 'image/') !== 0) {
|
||||||
|
$mime = imageMimeFromMagic($binary); // 环境无 fileinfo、或返回的不是图片
|
||||||
|
}
|
||||||
|
if ($mime === '') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
header('Content-Type: ' . $mime); // 覆盖开头设的 JSON 类型
|
||||||
|
header('Content-Length: ' . strlen($binary));
|
||||||
|
header('Cache-Control: public, max-age=86400');
|
||||||
|
echo $binary;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action !== '') {
|
||||||
|
fetchFail('不支持的 action:' . $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 模式 1/2:抓取详情 + 下载海报入库 ----
|
||||||
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
|
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
|
||||||
if ($q === '') {
|
$id = isset($_GET['id']) ? trim((string)$_GET['id']) : '';
|
||||||
|
if ($q === '' && $id === '') {
|
||||||
fetchFail('请输入电影名称');
|
fetchFail('请输入电影名称');
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldPoster = isset($_GET['old_poster']) ? trim((string)$_GET['old_poster']) : '';
|
$oldPoster = isset($_GET['old_poster']) ? trim((string)$_GET['old_poster']) : '';
|
||||||
|
|
||||||
// 1) 搜索
|
// $cand 供后续取标题/年份/海报兜底;走 id 直连时没有搜索候选,留空数组即可
|
||||||
$search = getJson(doubanSearchEndpoint($q));
|
$cand = [];
|
||||||
if (!$search['ok']) {
|
if ($id !== '') {
|
||||||
|
// 模式 2:下拉选中后直接按条目 id 取详情——省一次搜索,也避免「再搜一次选错条目」
|
||||||
|
if (!preg_match('/^\d{1,12}$/', $id)) {
|
||||||
|
fetchFail('无效的豆瓣条目 ID');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 模式 1:按片名搜索
|
||||||
|
$search = getJson(doubanSearchEndpoint($q));
|
||||||
|
if (!$search['ok']) {
|
||||||
fetchFail('搜索失败:' . $search['err']);
|
fetchFail('搜索失败:' . $search['err']);
|
||||||
}
|
}
|
||||||
$list = is_array($search['data']) ? $search['data'] : [];
|
$list = is_array($search['data']) ? $search['data'] : [];
|
||||||
if (empty($list)) {
|
if (empty($list)) {
|
||||||
fetchFail('豆瓣没有找到「' . $q . '」,换个更准确的片名试试');
|
fetchFail('豆瓣没有找到「' . $q . '」,换个更准确的片名试试');
|
||||||
}
|
}
|
||||||
|
$picked = pickCandidate($list, $q);
|
||||||
$cand = pickCandidate($list, $q);
|
if (!$picked || empty($picked['id'])) {
|
||||||
if (!$cand || empty($cand['id'])) {
|
|
||||||
fetchFail('豆瓣没有找到「' . $q . '」的影视条目,换个更准确的片名试试');
|
fetchFail('豆瓣没有找到「' . $q . '」的影视条目,换个更准确的片名试试');
|
||||||
|
}
|
||||||
|
$cand = $picked;
|
||||||
|
$id = (string)$picked['id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$id = (string)$cand['id'];
|
|
||||||
|
|
||||||
// 2) 详情
|
// 2) 详情
|
||||||
$detail = getJson(doubanDetailEndpoint($id), doubanDetailReferer($id));
|
$detail = getJson(doubanDetailEndpoint($id), doubanDetailReferer($id));
|
||||||
if (!$detail['ok']) {
|
if (!$detail['ok']) {
|
||||||
|
|||||||
Reference in New Issue
Block a user