diff --git a/README.md b/README.md
index 553d981..f50858c 100644
--- a/README.md
+++ b/README.md
@@ -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` 是给 `
` 预览的地址。海报已由本接口**下载入库**,所以它是本站同源地址(或存储直链),不是豆瓣的外链。
+ - `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; }
diff --git a/add.php b/add.php
index 32b0539..c191fe3 100644
--- a/add.php
+++ b/add.php
@@ -1,6 +1,6 @@
+
@@ -571,23 +580,28 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
});
- // ---------- OMDb 自动获取 ----------
+ // ---------- 豆瓣自动获取 ----------
document.getElementById('fetchBtn').addEventListener('click', function() {
const title = document.getElementById('movieTitle').value.trim();
const status = document.getElementById('fetchStatus');
const btn = this;
-
+
if (!title) {
status.style.color = '#e74c3c';
status.textContent = '⚠️ 请先输入电影名称';
return;
}
-
+
btn.disabled = true;
status.style.color = 'var(--text-secondary)';
- status.textContent = '⏳ 正在获取...';
-
- fetch('fetch_movie.php?q=' + encodeURIComponent(title))
+ status.textContent = '⏳ 正在获取…';
+
+ // 上一次抓取但仍未采用的海报键,交给服务端清理,避免重复点击堆积文件
+ 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) {
@@ -596,32 +610,40 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
btn.disabled = false;
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') {
- const preview = document.getElementById('posterPreview');
- const wrap = document.getElementById('posterPreviewWrap');
+
+ // 海报:已由服务端下载入库。这里记录键值(提交时写入 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;
diff --git a/fetch_movie.php b/fetch_movie.php
index c56c67b..66c37d9 100644
--- a/fetch_movie.php
+++ b/fetch_movie.php
@@ -1,76 +1,330 @@
+// 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 是给
![]()
预览用的地址(已落盘,走本站同源或存储直链)
+// - 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_close($ch);
+ $ch = curl_init();
+ 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'] ?? '',
-];
-
-// 如果海报是 'N/A',置空
-if ($result['poster'] === 'N/A') {
- $result['poster'] = '';
+function doubanDetailReferer($id) {
+ $ref = getenv('MOVIELOG_DOUBAN_DETAIL_REF');
+ return $ref ? $ref : ('https://m.douban.com/movie/subject/' . rawurlencode($id) . '/');
}
-echo json_encode($result);
\ No newline at end of file
+// 规范化片名用于匹配:去空白、去常见标点、转小写
+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',
+ ];
+ return isset($map[$mime]) ? $map[$mime] : 'jpg';
+}
+
+// 按魔数判断图片类型;不是已知图片则返回空串。
+// 兜底用:既应对「环境没有 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);
diff --git a/lib/helpers.php b/lib/helpers.php
index 49a1b41..8200fa1 100644
--- a/lib/helpers.php
+++ b/lib/helpers.php
@@ -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;