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:
+308
-54
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user