v10: 修影院编辑回填布尔化 bug;评分改 10 星制;观影人改弹窗+拼音首字母联想;豆瓣自动补充迁移(movie_details.douban_meta,schema v10)
This commit is contained in:
+417
@@ -0,0 +1,417 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user