Files
MovieLog_Server/fetch_movie.php
T
laoyang 99c22d17bd feat: 片名输入加下拉联想,不必把片名打全就能选片
输入片名时实时下拉候选(海报缩略图 + 片名 + 原名 + 年份 + 电影/剧集标注),
选中即自动填充影片信息,省去「打全片名再点按钮」这一步。

fetch_movie.php
- 新增 action=suggest:只回候选列表,不取详情、不下载海报、不写任何文件,
  可随输入安全地频繁调用;会过滤「影人」类条目、按豆瓣条目 id 去重、默认限 8 条。
- 新增 action=thumb:服务端代取豆瓣缩略图。豆瓣图片有防盗链(只有 Referer 为
  豆瓣域名才返回 200,其它一律 403),浏览器直连必然被拒,只能由服务端带
  Referer 代取;并加 Cache-Control 让浏览器缓存,重复展开不重复回源。
- 新增 ?id=豆瓣条目id:跳过搜索直接取详情。下拉选中后走这条路,既省一次搜索,
  也避免「再按片名搜一次、结果选错条目」。
- 安全:新增 validateDoubanImageUrl() 严格白名单(只允许
  https://img*.doubanio.com/view/photo/…),否则 thumb 就成了 SSRF 跳板;
  接口地址的测试用覆盖项一并写进注释。

add.php
- 片名输入框改为「输入即联想」:320ms 防抖、会话内结果缓存、用 AbortController
  丢弃过期在途请求、↑↓ 选择 / Enter 确认 / Esc 关闭、点击外部关闭、
  中文输入法组字期间不发请求也不劫持 Enter(此时 Enter 是选字)。
- 候选里的豆瓣文本一律转义后再插入,避免把对方内容当 HTML 执行。
- 「📡 自动获取」按钮保留:候选里没有想要的条目时仍可按回车或点按钮,由服务端
  挑最匹配的条目;按钮与下拉选中复用同一套获取逻辑(runFetch)。

README.md
- 接口文档改为四种模式(q / id / suggest / thumb),补上参数、响应样例、
  防盗链原因与白名单说明;使用说明与安全注意事项同步更新。

验证
- fetch_movie_test.py 端到端 79 项全通过(新增 suggest / thumb / id 三组用例,
  含「suggest 与 thumb 不写任何文件」「非白名单地址一律 400」)。
- storage_test.py 回归 196 项全通过。
- A/B 渲染比对 47 个响应:仅 add.php 的两个页面有差异,且差异全部落在下拉联想
  相关代码上,其余响应逐字节一致。
- 已部署到线上,双 PHP 版本(7.4 / 8.1)真实抓取通过:suggest 返回候选、
  thumb 代取回 27KB JPEG、非白名单地址被拒、id= 直连取详情成功。
2026-09-22 07:36:05 +08:00

499 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ============================================
// 文件: 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)
//
// 用法(三种模式,均需登录):
// 1) ?q=片名[&old_poster=键] 按片名搜索 → 取详情 → 下载海报入库
// 2) ?id=豆瓣条目id[&old_poster=键]
// 跳过搜索,直接取该条目详情(下拉选中后用它,
// 既省一次搜索,也避免「再搜一次选错条目」)
// 3) ?action=suggest&q=关键词 只返回候选列表,不取详情、不下载海报、不写任何文件
// 4) ?action=thumb&u=<图片地址> 代理豆瓣缩略图(豆瓣图片有防盗链,浏览器直连会 403)
//
// 返回: JSON 1/2 成功 -> { ok, source, title, year, genre, runtime, director,
// actors, plot, rating, country, alias,
// poster, poster_key, douban_id, douban_url }
// 3 成功 -> { ok, items: [ { id, title, sub_title, year,
// episode, kind, thumb } ] }
// 4 成功 -> 直接回吐图片字节(Content-Type: image/*)
// 失败 -> { error: "..." }
//
// 说明:
// - poster 是给 <img> 预览用的地址(已落盘,走本站同源或存储直链)
// - poster_key 是提交表单时应写入 movies.poster_path 的存储键
// - 每次抓取都会把海报下载到 uploads/posters/,因此重复点击需要清理上一张
// (前端把上一张的键回传为 old_poster,由本接口删除,避免堆积垃圾文件)
// - action=suggest / action=thumb 都**不写任何文件**,可安全地随输入频繁调用
// - 网页接口地址可用环境变量覆盖,便于本地 mock 测试:
// MOVIELOG_DOUBAN_SEARCH / MOVIELOG_DOUBAN_DETAIL / MOVIELOG_DOUBAN_DETAIL_REF
// MOVIELOG_DOUBAN_IMG_ALLOW(仅测试用,逗号分隔的完整来源白名单)
// ============================================
require_once __DIR__ . '/config.php';
header('Content-Type: application/json; charset=utf-8');
// 移动端 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;
}
// 发起一次 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_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 ($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' => ''];
}
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];
}
// 接口地址支持用环境变量覆盖,便于本地用 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);
}
function doubanDetailEndpoint($id) {
$base = getenv('MOVIELOG_DOUBAN_DETAIL');
$base = $base ? rtrim($base, '/') : 'https://m.douban.com/rexxar/api/v2/movie';
return $base . '/' . rawurlencode($id);
}
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',
];
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 '';
}
// ---------- 图片地址白名单 ----------
//
// action=thumb 是「服务端代取」,如果放任它取任意 URL 就成了 SSRF 跳板。
// 因此这里对地址做严格白名单:只允许豆瓣图片 CDN(img*.doubanio.com)下的图片路径。
//
// MOVIELOG_DOUBAN_IMG_ALLOW 是给本地端到端测试留的口子:只有运维显式设置它
// (逗号分隔的完整来源,如 "http://127.0.0.1:8198")才生效,用来把 mock 服务放行。
// 默认不设置时行为与纯豆瓣白名单完全一致。
function doubanImageAllowedOrigins() {
$raw = getenv('MOVIELOG_DOUBAN_IMG_ALLOW');
if (!$raw) return [];
$out = [];
foreach (explode(',', $raw) as $o) {
$o = rtrim(trim($o), '/');
if ($o !== '') $out[] = $o;
}
return $out;
}
function originOfUrl(array $p) {
$scheme = isset($p['scheme']) ? strtolower($p['scheme']) : '';
$host = isset($p['host']) ? strtolower($p['host']) : '';
if ($scheme === '' || $host === '') return '';
$port = isset($p['port']) ? ':' . $p['port'] : '';
return $scheme . '://' . $host . $port;
}
function validateDoubanImageUrl($url) {
if (!is_string($url) || $url === '') return false;
$p = parse_url($url);
if (!$p || empty($p['host']) || empty($p['path'])) return false;
$origin = originOfUrl($p);
foreach (doubanImageAllowedOrigins() as $allowed) {
if ($origin !== '' && strcasecmp($origin, strtolower($allowed)) === 0) return true;
}
if (($p['scheme'] ?? '') !== 'https') return false;
if (!preg_match('/^img\d*\.doubanio\.com$/i', $p['host'])) return false;
if (!preg_match('#^/view/photo/[A-Za-z0-9_/]+\.(?:jpg|jpeg|png|webp)$#i', $p['path'])) return false;
return true;
}
// 把豆瓣图片地址转成「本站代理地址」。不能把豆瓣地址直接交给 <img>:
// 豆瓣图片有防盗链,浏览器直连一律 403(本站 Referrer-Policy: same-origin,
// 跨域图片不带 Referer,同样被拒)。
function thumbProxyUrl($img) {
if (!validateDoubanImageUrl($img)) return '';
return 'fetch_movie.php?action=thumb&u=' . rawurlencode($img);
}
// 判断候选是电影还是剧集:豆瓣的 suggest 里两者 type 都是 "movie",
// 只能靠 episode(集数)区分——有集数就是剧集。
function doubanKindLabel($type, $episode) {
if ((int)$episode > 1) return '剧集';
if ($type === '' || $type === 'movie') return '电影';
return (string)$type;
}
// 把 suggest 的原始结果整理成前端下拉要用的字段(过滤影人、去重、限量)
function buildSuggestItems(array $list, $limit = 8) {
$items = [];
$seen = [];
foreach ($list as $it) {
if (!is_array($it) || empty($it['id'])) continue;
$type = isset($it['type']) ? (string)$it['type'] : 'movie';
if (in_array($type, ['celebrity', 'person', 'actor', 'director'], true)) continue;
$id = (string)$it['id'];
if (isset($seen[$id])) continue;
$seen[$id] = true;
$img = isset($it['img']) ? (string)$it['img'] : '';
$episode = isset($it['episode']) ? (string)$it['episode'] : '';
$items[] = [
'id' => $id,
'title' => isset($it['title']) ? (string)$it['title'] : '',
'sub_title' => isset($it['sub_title']) ? (string)$it['sub_title'] : '',
'year' => isset($it['year']) ? (string)$it['year'] : '',
'episode' => $episode,
'kind' => doubanKindLabel($type, $episode),
'thumb' => $img !== '' ? thumbProxyUrl($img) : '',
];
if (count($items) >= $limit) break;
}
return $items;
}
// 删除上一次抓取但未被采用的海报。
// 只删 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);
}
$action = isset($_GET['action']) ? (string)$_GET['action'] : '';
// ---- 模式 3:片名候选列表(供下拉联想;只读,不取详情、不写任何文件)----
if ($action === 'suggest') {
$kw = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
if ($kw === '') {
echo json_encode(['ok' => true, 'items' => []], JSON_UNESCAPED_UNICODE);
exit;
}
$res = getJson(doubanSearchEndpoint($kw));
if (!$res['ok']) {
fetchFail('搜索失败:' . $res['err']);
}
$list = is_array($res['data']) ? $res['data'] : [];
echo json_encode(
['ok' => true, 'items' => buildSuggestItems($list)],
JSON_UNESCAPED_UNICODE
);
exit;
}
// ---- 模式 4:代理豆瓣缩略图 ----
// 浏览器不能直连豆瓣图片(防盗链,见 validateDoubanImageUrl 注释),只能由服务端代取。
// 只读、不落盘;配 Cache-Control 让浏览器缓存,重复展开下拉不再回源。
if ($action === 'thumb') {
$u = isset($_GET['u']) ? (string)$_GET['u'] : '';
if (!validateDoubanImageUrl($u)) {
http_response_code(400);
exit;
}
$res = httpGet($u, 'https://movie.douban.com/');
$binary = $res['ok'] ? $res['body'] : '';
if ($binary === '') {
http_response_code(404);
exit;
}
$mime = storageMimeFromContents($binary);
if (strpos($mime, 'image/') !== 0) {
$mime = imageMimeFromMagic($binary); // 环境无 fileinfo、或返回的不是图片
}
if ($mime === '') {
http_response_code(404);
exit;
}
header('Content-Type: ' . $mime); // 覆盖开头设的 JSON 类型
header('Content-Length: ' . strlen($binary));
header('Cache-Control: public, max-age=86400');
echo $binary;
exit;
}
if ($action !== '') {
fetchFail('不支持的 action:' . $action);
}
// ---- 模式 1/2:抓取详情 + 下载海报入库 ----
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
$id = isset($_GET['id']) ? trim((string)$_GET['id']) : '';
if ($q === '' && $id === '') {
fetchFail('请输入电影名称');
}
$oldPoster = isset($_GET['old_poster']) ? trim((string)$_GET['old_poster']) : '';
// $cand 供后续取标题/年份/海报兜底;走 id 直连时没有搜索候选,留空数组即可
$cand = [];
if ($id !== '') {
// 模式 2:下拉选中后直接按条目 id 取详情——省一次搜索,也避免「再搜一次选错条目」
if (!preg_match('/^\d{1,12}$/', $id)) {
fetchFail('无效的豆瓣条目 ID');
}
} else {
// 模式 1:按片名搜索
$search = getJson(doubanSearchEndpoint($q));
if (!$search['ok']) {
fetchFail('搜索失败:' . $search['err']);
}
$list = is_array($search['data']) ? $search['data'] : [];
if (empty($list)) {
fetchFail('豆瓣没有找到「' . $q . '」,换个更准确的片名试试');
}
$picked = pickCandidate($list, $q);
if (!$picked || empty($picked['id'])) {
fetchFail('豆瓣没有找到「' . $q . '」的影视条目,换个更准确的片名试试');
}
$cand = $picked;
$id = (string)$picked['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);