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