Files
MovieLog_Server/fetch_movie.php
T
laoyang 3a3c5890cc 安装向导 + PHP 8.5 兼容修复
新增 install.php:全新部署时自动接管,用户只需上传文件、打开站点即进入
安装向导。安装页自己做三件事:
  - 建 db/ 与 uploads/{posters,tickets,photos,avatars}/,权限调到 775
  - 逐目录真写临时文件实测可写,不靠 is_writable 猜
  - 建库、建管理员、写 db/.installed 锁、自删本文件

配套改动:
  - config.php 把安装检查前置于建库之前,未安装一律 302 到 install.php。
    原实现无条件调用 initDB(),导致「打开首页」就等于建库并种下 admin/123456。
  - lib/db.php 的 initDB(\$seedDefaultAdmin = true);安装向导传 false,
    管理员由用户自己设,绝不种弱口令。新增 isInstalled()。
  - login.php / nav.php 接上 siteName(),站点名称不再只存不用。
  - lib/settings.php 新增 siteName(),缺省回退「电影日志」。

PHP 8.5 兼容:
  - 删掉 fetch_movie.php 与 lib/storage/http.php 的 curl_close()。
    8.5 起该函数 deprecated,调用会往输出插一行警告,把 JSON 响应污染成
    「警告 + JSON」,前端 JSON.parse 失败 → 电影名联想功能完全失灵。
  - config.php 的 display_errors 由 1 改为 0(生产环境不应向浏览器吐错误),
    错误仍写日志;需要调试时设 MOVIELOG_DEBUG=1 打开。

线上验证(movie.yangyq.net,PHP 8.5.12):
  未安装时 / 、/login.php 均 302 → install.php;
  安装页 200、体检 10/10 通过、自动建好 5 个目录;
  安装后 install.php 404、管理员登录成功、首页 200 无报错;
  联想接口带会话请求「流浪地球/星际穿越/霸王别姬」均返回纯 JSON(首字符 {)。
2026-10-31 21:35:41 +08:00

501 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
// ============================================
// 文件: fetch_movie.php (豆瓣版)
// 说明: 根据片名从豆瓣抓取电影信息,并把海报下载入库。
// 豆瓣对中文片名的收录最全(国产片 / 剧集 / 老片 / 冷门片都有),
// 英文片名同样可以命中(豆瓣会把英文名归到对应中文条目,如 Inception → 盗梦空间)。
//
// 数据来源(均为公开网页接口,非官方 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)
//
// 用法(三种模式,均需登录):
// 1) ?q=片名[&old_poster=键] 按片名搜索 → 取详情 → 下载海报入库
// 2) ?id=豆瓣条目id[&old_poster=键]
// 跳过搜索,直接取该条目详情(下拉选中后用它,
// 既省一次搜索,也避免「再搜一次选错条目」)
// 3) ?action=suggest&q=关键词 只返回候选列表,不取详情、不下载海报、不写任何文件
// 4) ?action=thumb&u=<图片地址> 代理豆瓣缩略图(豆瓣图片有防盗链,浏览器直连会 403)
//
// 返回: JSON 1/2 成功 -> { ok, source, title, year, genre, runtime, director,
// actors, plot, rating, country, alias,
// poster, poster_key, douban_id, douban_url }
// 3 成功 -> { ok, items: [ { id, title, sub_title, year,
// episode, kind, thumb } ] }
// 4 成功 -> 直接回吐图片字节(Content-Type: image/*)
// 失败 -> { error: "..." }
//
// 说明:
// - poster 是给 <img> 预览用的地址(已落盘,走本站同源或存储直链)
// - poster_key 是提交表单时应写入 movies.poster_path 的存储键
// - 每次抓取都会把海报下载到 uploads/posters/,因此重复点击需要清理上一张
// (前端把上一张的键回传为 old_poster,由本接口删除,避免堆积垃圾文件)
// - action=suggest / action=thumb 都**不写任何文件**,可安全地随输入频繁调用
// - 网页接口地址可用环境变量覆盖,便于本地 mock 测试:
// MOVIELOG_DOUBAN_SEARCH / MOVIELOG_DOUBAN_DETAIL / MOVIELOG_DOUBAN_DETAIL_REF
// MOVIELOG_DOUBAN_IMG_ALLOW(仅测试用,逗号分隔的完整来源白名单)
// ============================================
require_once __DIR__ . '/config.php';
header('Content-Type: application/json; charset=utf-8');
// 移动端 UA:豆瓣的移动端接口(rexxar)对移动端 UA 正常返回,
// 桌面 UA 打 PC 详情页会被反爬 302,因此统一用移动端 UA。
define('DOUBAN_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');
// ---------------- 基础工具 ----------------
function fetchFail($msg, $http = 200) {
if ($http !== 200) {
http_response_code($http);
}
echo json_encode(['error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
// 发起一次 GET 请求,返回 [ok, http, body, err]
function httpGet($url, $referer = '', $accept = '') {
$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 => DOUBAN_UA,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_ENCODING => '', // 自动协商并解压 gzip
CURLOPT_SSL_VERIFYPEER => true, // 服务器已配置 CA 束,开启证书校验
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
// 不调用 curl_close():PHP 8.0 起句柄由 GC 回收,该函数自 8.5 起被标记 deprecated。
// 一旦调用,8.5 会往输出里插一行 Deprecated 警告,把本接口的 JSON 响应污染成
// 「警告 + JSON」,前端 JSON.parse 直接失败 —— 联想功能就是因此挂掉的。
if ($body === false) {
return ['ok' => false, 'http' => $http, 'body' => '', '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' => ''];
}
function getJson($url, $referer = '') {
$res = httpGet($url, $referer, 'application/json');
if (!$res['ok']) {
return ['ok' => false, 'err' => $res['err'], 'data' => null];
}
$data = json_decode($res['body'], true);
if (!is_array($data)) {
return ['ok' => false, 'err' => '返回内容不是合法 JSON(可能被反爬拦截)', 'data' => null];
}
return ['ok' => true, 'err' => '', 'data' => $data];
}
// 接口地址支持用环境变量覆盖,便于本地用 mock 服务做端到端测试
function doubanSearchEndpoint($q) {
$base = getenv('MOVIELOG_DOUBAN_SEARCH');
$base = $base ? rtrim($base, '/') : 'https://movie.douban.com/j/subject_suggest';
return $base . '?q=' . rawurlencode($q);
}
function doubanDetailEndpoint($id) {
$base = getenv('MOVIELOG_DOUBAN_DETAIL');
$base = $base ? rtrim($base, '/') : 'https://m.douban.com/rexxar/api/v2/movie';
return $base . '/' . rawurlencode($id);
}
function doubanDetailReferer($id) {
$ref = getenv('MOVIELOG_DOUBAN_DETAIL_REF');
return $ref ? $ref : ('https://m.douban.com/movie/subject/' . rawurlencode($id) . '/');
}
// 规范化片名用于匹配:去空白、去常见标点、转小写
function normalizeTitle($s) {
$s = (string)$s;
$s = preg_replace('/[\s\x{3000}]+/u', '', $s);
$s = str_replace([',', ',', '。', '·', '・', ':', ':', '!', '!', '?', '?', '、', '《', '》'], '', $s);
return function_exists('mb_strtolower') ? mb_strtolower($s, 'UTF-8') : strtolower($s);
}
// 从搜索候选里挑出最可能的那一条:同名优先、有海报优先、有年份优先
function pickCandidate(array $list, $q) {
$qNorm = 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 = isset($item['title']) ? (string)$item['title'] : '';
$sub = isset($item['sub_title']) ? (string)$item['sub_title'] : '';
$score = 0;
if ($title !== '' && normalizeTitle($title) === $qNorm) $score += 5;
if ($sub !== '' && 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;
}
// 把若干「名字对象」拼成字符串,如 [{'name':'饺子'}] -> '饺子'
function joinNames($list, $limit = 0) {
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);
}
// 把字符串数组拼成 ' / ' 分隔的字符串
function joinList($list, $limit = 0) {
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);
}
function extForMime($mime) {
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
return isset($map[$mime]) ? $map[$mime] : 'jpg';
}
// 按魔数判断图片类型;不是已知图片则返回空串。
// 兜底用:既应对「环境没有 fileinfo」,也用来挡掉反爬返回的 HTML 页面。
function imageMimeFromMagic($binary) {
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 '';
}
// ---------- 图片地址白名单 ----------
//
// action=thumb 是「服务端代取」,如果放任它取任意 URL 就成了 SSRF 跳板。
// 因此这里对地址做严格白名单:只允许豆瓣图片 CDN(img*.doubanio.com)下的图片路径。
//
// MOVIELOG_DOUBAN_IMG_ALLOW 是给本地端到端测试留的口子:只有运维显式设置它
// (逗号分隔的完整来源,如 "http://127.0.0.1:8198")才生效,用来把 mock 服务放行。
// 默认不设置时行为与纯豆瓣白名单完全一致。
function doubanImageAllowedOrigins() {
$raw = getenv('MOVIELOG_DOUBAN_IMG_ALLOW');
if (!$raw) return [];
$out = [];
foreach (explode(',', $raw) as $o) {
$o = rtrim(trim($o), '/');
if ($o !== '') $out[] = $o;
}
return $out;
}
function originOfUrl(array $p) {
$scheme = isset($p['scheme']) ? strtolower($p['scheme']) : '';
$host = isset($p['host']) ? strtolower($p['host']) : '';
if ($scheme === '' || $host === '') return '';
$port = isset($p['port']) ? ':' . $p['port'] : '';
return $scheme . '://' . $host . $port;
}
function validateDoubanImageUrl($url) {
if (!is_string($url) || $url === '') return false;
$p = parse_url($url);
if (!$p || empty($p['host']) || empty($p['path'])) return false;
$origin = originOfUrl($p);
foreach (doubanImageAllowedOrigins() as $allowed) {
if ($origin !== '' && strcasecmp($origin, strtolower($allowed)) === 0) return true;
}
if (($p['scheme'] ?? '') !== 'https') return false;
if (!preg_match('/^img\d*\.doubanio\.com$/i', $p['host'])) return false;
if (!preg_match('#^/view/photo/[A-Za-z0-9_/]+\.(?:jpg|jpeg|png|webp)$#i', $p['path'])) return false;
return true;
}
// 把豆瓣图片地址转成「本站代理地址」。不能把豆瓣地址直接交给 <img>:
// 豆瓣图片有防盗链,浏览器直连一律 403(本站 Referrer-Policy: same-origin,
// 跨域图片不带 Referer,同样被拒)。
function thumbProxyUrl($img) {
if (!validateDoubanImageUrl($img)) return '';
return 'fetch_movie.php?action=thumb&u=' . rawurlencode($img);
}
// 判断候选是电影还是剧集:豆瓣的 suggest 里两者 type 都是 "movie",
// 只能靠 episode(集数)区分——有集数就是剧集。
function doubanKindLabel($type, $episode) {
if ((int)$episode > 1) return '剧集';
if ($type === '' || $type === 'movie') return '电影';
return (string)$type;
}
// 把 suggest 的原始结果整理成前端下拉要用的字段(过滤影人、去重、限量)
function buildSuggestItems(array $list, $limit = 8) {
$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 = isset($it['img']) ? (string)$it['img'] : '';
$episode = isset($it['episode']) ? (string)$it['episode'] : '';
$items[] = [
'id' => $id,
'title' => isset($it['title']) ? (string)$it['title'] : '',
'sub_title' => isset($it['sub_title']) ? (string)$it['sub_title'] : '',
'year' => isset($it['year']) ? (string)$it['year'] : '',
'episode' => $episode,
'kind' => doubanKindLabel($type, $episode),
'thumb' => $img !== '' ? thumbProxyUrl($img) : '',
];
if (count($items) >= $limit) break;
}
return $items;
}
// 删除上一次抓取但未被采用的海报。
// 只删 uploads/posters/ 下的文件,且必须确认没有任何电影记录引用它,才动手。
function cleanupOldPoster($key) {
$key = ltrim((string)$key, '/');
if (!preg_match('#^uploads/posters/[A-Za-z0-9._-]+$#', $key)) return;
try {
$db = getDB();
$stmt = $db->prepare("SELECT COUNT(*) FROM movies WHERE poster_path = ?");
$stmt->execute([$key]);
if ((int)$stmt->fetchColumn() > 0) return; // 已被引用,不能删
} catch (Exception $e) {
return; // 查询失败就保守地不删
}
storageDelete($key);
}
// 下载海报并入库,返回存储键(失败返回空串)
function storeRemotePoster($url) {
if (!is_string($url) || $url === '') return '';
// 豆瓣图片服务器要求带 Referer,否则 403
$res = httpGet($url, 'https://movie.douban.com/');
if (!$res['ok'] || $res['body'] === '') return '';
$binary = $res['body'];
// 海报通常几十 KB;给个上限,避免异常响应把内存吃满
if (strlen($binary) > 12 * 1024 * 1024) return '';
$mime = storageMimeFromContents($binary);
if (strpos($mime, 'image/') !== 0) {
// 环境无 fileinfo,或返回的不是图片(比如被反爬拦成 HTML 页面)
$mime = imageMimeFromMagic($binary);
if ($mime === '') return '';
}
$filename = fingerprintedFilename($binary, extForMime($mime), 'poster');
$key = relPathOfDir(POSTER_DIR) . $filename;
if (!storagePutContents($binary, $key, $mime)) return '';
return $key;
}
// ---------------- 主流程 ----------------
// 本接口会写入存储(下载海报),必须登录后才能调用。
// 这里不用 checkLogin()(它会 302 到 login.php),而是返回结构化错误,
// 便于前端在会话过期时给出明确提示。
if (!isset($_SESSION['admin_id'])) {
fetchFail('未登录或登录已过期,请重新登录后再试', 401);
}
$action = isset($_GET['action']) ? (string)$_GET['action'] : '';
// ---- 模式 3:片名候选列表(供下拉联想;只读,不取详情、不写任何文件)----
if ($action === 'suggest') {
$kw = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
if ($kw === '') {
echo json_encode(['ok' => true, 'items' => []], JSON_UNESCAPED_UNICODE);
exit;
}
$res = getJson(doubanSearchEndpoint($kw));
if (!$res['ok']) {
fetchFail('搜索失败:' . $res['err']);
}
$list = is_array($res['data']) ? $res['data'] : [];
echo json_encode(
['ok' => true, 'items' => buildSuggestItems($list)],
JSON_UNESCAPED_UNICODE
);
exit;
}
// ---- 模式 4:代理豆瓣缩略图 ----
// 浏览器不能直连豆瓣图片(防盗链,见 validateDoubanImageUrl 注释),只能由服务端代取。
// 只读、不落盘;配 Cache-Control 让浏览器缓存,重复展开下拉不再回源。
if ($action === 'thumb') {
$u = isset($_GET['u']) ? (string)$_GET['u'] : '';
if (!validateDoubanImageUrl($u)) {
http_response_code(400);
exit;
}
$res = httpGet($u, 'https://movie.douban.com/');
$binary = $res['ok'] ? $res['body'] : '';
if ($binary === '') {
http_response_code(404);
exit;
}
$mime = storageMimeFromContents($binary);
if (strpos($mime, 'image/') !== 0) {
$mime = imageMimeFromMagic($binary); // 环境无 fileinfo、或返回的不是图片
}
if ($mime === '') {
http_response_code(404);
exit;
}
header('Content-Type: ' . $mime); // 覆盖开头设的 JSON 类型
header('Content-Length: ' . strlen($binary));
header('Cache-Control: public, max-age=86400');
echo $binary;
exit;
}
if ($action !== '') {
fetchFail('不支持的 action:' . $action);
}
// ---- 模式 1/2:抓取详情 + 下载海报入库 ----
$q = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
$id = isset($_GET['id']) ? trim((string)$_GET['id']) : '';
if ($q === '' && $id === '') {
fetchFail('请输入电影名称');
}
$oldPoster = isset($_GET['old_poster']) ? trim((string)$_GET['old_poster']) : '';
// $cand 供后续取标题/年份/海报兜底;走 id 直连时没有搜索候选,留空数组即可
$cand = [];
if ($id !== '') {
// 模式 2:下拉选中后直接按条目 id 取详情——省一次搜索,也避免「再搜一次选错条目」
if (!preg_match('/^\d{1,12}$/', $id)) {
fetchFail('无效的豆瓣条目 ID');
}
} else {
// 模式 1:按片名搜索
$search = getJson(doubanSearchEndpoint($q));
if (!$search['ok']) {
fetchFail('搜索失败:' . $search['err']);
}
$list = is_array($search['data']) ? $search['data'] : [];
if (empty($list)) {
fetchFail('豆瓣没有找到「' . $q . '」,换个更准确的片名试试');
}
$picked = pickCandidate($list, $q);
if (!$picked || empty($picked['id'])) {
fetchFail('豆瓣没有找到「' . $q . '」的影视条目,换个更准确的片名试试');
}
$cand = $picked;
$id = (string)$picked['id'];
}
// 2) 详情
$detail = getJson(doubanDetailEndpoint($id), doubanDetailReferer($id));
if (!$detail['ok']) {
fetchFail('获取详情失败:' . $detail['err']);
}
$d = $detail['data'];
// 3) 组装字段
$title = isset($d['title']) && $d['title'] !== '' ? (string)$d['title'] : (string)($cand['title'] ?? $q);
$year = isset($d['year']) && $d['year'] !== '' ? (string)$d['year'] : (string)($cand['year'] ?? '');
$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 = '';
if (isset($d['pic']['normal']) && $d['pic']['normal'] !== '') {
$posterUrl = (string)$d['pic']['normal'];
} elseif (isset($d['cover_url']) && $d['cover_url'] !== '') {
$posterUrl = (string)$d['cover_url'];
} elseif (!empty($cand['img'])) {
$posterUrl = (string)$cand['img'];
}
// 4) 海报入库
$posterKey = '';
$posterDisplay = '';
if ($posterUrl !== '') {
$posterKey = storeRemotePoster($posterUrl);
if ($posterKey !== '') {
$posterDisplay = storageUrl($posterKey);
}
}
// 无论海报是否成功,都把上一次的残留清掉(若这次成功,old 与 new 不同名)
if ($oldPoster !== '' && $oldPoster !== $posterKey) {
cleanupOldPoster($oldPoster);
}
// 5) 输出
echo json_encode([
'ok' => true,
'source' => 'douban',
'douban_id' => $id,
'douban_url' => 'https://movie.douban.com/subject/' . $id . '/',
'title' => $title,
'original_title' => isset($d['original_title']) ? (string)$d['original_title'] : '',
'year' => $year,
'genre' => joinList($d['genres'] ?? []),
'runtime' => joinList($d['durations'] ?? []),
'director' => joinNames($d['directors'] ?? []),
'actors' => joinNames($d['actors'] ?? [], 6),
'plot' => isset($d['intro']) ? (string)$d['intro'] : '',
'rating' => $rating,
'country' => joinList($d['countries'] ?? []),
'alias' => joinList($d['aka'] ?? [], 3),
'poster' => $posterDisplay,
'poster_key' => $posterKey,
], JSON_UNESCAPED_UNICODE);