端上「记一场」要与网页端同步实现(用户 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 已含两条新能力
530 lines
22 KiB
PHP
530 lines
22 KiB
PHP
<?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);
|
||
}
|
||
}
|