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
+308 -54
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_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);
// 规范化片名用于匹配:去空白、去常见标点、转小写
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);