// 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: "..." } // // 说明: // - poster 是给 预览用的地址(已落盘,走本站同源或存储直链) // - poster_key 是提交表单时应写入 movies.poster_path 的存储键 // - 每次抓取都会把海报下载到 uploads/posters/,因此重复点击需要清理上一张 // (前端把上一张的键回传为 old_poster,由本接口删除,避免堆积垃圾文件) // ============================================ 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 ''; } // 删除上一次抓取但未被采用的海报。 // 只删 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); } $q = isset($_GET['q']) ? trim((string)$_GET['q']) : ''; if ($q === '') { fetchFail('请输入电影名称'); } $oldPoster = isset($_GET['old_poster']) ? trim((string)$_GET['old_poster']) : ''; // 1) 搜索 $search = getJson(doubanSearchEndpoint($q)); if (!$search['ok']) { fetchFail('搜索失败:' . $search['err']); } $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)); 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);