* 3) 海报 https://imgN.doubanio.com/...(需带 Referer,防盗链) * * 与 MovieLog 版的差异: * · 环境变量前缀改为 FAMILYBOOK_DOUBAN_*(便于本地 mock 端到端测试); * · 海报落盘不走本类 —— 交给 Upload::image() 的 poster 槽位管线 * (裁剪/缩放/转 JPEG/Storage 统一出口),本类只负责「取回字节」; * · 元数据不落库,组装成数组返回,由调用方决定存哪。 */ declare(strict_types=1); namespace App; final class Douban { /** 移动端 UA:豆瓣移动端接口(rexxar)对移动 UA 正常返回,桌面 UA 会被反爬 302 */ private const 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'; /** 海报字节上限:正常海报几十 KB,超了说明响应不对 */ private const MAX_POSTER_BYTES = 12 * 1024 * 1024; // ---------------- 对外入口 ---------------- /** * 片名候选列表(下拉联想用;只读,不取详情、不写任何文件)。 * @return array{ok:bool, items?:array, error?:string} */ public static function suggest(string $kw): array { $kw = trim($kw); if ($kw === '') { return ['ok' => true, 'items' => []]; } $res = self::getJson(self::searchEndpoint($kw)); if (!$res['ok']) { return ['ok' => false, 'error' => '搜索失败:' . $res['err']]; } return ['ok' => true, 'items' => self::buildSuggestItems(is_array($res['data']) ? $res['data'] : [])]; } /** * 按豆瓣条目 id 取详情,返回整理好的字段。 * @return array{ok:bool, meta?:array, title?:string, poster_url?:string, error?:string} */ public static function fetchById(string $id): array { if (!preg_match('/^\d{1,12}$/', $id)) { return ['ok' => false, 'error' => '无效的豆瓣条目 ID']; } return self::fetchDetail($id, []); } /** * 按片名搜索 → 挑最佳候选 → 取详情。 * @return array{ok:bool, meta?:array, title?:string, poster_url?:string, error?:string} */ public static function fetchByTitle(string $q): array { $q = trim($q); if ($q === '') { return ['ok' => false, 'error' => '请输入电影名称']; } $search = self::getJson(self::searchEndpoint($q)); if (!$search['ok']) { return ['ok' => false, 'error' => '搜索失败:' . $search['err']]; } $list = is_array($search['data']) ? $search['data'] : []; if ($list === []) { return ['ok' => false, 'error' => '豆瓣没有找到「' . $q . '」,换个更准确的片名试试']; } $picked = self::pickCandidate($list, $q); if ($picked === null) { return ['ok' => false, 'error' => '豆瓣没有找到「' . $q . '」的影视条目,换个更准确的片名试试']; } return self::fetchDetail((string)$picked['id'], $picked, $q); } /** * 代理豆瓣缩略图(防盗链,浏览器不能直连)。只读、不落盘。 * @return array{mime:string, body:string}|null */ public static function thumb(string $url): ?array { if (!self::imageUrlAllowed($url)) { return null; } $res = self::httpGet($url, 'https://movie.douban.com/'); $binary = $res['ok'] ? $res['body'] : ''; if ($binary === '') { return null; } $mime = self::imageMimeFromMagic($binary); return $mime === '' ? null : ['mime' => $mime, 'body' => $binary]; } /** 下载海报字节(给 Upload 管线用)。失败返回空串。 */ public static function downloadPoster(string $url): string { if (!self::imageUrlAllowed($url)) { return ''; } // 豆瓣图片服务器要求带 Referer,否则 403 $res = self::httpGet($url, 'https://movie.douban.com/'); $binary = $res['ok'] ? $res['body'] : ''; if ($binary === '' || strlen($binary) > self::MAX_POSTER_BYTES) { return ''; } // 不是已知图片格式(比如被反爬拦成 HTML)就退回 —— 交给调用方提示 return self::imageMimeFromMagic($binary) === '' ? '' : $binary; } // ---------------- 详情与字段整理 ---------------- /** @param array $cand 搜索候选(id 直连时为空数组),供标题/年份/海报兜底 */ private static function fetchDetail(string $id, array $cand, string $q = ''): array { $detail = self::getJson(self::detailEndpoint($id), self::detailReferer($id)); if (!$detail['ok']) { return ['ok' => false, 'error' => '获取详情失败:' . $detail['err']]; } $d = is_array($detail['data']) ? $detail['data'] : []; $title = (string)($d['title'] ?? '') !== '' ? (string)$d['title'] : (string)($cand['title'] ?? $q); $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 = ''; foreach ([(string)($d['pic']['normal'] ?? ''), (string)($d['cover_url'] ?? ''), (string)($cand['img'] ?? '')] as $u) { if ($u !== '') { $posterUrl = $u; break; } } $meta = [ 'douban_id' => $id, 'douban_url' => 'https://movie.douban.com/subject/' . $id . '/', 'year' => self::str($d, 'year') !== '' ? self::str($d, 'year') : (string)($cand['year'] ?? ''), 'genre' => self::joinList($d['genres'] ?? []), 'runtime' => self::joinList($d['durations'] ?? []), 'director' => self::joinNames($d['directors'] ?? []), 'actors' => self::joinNames($d['actors'] ?? [], 6), 'plot' => (string)($d['intro'] ?? ''), 'rating' => $rating, 'country' => self::joinList($d['countries'] ?? []), 'alias' => self::joinList($d['aka'] ?? [], 3), ]; return ['ok' => true, 'meta' => $meta, 'title' => $title, 'poster_url' => $posterUrl]; } private static function str(array $d, string $k): string { return isset($d[$k]) && is_scalar($d[$k]) ? (string)$d[$k] : ''; } /** * 拼一份**带主要信息**的简介(v24 用户 2026-10-03 第 2 条)。 * * 用户原话:「添加电影的时候,从豆瓣获取的信息不够,应该把导演、主要演员, * 上映年份等主要信息,也都放在简介中。」 * * 为什么要有这个函数(而不是在路上顺手拼字符串): * · 简介是**写进 movie_details.info 的正文**,一旦存下来就与豆瓣脱钩了 * (以后豆瓣改了也不会变)。所以拼法必须是一处定义、可测: * `_fb_douban_summary.php` 直接断言各种组合下的输出。 * · 用户要求"主要信息**也都放在简介中**",即这些字段要**落进正文**, * 而不是只躺在隐藏的 douban_meta JSON 里(那个详情页另有展示, * 但编辑表单里看不到、复制出去也没有)。 * * 格式(只有有值的行才出现,绝不写"导演:"这种空壳行): * 《片名》(年份) * 导演:X * 主演:A、B、C * 类型:剧情 / 家庭 * 片长:125分钟 * 制片国家/地区:中国大陆 * 豆瓣评分:8.6 * * <剧情简介原文> * * ⚠️ 用与详情页 `db-meta` 相同的标签词(年份/类型/片长/导演/主演/制片国家/地区), * 这样同一个字段在表单与详情页叫同一个名字,用户不会以为是两回事。 * * @param array $meta fetchDetail() 产出的 meta * @param string $title 片名(可能来自候选/用户输入) * @param bool $withPlot 是否附带剧情简介原文(默认带) * @return string 可直接写进 info 的文本;什么信息都没有时返回 '' */ public static function summaryText(array $meta, string $title = '', bool $withPlot = true): string { $title = trim($title); $year = trim((string)($meta['year'] ?? '')); $lines = []; // 第一行:片名(年份) $head = ''; if ($title !== '') { $head = '《' . $title . '》'; if ($year !== '') { $head .= '(' . $year . ')'; } } elseif ($year !== '') { $head = $year . ' 年'; } if ($head !== '') { $lines[] = $head; } // 主要信息:顺序按"用户最关心 → 次要",空的不占行 $fields = [ '导演' => (string)($meta['director'] ?? ''), '主演' => (string)($meta['actors'] ?? ''), '类型' => (string)($meta['genre'] ?? ''), '片长' => (string)($meta['runtime'] ?? ''), '制片国家/地区' => (string)($meta['country'] ?? ''), '又名' => (string)($meta['alias'] ?? ''), '豆瓣评分' => (string)($meta['rating'] ?? ''), ]; foreach ($fields as $label => $val) { $val = trim($val); if ($val !== '') { $lines[] = $label . ':' . $val; } } $plot = $withPlot ? trim((string)($meta['plot'] ?? '')) : ''; if ($plot !== '') { $lines[] = ''; // 空行:把"事实"与"剧情"分开,读起来清爽 $lines[] = $plot; } return trim(implode("\n", $lines)); } // ---------------- 搜索候选整理 ---------------- /** 规范化片名用于匹配:去空白、去常见标点、转小写 */ private static function normalizeTitle(string $s): string { $s = preg_replace('/[\s\x{3000}]+/u', '', $s) ?? ''; $s = str_replace([',', ',', '。', '·', '・', ':', ':', '!', '!', '?', '?', '、', '《', '》'], '', $s); return function_exists('mb_strtolower') ? mb_strtolower($s, 'UTF-8') : strtolower($s); } /** 从搜索候选里挑出最可能的那一条:同名优先、有海报优先、有年份优先 */ private static function pickCandidate(array $list, string $q): ?array { $qNorm = self::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 = self::str($item, 'title'); $sub = self::str($item, 'sub_title'); $score = 0; if ($title !== '' && self::normalizeTitle($title) === $qNorm) $score += 5; if ($sub !== '' && self::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; } /** 判断候选是电影还是剧集:suggest 里两者 type 都是 movie,只能靠 episode 区分 */ private static function kindLabel(string $type, string $episode): string { if ((int)$episode > 1) return '剧集'; if ($type === '' || $type === 'movie') return '电影'; return $type; } /** 把 suggest 的原始结果整理成前端下拉要用的字段(过滤影人、去重、限量) */ private static function buildSuggestItems(array $list, int $limit = 8): array { $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 = self::str($it, 'img'); $episode = self::str($it, 'episode'); $items[] = [ 'id' => $id, 'title' => self::str($it, 'title'), 'sub_title' => self::str($it, 'sub_title'), 'year' => self::str($it, 'year'), 'episode' => $episode, 'kind' => self::kindLabel($type, $episode), 'thumb' => $img !== '' ? self::thumbProxyUrl($img) : '', ]; if (count($items) >= $limit) { break; } } return $items; } // ---------------- 图片地址白名单(防 SSRF) ---------------- /** * action=thumb / 海报下载都是「服务端代取」,放任任意 URL 就是 SSRF 跳板。 * 只允许豆瓣图片 CDN(img*.doubanio.com)下的图片路径。 * FAMILYBOOK_DOUBAN_IMG_ALLOW(逗号分隔的完整来源)是本地端到端测试留给 mock 的口子。 */ public static function imageUrlAllowed(string $url): bool { if ($url === '') { return false; } $p = parse_url($url); if (!$p || empty($p['host']) || empty($p['path'])) { return false; } $scheme = strtolower((string)($p['scheme'] ?? '')); $host = strtolower((string)$p['host']); $port = isset($p['port']) ? ':' . $p['port'] : ''; $origin = ($scheme === '' || $host === '') ? '' : $scheme . '://' . $host . $port; $raw = getenv('FAMILYBOOK_DOUBAN_IMG_ALLOW'); if ($raw !== false && $raw !== '') { foreach (explode(',', $raw) as $allowed) { $allowed = rtrim(trim($allowed), '/'); if ($allowed !== '' && $origin !== '' && strcasecmp($origin, strtolower($allowed)) === 0) { return true; } } } if ($scheme !== 'https') return false; if (!preg_match('/^img\d*\.doubanio\.com$/i', $host)) return false; if (!preg_match('#^/view/photo/[A-Za-z0-9_/]+\.(?:jpg|jpeg|png|webp)$#i', (string)$p['path'])) return false; return true; } /** 豆瓣图片地址 → 本站代理地址(豆瓣有防盗链, 直连一律 403) */ public static function thumbProxyUrl(string $img): string { if (!self::imageUrlAllowed($img)) { return ''; } // 路由的 url() 会在输出处再转义;这里手动拼查询串 return 'index.php?r=movies/douban&action=thumb&u=' . rawurlencode($img); } // ---------------- HTTP 与格式识别 ---------------- private static function searchEndpoint(string $q): string { $base = getenv('FAMILYBOOK_DOUBAN_SEARCH'); $base = $base ? rtrim($base, '/') : 'https://movie.douban.com/j/subject_suggest'; return $base . '?q=' . rawurlencode($q); } private static function detailEndpoint(string $id): string { $base = getenv('FAMILYBOOK_DOUBAN_DETAIL'); $base = $base ? rtrim($base, '/') : 'https://m.douban.com/rexxar/api/v2/movie'; return $base . '/' . rawurlencode($id); } private static function detailReferer(string $id): string { $ref = getenv('FAMILYBOOK_DOUBAN_DETAIL_REF'); return $ref ? $ref : ('https://m.douban.com/movie/subject/' . rawurlencode($id) . '/'); } /** @return array{ok:bool, http:int, body:string, err:string} */ private static function httpGet(string $url, string $referer = '', string $accept = ''): array { $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 => self::UA, CURLOPT_HTTPHEADER => $headers, CURLOPT_ENCODING => '', CURLOPT_SSL_VERIFYPEER => true, 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 !== '' ? $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' => '']; } /** @return array{ok:bool, data:mixed, err:string} */ private static function getJson(string $url, string $referer = ''): array { $res = self::httpGet($url, $referer, 'application/json'); if (!$res['ok']) { return ['ok' => false, 'data' => null, 'err' => $res['err']]; } $data = json_decode($res['body'], true); if (!is_array($data)) { return ['ok' => false, 'data' => null, 'err' => '返回内容不是合法 JSON(可能被反爬拦截)']; } return ['ok' => true, 'data' => $data, 'err' => '']; } /** 按魔数判断图片类型;不是已知图片返回空串(兜底 fileinfo、挡反爬 HTML) */ private static function imageMimeFromMagic(string $binary): string { 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 ''; } // ---------------- 名单拼接 ---------------- /** [{'name':'饺子'}] -> '饺子 / 另一位' */ private static function joinNames(mixed $list, int $limit = 0): string { 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); } private static function joinList(mixed $list, int $limit = 0): string { 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); } }