Files
FamilyBook/app/Douban.php
T

418 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 豆瓣自动补充(v10,MovieLog fetch_movie.php 同源迁移)。
*
* 数据来源(公开网页接口,非官方 API):
* 1) 搜索 https://movie.douban.com/j/subject_suggest?q=片名
* 2) 详情 https://m.douban.com/rexxar/api/v2/movie/<id>
* 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] : '';
}
// ---------------- 搜索候选整理 ----------------
/** 规范化片名用于匹配:去空白、去常见标点、转小写 */
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;
}
/** 豆瓣图片地址 → 本站代理地址(豆瓣有防盗链,<img> 直连一律 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);
}
}