2026-10-01 10:21:45 +08:00
|
|
|
|
<?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] : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 20:23:22 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 拼一份**带主要信息**的简介(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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 10:21:45 +08:00
|
|
|
|
// ---------------- 搜索候选整理 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
/** 规范化片名用于匹配:去空白、去常见标点、转小写 */
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|