Files
FamilyBook/app/Douban.php
T
laoyang 4377fa995f 观影:豆瓣简介补上导演/主演/上映年份等主要信息
用户原话:「观影模块,添加电影的时候,从豆瓣获取的信息不够,
          应该把导演、主要演员,上映年份等主要信息,也都放在简介中。」

**改了什么**
- 新增 `Douban::summaryText($meta, $title, $withPlot)`:把主要信息拼成一段可读文本。
  格式(只有有值的行才出现,绝不写"导演:"这种空壳行):
      《片名》(年份)
      导演:X     主演:A、B、C     类型:…     片长:…     制片国家/地区:…     又名:…     豆瓣评分:…
      <空行>
      <剧情简介原文>
  标签词与详情页 `db-meta` 用的同一套(年份/导演/主演…),同一字段在两处叫同一个名字。
- `movies/douban` 接口新增 `summary` 字段,**在服务端拼**。
  为什么不在前端拼:这段文本会**写进 movie_details.info**(落库、与豆瓣脱钩),
  拼法必须可测、只有一处;拼在 JS 里既测不到,也没法在"重新抓取"之外复用。
- 前端 `apply()` 改为填 `data.summary`(原来只填 `meta.plot`),
  并补了两处**原来缺失的反馈**:
  · 简介里已有用户写的内容 → 明确告知"没有覆盖它"(并提示清空后重抓);
  · 豆瓣确实没有可用简介文本 → 也明确说一声。
  之前这两种情况都是**静默什么都不做**,用户会以为"抓取坏了"。
  简介框 rows 3 → 6(内容变多了),placeholder 也说明了会自动带上主要信息。

**验证**
- 新增 `_fb_douban_summary.py` 22 条,覆盖:字段齐全 / 缺字段不留空壳行 /
  全空返回空串 / 只有剧情时不带多余空行 / 接口确实吐 summary /
  前端写入的值只能是 summary(按 `infoEl.value = …` 的实际赋值断言,
  不是"文件里有没有某个词")/ 仍然只填空不覆盖。
- **反向验证**:把拼装改回"简介=plot"→ ① 组 8 条立刻红(片名/年份/导演/主演/
  类型/片长/国家/评分全部缺失),还原后复绿。
- **线上真连豆瓣**实测(`哪吒之魔童降世` / `流浪地球`):year/director/actors/
  genre/runtime/country/rating/plot 全部有值,拼出的简介如
  《哪吒之魔童降世》(2019)/导演:饺子/主演:吕艳婷 / 囧森瑟夫 / 瀚墨 …/
  类型:剧情 / 喜剧 / 动画/片长:110分钟/豆瓣评分:8.4 + 剧情原文。
- 部署脚本加 4 条静态断言(summaryText 在服务端、接口吐 summary、
  前端不许再只拿 meta.plot、摘不到要提示)。
- 回归:e2e 775/0 · 豆瓣简介 22/0 · 第 5 轮专项 91/0 · 导入 21/0。
2026-10-03 20:23:22 +08:00

497 lines
20 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] : '';
}
/**
* 拼一份**带主要信息**的简介(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;
}
/** 豆瓣图片地址 → 本站代理地址(豆瓣有防盗链,<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);
}
}