feat: 「自动获取」改用豆瓣数据源,海报自动下载入库

此前的 OMDb 数据源必须用英文片名,国产片覆盖极差。改用豆瓣公开接口后
中文片名直接可搜,冷门片/老片/剧集也能命中。

fetch_movie.php(全量重写)
- 搜索走 movie.douban.com/j/subject_suggest;详情走移动端
  m.douban.com/rexxar/api/v2/movie/<id>(PC 详情页有反爬 302,故用移动端接口)
- 海报下载后写入 uploads/posters/,文件名沿用内容指纹规则
- 新增登录鉴权:未登录返回 401(此前该接口完全公开)
- 启用 TLS 证书校验(原为 CURLOPT_SSL_VERIFYPEER=false)
- 魔数兜底 + 12MB 上限:挡掉反爬返回的 HTML 页面与异常大响应
- old_poster 残留清理:重复点击时删掉上一张,且仅删「未被数据库引用」的文件
- 接口地址支持环境变量覆盖,便于用 mock 做端到端测试

lib/helpers.php
- 新增 isValidStoredKey():校验表单回传的存储键,防目录穿越与越权引用

add.php
- 前端改用新字段(豆瓣评分 / 地区 / 又名),并把 poster_key 放进隐藏域回传
- 提交时「手动上传的海报优先」,没有才采用自动获取已入库的海报

README.md
- 同步为豆瓣数据源说明,移除已过时的 OMDb API Key 与 SSL 校验两条安全提示

验证
- 本地 mock 豆瓣端到端:33/33 通过
- 既有存储回归套件:196/196 通过
- A/B 归一化比对:除 add.php 按预期变化外,其余 45 个响应完全一致
- 线上实测:流浪地球 / 哪吒之魔童闹海 均抓取成功,海报落盘为合法 JPEG
This commit is contained in:
2026-09-21 23:05:40 +08:00
parent 294c34bbc2
commit ddf2eeafaa
4 changed files with 416 additions and 100 deletions
+42 -20
View File
@@ -30,7 +30,7 @@
- **两种图片访问方式**:**直连存储**(浏览器直接访问对象存储)与**服务端代理**(经本站 `media.php` 鉴权后由服务器读取回吐,**桶可保持私有**)。两者的取舍与配置见下文《图片存储与对象存储》。
### 信息辅助
- **OMDb 自动获取**:在添加页输入片名后点击「📡 自动获取」,自动填充年份、类型、片长、导演、主演、IMDb 评分与剧情简介,并预览海报(海报仅供参考,仍需本地上传)。
- **豆瓣自动获取**:在添加页输入片名(中文即可,英文片名也能命中对应条目)后点击「📡 自动获取」,自动填充年份、地区、类型、片长、导演、主演、豆瓣评分、又名与剧情简介,**并把海报下载入库**——提交时若未手动上传图片,就直接采用自动获取的海报。
- **CSV 批量导入**:一次性导入历史数据,自动识别 UTF-8 / GBK / GB2312 编码,自动创建不存在的影院,影院名去重,全程事务保护(出错自动回滚),并输出逐行错误报告。
### 检索与统计
@@ -69,7 +69,7 @@
| 数据库 | SQLite(`db/movies.db`,首次运行自动建表) |
| 前端 | 原生 HTML / CSS / JavaScript,无构建步骤 |
| 第三方库 | [Cropper.js 1.5.13](https://github.com/fengyuanchen/cropperjs)(图片裁剪)、[Chart.js 4.4.0](https://www.chartjs.org/)(图表),均由 CDN 引入 |
| 外部 API | [OMDb API](http://www.omdbapi.com/)(电影信息查询,需申请 API Key) |
| 外部 API | [豆瓣](https://movie.douban.com/)(公开网页接口,**无需申请 Key**,用于电影信息与海报查询) |
---
@@ -112,7 +112,7 @@ MovieLog_Server/
├── settings.php # 系统设置页:存储后端选择、图片访问方式、连接测试、迁移
├── storage_api.php # 存储维护接口(stat / migrate / test),返回 JSON
├── media.php # 服务端图片代理:鉴权后由服务器读取对象并同源回吐
├── fetch_movie.php # OMDb 代理接口,返回 JSON
├── fetch_movie.php # 豆瓣抓取接口(搜索 + 详情 + 海报入库),返回 JSON
├── test_crop.php # 开发调试页:验证 Base64 图片落盘是否正常
├── css/style.css # 全局样式
├── js/main.js # 公共脚本(含各页面共用的表单联动函数、破图兜底)
@@ -179,7 +179,11 @@ MovieLog_Server/
chmod -R 755 db uploads
```
3. **配置 OMDb API Key**(可选,仅影响「自动获取」功能):编辑 `fetch_movie.php`,将 `OMDB_API_KEY` 换成你在 [omdbapi.com](http://www.omdbapi.com/apikey.aspx) 申请的免费 Key。
3. **确认「自动获取」可用(可选)**:该功能由服务端直接请求豆瓣的公开网页接口实现,**无需申请任何 API Key**。只需保证:
- PHP 已启用 `curl` 扩展(本项目的图片下载、对象存储也都依赖它);
- 服务器能出网访问 `movie.douban.com`、`m.douban.com` 与 `img*.doubanio.com`。
若服务器网络受限导致请求失败,「自动获取」会返回明确提示,但**不影响其它功能**——照常手动填写信息、上传海报即可。
4. **访问站点**:浏览器打开 `login.php`。
@@ -277,27 +281,45 @@ CSV **共 9 列,顺序固定**:
### 接口:`fetch_movie.php`
供添加页前端调用,服务端代请求 OMDb 避免浏览器跨域。
供添加页前端调用,服务端代请求豆瓣(既避免浏览器跨域,也复用服务器的出网能力)。
- **请求**:`GET fetch_movie.php?q=电影名称`
- **请求**:`GET fetch_movie.php?q=片名[&old_poster=上一次抓取的海报键]`
- `q`:片名,中文或英文均可(英文名会命中对应的中文条目)
- `old_poster`:可选。用于清理「上一次抓取、但本次未采用」的海报文件,前端在重复点击时自动回传
- **需要登录**:未登录返回 `HTTP 401` + `{"error": "…"}`
- **响应**:`Content-Type: application/json`
- **成功**:
```json
{
"title": "Forrest Gump",
"year": "1994",
"rated": "PG-13",
"released": "06 Jul 1994",
"runtime": "142 min",
"genre": "Drama, Romance",
"director": "Robert Zemeckis",
"actors": "Tom Hanks, Robin Wright, Gary Sinise",
"plot": "...",
"poster": "https://...",
"imdbRating": "8.8"
"ok": true,
"source": "douban",
"douban_id": "34780991",
"douban_url": "https://movie.douban.com/subject/34780991/",
"title": "哪吒之魔童闹海",
"original_title": "",
"year": "2025",
"genre": "剧情 / 喜剧 / 动画",
"runtime": "144分钟",
"director": "饺子",
"actors": "吕艳婷 / 囧森瑟夫 / 瀚墨",
"plot": "……剧情简介……",
"rating": "8.4",
"country": "中国大陆",
"alias": "哪吒2 / 哪吒2之魔童闹海 / Ne Zha 2",
"poster": "/uploads/posters/xxxxxxxxxxxx_xxxxxx_poster.jpg",
"poster_key": "uploads/posters/xxxxxxxxxxxx_xxxxxx_poster.jpg"
}
```
- **失败**:`{"error": "错误说明"}`(如「未找到相关电影,请尝试更准确的关键词」)
- **字段说明**:
- `poster` 是给 `<img>` 预览的地址。海报已由本接口**下载入库**,所以它是本站同源地址(或存储直链),不是豆瓣的外链。
- `poster_key` 是提交表单时应写入 `movies.poster_path` 的存储键。前端把它放进隐藏域 `fetched_poster_key` 回传;
提交时**手动上传的海报优先**,没有才采用该键。键的目录与文件名、以及文件是否真实存在,都由服务端校验。
- `rating` 为豆瓣评分(保留一位小数)。无评分、或该片暂无海报时,对应字段为空串。
- **失败**:`{"error": "错误说明"}`(如「豆瓣没有找到「xxx」,换个更准确的片名试试」)
> **关于数据来源与合规**:本功能使用的是豆瓣的**公开网页接口**(非官方开放 API,也不需要 API Key),
> 按需抓取少量条目用于个人观影记录,并已加入登录鉴权。请自行评估使用场景与豆瓣的服务条款;
> 若需商用或大规模采集,请改用官方或商业数据源。
---
@@ -539,10 +561,10 @@ curl -sk -H "Host: 你的域名" -H "Cookie: PHPSESSID=xxxx" -o /dev/null \
部署到公网前请务必处理以下事项:
1. **修改默认口令**:默认账号 `admin / 123456` 必须在首次登录后立即更换。
2. **更换 OMDb API Key**:`fetch_movie.php` 中当前硬编码的 Key 为开发期临时使用,请替换为你自己的 Key;若仓库需要公开,建议将该值改为从环境变量读取。
2. **「自动获取」会写入存储**:`fetch_movie.php` 已要求登录后才能调用,且只请求固定的豆瓣域名、只把文件写入 `uploads/posters/`;表单回传的 `fetched_poster_key` 与 `old_poster` 都要通过「目录前缀 + 文件名 + 数据库引用」三重校验才会被采用或删除。若你的部署不需要该功能,可直接删除 `fetch_movie.php` 并移除 `add.php` 里的按钮。
3. **删除或保护 `test_crop.php`**:该文件是开发调试页,**没有登录校验**,仅用于验证 Base64 图片能否落盘,生产环境应直接删除。
4. **关闭调试输出**:`config.php` 顶部当前为 `error_reporting(E_ALL)` + `display_errors=1`,上线前请改为关闭或仅写日志,避免泄露路径与数据库信息。
5. **启用 HTTPS 并校正证书校验**:`fetch_movie.php` 中 `CURLOPT_SSL_VERIFYPEER` 被设为 `false`,生产环境应改为 `true`。
5. **保证 CA 证书可用**:图片下载与「自动获取」都依赖 `curl`,且 `fetch_movie.php` 已启用证书校验(`CURLOPT_SSL_VERIFYPEER = true`)。若服务器缺少 CA 证书束(`curl.cainfo` 为空,常见于最小化安装),请求会失败,请安装 `ca-certificates` 包。
6. **保护数据目录**:`db/`(含 `movies.db`)与 `uploads/` 应禁止直接目录列举;SQLite 文件更应通过 Web 服务器规则**拒绝任何 HTTP 访问**。Nginx 示例:
```nginx
location ~ ^/(db|uploads/.*\.(db|sqlite)) { deny all; }
+39 -17
View File
@@ -1,6 +1,6 @@
<?php
// ============================================
// 文件: add.php (支持 OMDb 自动获取 + 票根裁剪)
// 文件: add.php (支持豆瓣自动获取 + 票根裁剪)
// ============================================
require_once 'config.php';
@@ -40,8 +40,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} elseif (empty($watch_date)) {
$error = '请选择观影日期';
} else {
// ----- 海报:直接上传 -----
// ----- 海报:优先手动上传,其次采用「自动获取」已抓取入库的海报 -----
$poster_path = uploadImageFile('poster', POSTER_DIR, 'uploads/posters/') ?? '';
if ($poster_path === '') {
// 前端把「自动获取」接口返回的 poster_key 放在隐藏域回传。
// 该键来自客户端,不可信任:需校验目录/文件名,并确认文件确实存在才采用。
$fetched_poster = trim($_POST['fetched_poster_key'] ?? '');
if (isValidStoredKey($fetched_poster, 'uploads/posters/') && storageExists($fetched_poster)) {
$poster_path = $fetched_poster;
}
}
// ----- 票根:裁剪结果优先,其次原始上传 -----
$ticket_path = '';
@@ -417,12 +425,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<div class="form-group">
<label>电影海报</label>
<input type="file" name="poster" accept="image/*" id="posterInput">
<input type="hidden" name="fetched_poster_key" id="fetchedPosterKey" value="">
<div id="posterPreviewWrap" style="display:none;margin-top:6px;">
<img id="posterPreview" style="max-width:150px;max-height:200px;border-radius:4px;border:1px solid var(--border-color);">
<br>
<span style="font-size:12px;color:var(--text-secondary);">预览(仅供参考,仍需上传图片文件)</span>
<span id="posterPreviewHint" style="font-size:12px;color:var(--text-secondary);"></span>
</div>
<div class="form-hint">推荐比例 2:3</div>
<div class="form-hint">推荐比例 2:3(「自动获取」会连同海报一起下载;手动上传时以上传的为准)</div>
</div>
<!-- 票根(带裁剪) -->
<div class="form-group">
@@ -571,7 +580,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
});
// ---------- OMDb 自动获取 ----------
// ---------- 豆瓣自动获取 ----------
document.getElementById('fetchBtn').addEventListener('click', function() {
const title = document.getElementById('movieTitle').value.trim();
const status = document.getElementById('fetchStatus');
@@ -585,9 +594,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
btn.disabled = true;
status.style.color = 'var(--text-secondary)';
status.textContent = '⏳ 正在获取...';
status.textContent = '⏳ 正在获取…';
fetch('fetch_movie.php?q=' + encodeURIComponent(title))
// 上一次抓取但仍未采用的海报键,交给服务端清理,避免重复点击堆积文件
const oldKey = document.getElementById('fetchedPosterKey').value;
let url = 'fetch_movie.php?q=' + encodeURIComponent(title);
if (oldKey) url += '&old_poster=' + encodeURIComponent(oldKey);
fetch(url)
.then(res => res.json())
.then(data => {
if (data.error) {
@@ -597,31 +611,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
return;
}
// 填充电影名称(自动补全)
document.getElementById('movieTitle').value = data.title;
// 片名以豆瓣的正名称为准(自动补全/纠正)
if (data.title) document.getElementById('movieTitle').value = data.title;
// 组装信息文本
// 组装电影信息文本
let infoText = '';
if (data.year) infoText += '📅 年份:' + data.year + '\n';
if (data.country) infoText += '🌍 地区:' + data.country + '\n';
if (data.genre) infoText += '🏷️ 类型:' + data.genre + '\n';
if (data.runtime) infoText += '⏱️ 片长:' + data.runtime + '\n';
if (data.director) infoText += '🎬 导演:' + data.director + '\n';
if (data.actors) infoText += '👤 主演:' + data.actors + '\n';
if (data.imdbRating && data.imdbRating !== 'N/A') infoText += '⭐ IMDb评分:' + data.imdbRating + '\n';
if (data.plot && data.plot !== 'N/A') infoText += '\n📝 ' + data.plot;
if (data.rating) infoText += '⭐ 豆瓣评分:' + data.rating + '\n';
if (data.alias) infoText += '🔖 又名:' + data.alias + '\n';
if (data.plot) infoText += '\n📝 ' + data.plot;
document.getElementById('movieInfo').value = infoText;
// 显示海报预览
if (data.poster && data.poster !== 'N/A') {
// 海报:已由服务端下载入库。这里记录键值(提交时写入 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) {
keyInput.value = data.poster_key;
preview.src = data.poster;
hint.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
wrap.style.display = 'block';
status.textContent = '✅ 已获取信息,海报仅供参考,仍需上传图片文件';
status.textContent = '✅ 已获取信息与海报';
} else {
document.getElementById('posterPreviewWrap').style.display = 'none';
status.textContent = '✅ 已获取基本信息(无海报)';
keyInput.value = '';
wrap.style.display = 'none';
status.textContent = '✅ 已获取基本信息(该片暂无海报)';
}
status.style.color = '#27ae60';
btn.disabled = false;
+305 -51
View File
@@ -1,76 +1,330 @@
<?php
// ============================================
// 文件: fetch_movie.php (OMDb 版)
// 说明: 根据电影名从 OMDb API 获取信息
// 用法: ?q=电影名称
// 文件: fetch_movie.php (豆瓣版)
// 说明: 根据片名从豆瓣抓取电影信息,并把海报下载入库。
// 豆瓣对中文片名的收录最全(国产片 / 剧集 / 老片 / 冷门片都有),
// 英文片名同样可以命中(豆瓣会把英文名归到对应中文条目,如 Inception → 盗梦空间)。
//
// 数据来源(均为公开网页接口,非官方 API):
// 1) 搜索 https://movie.douban.com/j/subject_suggest?q=片名
// 2) 详情 https://m.douban.com/rexxar/api/v2/movie/<id>
// 3) 海报 https://imgN.doubanio.com/...(需带 Referer)
//
// 用法: GET ?q=片名[&old_poster=上一次抓取、尚未采用的海报键]
// 返回: JSON 成功 -> { ok, source, title, year, genre, runtime, director,
// actors, plot, rating, country, alias,
// poster, poster_key, douban_id, douban_url }
// 失败 -> { error: "..." }
//
// 说明:
// - poster 是给 <img> 预览用的地址(已落盘,走本站同源或存储直链)
// - poster_key 是提交表单时应写入 movies.poster_path 的存储键
// - 每次抓取都会把海报下载到 uploads/posters/,因此重复点击需要清理上一张
// (前端把上一张的键回传为 old_poster,由本接口删除,避免堆积垃圾文件)
// ============================================
// 填入你申请的 OMDb API Key
define('OMDB_API_KEY', 'f5a3828');
require_once __DIR__ . '/config.php';
header('Content-Type: application/json; charset=utf-8');
$q = isset($_GET['q']) ? trim($_GET['q']) : '';
if (empty($q)) {
echo json_encode(['error' => '请输入电影名称']);
// 移动端 UA:豆瓣的移动端接口(rexxar)对移动端 UA 正常返回,
// 桌面 UA 打 PC 详情页会被反爬 302,因此统一用移动端 UA。
define('DOUBAN_UA', 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) '
. 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1');
// ---------------- 基础工具 ----------------
function fetchFail($msg, $http = 200) {
if ($http !== 200) {
http_response_code($http);
}
echo json_encode(['error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
// OMDb API 请求 URL(按标题搜索,返回完整信息)
$url = 'http://www.omdbapi.com/?i=tt3896198&apikey=' . OMDB_API_KEY . '&t=' . urlencode($q) . '&plot=full';
// 发起一次 GET 请求,返回 [ok, http, body, err]
function httpGet($url, $referer = '', $accept = '') {
$headers = [];
if ($referer !== '') $headers[] = 'Referer: ' . $referer;
if ($accept !== '') $headers[] = 'Accept: ' . $accept;
// 发起请求
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_USERAGENT, 'MovieLog/1.0');
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 15,
CURLOPT_USERAGENT => DOUBAN_UA,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_ENCODING => '', // 自动协商并解压 gzip
CURLOPT_SSL_VERIFYPEER => true, // 服务器已配置 CA 束,开启证书校验
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($curlError) {
echo json_encode(['error' => '网络请求失败: ' . $curlError]);
exit;
if ($body === false) {
return ['ok' => false, 'http' => $http, 'body' => '', 'err' => $err ?: '请求失败'];
}
if ($http < 200 || $http >= 300) {
return ['ok' => false, 'http' => $http, 'body' => (string)$body, 'err' => 'HTTP ' . $http];
}
return ['ok' => true, 'http' => $http, 'body' => (string)$body, 'err' => ''];
}
if ($httpCode != 200) {
echo json_encode(['error' => 'API 返回 HTTP ' . $httpCode]);
exit;
function getJson($url, $referer = '') {
$res = httpGet($url, $referer, 'application/json');
if (!$res['ok']) {
return ['ok' => false, 'err' => $res['err'], 'data' => null];
}
$data = json_decode($res['body'], true);
if (!is_array($data)) {
return ['ok' => false, 'err' => '返回内容不是合法 JSON(可能被反爬拦截)', 'data' => null];
}
return ['ok' => true, 'err' => '', 'data' => $data];
}
$data = json_decode($response, true);
if (!$data) {
echo json_encode(['error' => '解析数据失败']);
exit;
// 接口地址支持用环境变量覆盖,便于本地用 mock 服务做端到端测试
function doubanSearchEndpoint($q) {
$base = getenv('MOVIELOG_DOUBAN_SEARCH');
$base = $base ? rtrim($base, '/') : 'https://movie.douban.com/j/subject_suggest';
return $base . '?q=' . rawurlencode($q);
}
// 检查是否找到电影
if (isset($data['Response']) && $data['Response'] === 'False') {
echo json_encode(['error' => '未找到相关电影,请尝试更准确的关键词']);
exit;
function doubanDetailEndpoint($id) {
$base = getenv('MOVIELOG_DOUBAN_DETAIL');
$base = $base ? rtrim($base, '/') : 'https://m.douban.com/rexxar/api/v2/movie';
return $base . '/' . rawurlencode($id);
}
// 提取需要的字段
$result = [
'title' => $data['Title'] ?? '',
'year' => $data['Year'] ?? '',
'rated' => $data['Rated'] ?? '',
'released'=> $data['Released'] ?? '',
'runtime' => $data['Runtime'] ?? '',
'genre' => $data['Genre'] ?? '',
'director'=> $data['Director'] ?? '',
'actors' => $data['Actors'] ?? '',
'plot' => $data['Plot'] ?? '',
'poster' => $data['Poster'] ?? '',
'imdbRating' => $data['imdbRating'] ?? '',
function doubanDetailReferer($id) {
$ref = getenv('MOVIELOG_DOUBAN_DETAIL_REF');
return $ref ? $ref : ('https://m.douban.com/movie/subject/' . rawurlencode($id) . '/');
}
// 规范化片名用于匹配:去空白、去常见标点、转小写
function normalizeTitle($s) {
$s = (string)$s;
$s = preg_replace('/[\s\x{3000}]+/u', '', $s);
$s = str_replace([',', ',', '。', '·', '・', ':', ':', '!', '!', '?', '?', '、', '《', '》'], '', $s);
return function_exists('mb_strtolower') ? mb_strtolower($s, 'UTF-8') : strtolower($s);
}
// 从搜索候选里挑出最可能的那一条:同名优先、有海报优先、有年份优先
function pickCandidate(array $list, $q) {
$qNorm = normalizeTitle($q);
$best = null;
$bestScore = -1;
foreach ($list as $item) {
if (!is_array($item) || empty($item['id'])) continue;
$type = isset($item['type']) ? (string)$item['type'] : 'movie';
if (in_array($type, ['celebrity', 'person', 'actor', 'director'], true)) continue;
$title = isset($item['title']) ? (string)$item['title'] : '';
$sub = isset($item['sub_title']) ? (string)$item['sub_title'] : '';
$score = 0;
if ($title !== '' && normalizeTitle($title) === $qNorm) $score += 5;
if ($sub !== '' && normalizeTitle($sub) === $qNorm) $score += 5;
if (!empty($item['img'])) $score += 2; // 有海报
if (!empty($item['year'])) $score += 1; // 有年份,更像正式条目
if ($best === null || $score > $bestScore) {
$best = $item;
$bestScore = $score;
}
}
return $best;
}
// 把若干「名字对象」拼成字符串,如 [{'name':'饺子'}] -> '饺子'
function joinNames($list, $limit = 0) {
if (!is_array($list)) return '';
$names = [];
foreach ($list as $it) {
if (is_array($it) && !empty($it['name'])) {
$names[] = (string)$it['name'];
} elseif (is_string($it) && $it !== '') {
$names[] = $it;
}
}
if ($limit > 0) {
$names = array_slice($names, 0, $limit);
}
return implode(' / ', $names);
}
// 把字符串数组拼成 ' / ' 分隔的字符串
function joinList($list, $limit = 0) {
if (!is_array($list)) return '';
$vals = [];
foreach ($list as $v) {
if (is_string($v) && $v !== '') $vals[] = $v;
elseif (is_scalar($v) && (string)$v !== '') $vals[] = (string)$v;
}
if ($limit > 0) {
$vals = array_slice($vals, 0, $limit);
}
return implode(' / ', $vals);
}
function extForMime($mime) {
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
// 如果海报是 'N/A',置空
if ($result['poster'] === 'N/A') {
$result['poster'] = '';
return isset($map[$mime]) ? $map[$mime] : 'jpg';
}
echo json_encode($result);
// 按魔数判断图片类型;不是已知图片则返回空串。
// 兜底用:既应对「环境没有 fileinfo」,也用来挡掉反爬返回的 HTML 页面。
function imageMimeFromMagic($binary) {
if (strncmp($binary, "\xFF\xD8\xFF", 3) === 0) return 'image/jpeg';
if (strncmp($binary, "\x89PNG\r\n\x1A\n", 8) === 0) return 'image/png';
if (strncmp($binary, "GIF87a", 6) === 0 || strncmp($binary, "GIF89a", 6) === 0) return 'image/gif';
if (strlen($binary) >= 12 && substr($binary, 0, 4) === 'RIFF' && substr($binary, 8, 4) === 'WEBP') {
return 'image/webp';
}
return '';
}
// 删除上一次抓取但未被采用的海报。
// 只删 uploads/posters/ 下的文件,且必须确认没有任何电影记录引用它,才动手。
function cleanupOldPoster($key) {
$key = ltrim((string)$key, '/');
if (!preg_match('#^uploads/posters/[A-Za-z0-9._-]+$#', $key)) return;
try {
$db = getDB();
$stmt = $db->prepare("SELECT COUNT(*) FROM movies WHERE poster_path = ?");
$stmt->execute([$key]);
if ((int)$stmt->fetchColumn() > 0) return; // 已被引用,不能删
} catch (Exception $e) {
return; // 查询失败就保守地不删
}
storageDelete($key);
}
// 下载海报并入库,返回存储键(失败返回空串)
function storeRemotePoster($url) {
if (!is_string($url) || $url === '') return '';
// 豆瓣图片服务器要求带 Referer,否则 403
$res = httpGet($url, 'https://movie.douban.com/');
if (!$res['ok'] || $res['body'] === '') return '';
$binary = $res['body'];
// 海报通常几十 KB;给个上限,避免异常响应把内存吃满
if (strlen($binary) > 12 * 1024 * 1024) return '';
$mime = storageMimeFromContents($binary);
if (strpos($mime, 'image/') !== 0) {
// 环境无 fileinfo,或返回的不是图片(比如被反爬拦成 HTML 页面)
$mime = imageMimeFromMagic($binary);
if ($mime === '') return '';
}
$filename = fingerprintedFilename($binary, extForMime($mime), 'poster');
$key = relPathOfDir(POSTER_DIR) . $filename;
if (!storagePutContents($binary, $key, $mime)) return '';
return $key;
}
// ---------------- 主流程 ----------------
// 本接口会写入存储(下载海报),必须登录后才能调用。
// 这里不用 checkLogin()(它会 302 到 login.php),而是返回结构化错误,
// 便于前端在会话过期时给出明确提示。
if (!isset($_SESSION['admin_id'])) {
fetchFail('未登录或登录已过期,请重新登录后再试', 401);
}
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
if ($q === '') {
fetchFail('请输入电影名称');
}
$oldPoster = isset($_GET['old_poster']) ? trim((string)$_GET['old_poster']) : '';
// 1) 搜索
$search = getJson(doubanSearchEndpoint($q));
if (!$search['ok']) {
fetchFail('搜索失败:' . $search['err']);
}
$list = is_array($search['data']) ? $search['data'] : [];
if (empty($list)) {
fetchFail('豆瓣没有找到「' . $q . '」,换个更准确的片名试试');
}
$cand = pickCandidate($list, $q);
if (!$cand || empty($cand['id'])) {
fetchFail('豆瓣没有找到「' . $q . '」的影视条目,换个更准确的片名试试');
}
$id = (string)$cand['id'];
// 2) 详情
$detail = getJson(doubanDetailEndpoint($id), doubanDetailReferer($id));
if (!$detail['ok']) {
fetchFail('获取详情失败:' . $detail['err']);
}
$d = $detail['data'];
// 3) 组装字段
$title = isset($d['title']) && $d['title'] !== '' ? (string)$d['title'] : (string)($cand['title'] ?? $q);
$year = isset($d['year']) && $d['year'] !== '' ? (string)$d['year'] : (string)($cand['year'] ?? '');
$rating = '';
if (isset($d['rating']) && is_array($d['rating']) && isset($d['rating']['value'])) {
$rv = (float)$d['rating']['value'];
if ($rv > 0) $rating = number_format($rv, 1, '.', '');
}
// 海报地址优先级:详情 pic.normal(大图)> cover_url > 搜索结果的缩略图
$posterUrl = '';
if (isset($d['pic']['normal']) && $d['pic']['normal'] !== '') {
$posterUrl = (string)$d['pic']['normal'];
} elseif (isset($d['cover_url']) && $d['cover_url'] !== '') {
$posterUrl = (string)$d['cover_url'];
} elseif (!empty($cand['img'])) {
$posterUrl = (string)$cand['img'];
}
// 4) 海报入库
$posterKey = '';
$posterDisplay = '';
if ($posterUrl !== '') {
$posterKey = storeRemotePoster($posterUrl);
if ($posterKey !== '') {
$posterDisplay = storageUrl($posterKey);
}
}
// 无论海报是否成功,都把上一次的残留清掉(若这次成功,old 与 new 不同名)
if ($oldPoster !== '' && $oldPoster !== $posterKey) {
cleanupOldPoster($oldPoster);
}
// 5) 输出
echo json_encode([
'ok' => true,
'source' => 'douban',
'douban_id' => $id,
'douban_url' => 'https://movie.douban.com/subject/' . $id . '/',
'title' => $title,
'original_title' => isset($d['original_title']) ? (string)$d['original_title'] : '',
'year' => $year,
'genre' => joinList($d['genres'] ?? []),
'runtime' => joinList($d['durations'] ?? []),
'director' => joinNames($d['directors'] ?? []),
'actors' => joinNames($d['actors'] ?? [], 6),
'plot' => isset($d['intro']) ? (string)$d['intro'] : '',
'rating' => $rating,
'country' => joinList($d['countries'] ?? []),
'alias' => joinList($d['aka'] ?? [], 3),
'poster' => $posterDisplay,
'poster_key' => $posterKey,
], JSON_UNESCAPED_UNICODE);
+18
View File
@@ -71,6 +71,24 @@ function relPathOfDir($absDir) {
return rtrim($rel, '/') . '/';
}
// 校验一个表单回传的存储键是否合法:必须严格落在 $prefix 目录下,
// 且文件名不含任何路径分隔符 / 特殊字符。
//
// 用途:自动获取海报后,前端会把服务端抓取入库的 poster_key 回传;
// 这个键来自客户端,不可信任——要防止「目录穿越」和「引用别的目录下的图」
// 之类的越权操作。校验通过后仍应用 storageExists() 确认文件真实存在。
function isValidStoredKey($key, $prefix) {
$key = ltrim((string)$key, '/');
$prefix = rtrim((string)$prefix, '/') . '/';
if ($key === '' || strpos($key, $prefix) !== 0) {
return false;
}
$name = substr($key, strlen($prefix));
return $name !== ''
&& strpos($name, '/') === false
&& (bool)preg_match('/^[A-Za-z0-9._-]+$/', $name);
}
// 删除已保存的文件(本地与云端按当前存储方式处理,不存在时静默跳过)
function deleteStoredFile($relative_path) {
if (empty($relative_path)) return false;