Files
laoyang 3459eea8f3 v64:观影豆瓣自动获取 + 海报/多图上传(移动端 API)
端上「记一场」要与网页端同步实现(用户 2026-10-19 六条),
服务端这一半只加**移动端入口**,网页端(index.php 那条路由)一行没动。

- ApiHandler:新增 `/movies/douban` 子路由(action=suggest / action=thumb /
  默认抓详情+海报落盘),逻辑与网页端 `movies/douban` 逐条对齐;
  新增 `/movies/<id>/poster` 与 `/movies/<id>/photos`(multipart 上传),
  字段名与端上一致(poster / photo);
  新增 `normalizeFiles()` 把 `photo[]` 的多文件从"按字段竖切"转成"一行一个";
  `movieData()` 认 `poster_key` 并**校验形态**(只接受 posters/xxx 形式);
  features 声明 movies_douban / movie_media(端上可探测老服务端)。

- Douban:新增 `apiThumbUrl()` —— 端上打不到 `index.php?r=...`,
  把豆瓣图片代理换成 `/api/v1/...` 形式(拼法只此一处);
  联想候选带上**过白名单后**的原始图片地址(未过白名单给空串,不下发 SSRF 跳板)。

- Movie:新增 `setPoster()`(单字段更新 —— 端上是"先建记录再传图"两段式,
  用 update() 要拼一份完整草稿,漏一个字段就静默覆盖用户刚填的内容)与
  `addPhotos()`(**追加**语义,与网页表单用的 savePhotos() 替换语义并存不混用)。

验证:接口 e2e 57/0(自带豆瓣 mock,离线可跑)· 反向验证 18/0 ·
本地全量 e2e 1521/0 · 线上部署 + 站点自检 19 条全过 +
`/api/v1/movies/douban` 401(路由通)· ping.features 已含两条新能力
2026-10-19 23:41:15 +08:00

530 lines
22 KiB
PHP
Raw Permalink 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] : '';
}
/**
* 拼一份**带主要信息**的简介(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) : '',
/* 🔴 v64:**原始**豆瓣图片地址(已过白名单)也带上。
⚠️ 为什么需要它:`thumb` 是**网页路由**的代理地址
(`index.php?r=movies/douban&action=thumb&u=…`),
手机端拿不到 `index.php?r=` 这种入口 —— 它只会打 `/api/v1/…`。
端上要用 `apiThumbUrl()` 把这个原始地址重新拼成 **API 形式**。
⚠️ 只有过白名单的才给(没过的一律空串)——
否则等于把"服务端代取任意 URL"的 SSRF 跳板下发给客户端。 */
'img' => self::imageUrlAllowed($img) ? $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);
}
/**
* 豆瓣图片地址 → **移动端 API** 代理地址(`/api/v1/movies/douban?action=thumb&u=…`)。
*
* 🔴 v64:端上只能打 `/api/v1/…`,拿不到 `index.php?r=` 那种网页入口
* (见 `buildSuggestItems` 里 `img` 字段的说明)。
* ⇒ 拼法在这里**定义一次**,端上不自己拼串 ——
* 免得以后接口路径一改,两端各改一份还对不齐。
*
* ⚠️ 与网页路由**同构**(那边是 `movies/douban&action=thumb`):
* `Api::dispatch()` 只解析到「资源 / id / 子动作」两层,
* `/movies/douban/thumb` 这种三段路径它认不出来(会把 thumb 当子动作丢掉)。
* 用 query 传 action 是最省事、也不会跟现有路由打架的形态。
*
* ⚠️ 返回值是**相对路径**,端上要自己补服务器基址并追加 `&token=`
* (`Image()` 加不了请求头,见 `media.php` 的说明)。
* 这样同一个地址在"用户换服务器"后仍然有效。
*/
public static function apiThumbUrl(string $img): string
{
if (!self::imageUrlAllowed($img)) {
return '';
}
return '/api/v1/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);
}
}