// 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($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; } // 把豆瓣图片地址转成「本站代理地址」。不能把豆瓣地址直接交给 : // 豆瓣图片有防盗链,浏览器直连一律 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);