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 @@ + -
推荐比例 2:3
+
推荐比例 2:3(「自动获取」会连同海报一起下载;手动上传时以上传的为准)
@@ -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;