// 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 是给
预览用的地址(已落盘,走本站同源或存储直链)
// - 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():PHP 8.0 起句柄由 GC 回收,该函数自 8.5 起被标记 deprecated。
// 一旦调用,8.5 会往输出里插一行 Deprecated 警告,把本接口的 JSON 响应污染成
// 「警告 + JSON」,前端 JSON.parse 直接失败 —— 联想功能就是因此挂掉的。
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;
}
// 把豆瓣图片地址转成「本站代理地址」。不能把豆瓣地址直接交给
:
// 豆瓣图片有防盗链,浏览器直连一律 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);