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= 直连取详情成功。
This commit is contained in:
2026-09-22 07:36:05 +08:00
parent ddf2eeafaa
commit 99c22d17bd
3 changed files with 522 additions and 81 deletions
+190 -22
View File
@@ -10,17 +10,31 @@
// 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: "..." }
// 用法(三种模式,均需登录):
// 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';
@@ -194,6 +208,95 @@ function imageMimeFromMagic($binary) {
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) {
@@ -243,29 +346,94 @@ if (!isset($_SESSION['admin_id'])) {
fetchFail('未登录或登录已过期,请重新登录后再试', 401);
}
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
if ($q === '') {
$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']) : '';
// 1) 搜索
$search = getJson(doubanSearchEndpoint($q));
if (!$search['ok']) {
fetchFail('搜索失败:' . $search['err']);
// $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'];
}
$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));