Files
laoyang 7138314dc7 v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:

1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
   → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
     活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
     节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
     (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)

2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
     旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
   → 活动**不再存**这三样,全部**读时算出来**:
     · Trip::derived()      最早开始 / 最晚结束 / 城市去重
     · Trip::cities()       按最早出现时间排(先后顺序本身是信息,不是字母序)
     · Trip::attachDerived() 列表页批量补(避免 N+1)
     · Trip::activitySpan()  没有旅程时说「还没有旅程」,不用「日期待补」
     create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
     列表排序/搜索、首页「最近去了」改走子查询。

3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
   「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
   第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
   于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
   → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
     app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
     三处(头像 / 题图 / 图书封面)全挂上。

4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
   → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
     不说清用户会满页找"怎么没地方填时间")。
     备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。

顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
  php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
  于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。

验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
2026-10-05 10:58:17 +08:00

546 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
/**
* 图书解析(v24 第 14 批 用户第 5 条):上传一本书 → 读出元数据与正文。
*
* 用户原话:「可以上传图书,可以解析各种格式的图书」。
*
* 能力边界(**页面上必须如实写出来**,别让人以为传什么都能读):
* · **EPUB** —— 完整解析:元数据(书名/作者/出版社/语言/ISBN)+ 封面 + 按 spine 顺序
* 拆出每一章的正文。这是电子书的事实标准,优先做好。
* · **TXT** —— 解析:自动认编码(UTF-8 / GBK / BIG5),按"第X章"切章;
* 切不出来就按 5000 字一段(否则一本 50 万字的书会变成一整页)。
* · **PDF** —— **不提取正文**(服务端没有 PDF 解析扩展,硬写会错得很自信)。
* 文件照存,阅读页用浏览器内嵌的 PDF 阅读器看(每家浏览器都有)。
* · **MOBI / AZW3 / 其它** —— 不解析正文(亚马逊的私有格式,解析要一本书的成本)。
* 文件照存、元数据照手填,仍然能进书架、能记笔记、能统计。
*
* ⚠️ 解析**只做纯文本**:不保留排版、图片、脚注。
* 这是"能读"与"原样还原"之间的取舍 —— 家庭书架要的是"随时翻两页",
* 不是排版引擎(那是一个独立项目的量)。
*
* @return array{ok:bool,error?:string,meta:array,chapters:array,cover?:array}
* cover = ['tmp'=>临时文件路径, 'mime'=>…](有封面才有;调用方负责上传与删临时文件)
*/
declare(strict_types=1);
namespace App;
final class BookParser
{
/** 单章上限(字符):超过就截断,防止一本巨书把库撑爆 */
private const CHAPTER_MAX = 200000;
/** 一本书最多解析多少章 */
private const CHAPTER_LIMIT = 600;
/**
* @param string $path 临时文件路径
* @param string $ext 扩展名(小写)
* @param string $name 原始文件名(用来兜底猜书名/作者)
*/
public static function parse(string $path, string $ext, string $name = ''): array
{
if (!is_file($path)) {
return ['ok' => false, 'error' => '文件读不到', 'meta' => [], 'chapters' => []];
}
$ext = strtolower($ext);
if ($ext === '' && $name !== '') {
$ext = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
}
switch ($ext) {
case 'epub':
return self::parseEpub($path);
case 'txt':
case 'text':
return self::parseTxt($path, $name);
case 'pdf':
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
'chapters' => [], 'note' => 'PDF 只存文件(服务端不提取正文),阅读时用浏览器自带的 PDF 阅读器看。'];
default:
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name),
'chapters' => [], 'note' => self::formatWord($ext) . ' 正文解析不了,文件已经存好了,元数据请手填。'];
}
}
// ---------------- EPUB ----------------
/**
* EPUB = 一个 zip 包:META-INF/container.xml 指路 → .opf 里放元数据与清单 →
* spine 决定章节顺序 → 每一章是一个 XHTML 文件。
*
* ⚠️ 路径要**相对 opf 所在目录**拼(很多书的 OEBPS/ 与 opf 不在一起),
* 直接用 opf 的路径当基准去拼会 404(ZipArchive::getFromName 返回 false)。
*/
private static function parseEpub(string $path): array
{
if (!class_exists('ZipArchive')) {
return ['ok' => true, 'error' => '', 'meta' => [], 'chapters' => [],
'note' => '服务器没有 zip 扩展,EPUB 的正文解析不了;文件已保存,元数据请手填。'];
}
$zip = new \ZipArchive();
$rc = $zip->open($path);
if ($rc !== true) {
return ['ok' => false, 'error' => '这个 EPUB 打不开(错误码 ' . (int)$rc . ')',
'meta' => [], 'chapters' => []];
}
// ① 找 OPF(书的"目录清单")
$opfPath = '';
$container = $zip->getFromName('META-INF/container.xml');
if ($container !== false && $container !== '') {
if (preg_match('/full-path\s*=\s*["\']([^"\']+)["\']/i', $container, $m)) {
$opfPath = trim($m[1]);
}
}
if ($opfPath === '') {
// 有些书没写 container.xml(不合规范但确实存在)→ 自己找第一个 .opf
for ($i = 0; $i < $zip->numFiles; $i++) {
$n = (string)$zip->getNameIndex($i);
if (substr(strtolower($n), -4) === '.opf') {
$opfPath = $n;
break;
}
}
}
if ($opfPath === '') {
$zip->close();
return ['ok' => false, 'error' => '这个 EPUB 里找不到目录文件(.opf),可能已损坏',
'meta' => [], 'chapters' => []];
}
$opfDir = self::dirOf($opfPath);
$opf = (string)$zip->getFromName($opfPath);
$zip->close();
if ($opf === '') {
return ['ok' => false, 'error' => '这个 EPUB 的目录文件是空的', 'meta' => [], 'chapters' => []];
}
$meta = self::opfMeta($opf);
// ② 清单:id → 文件
$manifest = self::opfManifest($opf);
// ③ 顺序:spine 里的 idref
$spine = self::opfSpine($opf, $manifest);
// ④ 封面:metadata 里的 <meta name="cover" content="id">,或 manifest 里 properties 带 cover-image
$cover = self::epubCover($path, $opf, $opfDir, $manifest);
$zip2 = new \ZipArchive();
if ($zip2->open($path) !== true) {
return ['ok' => true, 'meta' => $meta, 'chapters' => [], 'cover' => $cover,
'note' => '元数据读到了,正文没读出来(文件打不开第二次)。'];
}
$chapters = [];
$seq = 0;
foreach ($spine as $href) {
if (count($chapters) >= self::CHAPTER_LIMIT) {
break;
}
$full = self::joinPath($opfDir, $href);
$raw = $zip2->getFromName($full);
// ⚠️ 有些书的 spine 里写了 HTML 实体或大小写不一致 → 退一步按名字再试
if ($raw === false) {
$raw = $zip2->getFromName(ltrim($href, '/'));
}
if ($raw === false || $raw === '') {
continue;
}
[$title, $body] = self::xhtmlToText($raw);
if (trim($body) === '') {
continue;
}
$seq++;
$chapters[] = [
'seq' => $seq,
'title' => $title !== '' ? $title : ('第 ' . $seq . ' 章'),
'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8'),
];
}
$zip2->close();
return ['ok' => true, 'error' => '', 'meta' => $meta, 'chapters' => $chapters, 'cover' => $cover];
}
/** OPF 的 metadata:书名 / 作者 / 出版社 / 语言 / ISBN / 简介 */
private static function opfMeta(string $opf): array
{
$get = static function (string $tag) use ($opf): string {
if (preg_match_all('#<dc:' . $tag . '[^>]*>(.*?)</dc:' . $tag . '>#is', $opf, $m)) {
foreach ($m[1] as $v) {
$v = trim(html_entity_decode(strip_tags($v), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($v !== '') {
return $v;
}
}
}
return '';
};
$isbn = '';
if (preg_match_all('#<dc:identifier[^>]*>(.*?)</dc:identifier>#is', $opf, $mm)) {
foreach ($mm[0] as $i => $full) {
$v = trim(strip_tags($mm[1][$i]));
// opf:scheme="ISBN" 或 urn:isbn:xxxx
if (stripos($full, 'ISBN') !== false || stripos($v, 'isbn') === 0) {
$isbn = preg_replace('/^urn:isbn:/i', '', $v) ?? '';
break;
}
if ($isbn === '' && preg_match('/^(97[89])?\d{9}[\dXx]$/', $v)) {
$isbn = $v;
}
}
}
$date = $get('date');
return [
'title' => $get('title'),
'subtitle' => '',
'author' => $get('creator'),
'publisher' => $get('publisher'),
'language' => $get('language'),
'isbn' => $isbn,
'published' => $date !== '' ? substr($date, 0, 10) : '',
'summary' => $get('description'),
];
}
/** @return array<string,array{href:string,type:string,props:string}> id => 项 */
private static function opfManifest(string $opf): array
{
$out = [];
if (!preg_match_all('#<item\s+([^>]*?)/?>#is', $opf, $m)) {
return $out;
}
foreach ($m[1] as $attr) {
$a = self::attrs($attr);
$id = (string)($a['id'] ?? '');
if ($id === '') {
continue;
}
$out[$id] = [
'href' => html_entity_decode((string)($a['href'] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'type' => strtolower((string)($a['media-type'] ?? '')),
'props' => strtolower((string)($a['properties'] ?? '')),
];
}
return $out;
}
/** @return string[] 按阅读顺序排的正文文件(相对 opf 目录) */
private static function opfSpine(string $opf, array $manifest): array
{
$ids = [];
if (preg_match('#<spine\b[^>]*>(.*?)</spine>#is', $opf, $m)) {
if (preg_match_all('#<itemref\s+([^>]*?)/?>#is', $m[1], $mm)) {
foreach ($mm[1] as $attr) {
$a = self::attrs($attr);
$id = (string)($a['idref'] ?? '');
if ($id !== '') {
$ids[] = $id;
}
}
}
}
$out = [];
foreach ($ids as $id) {
$it = $manifest[$id] ?? null;
if (!$it) {
continue;
}
$t = (string)$it['type'];
// 只收正文档(xhtml / html / xml);图片、css、ncx 跳过
if ($t !== '' && strpos($t, 'html') === false && strpos($t, 'xml') === false) {
continue;
}
$out[] = (string)$it['href'];
}
// spine 为空(不合规范)→ 退一步:按 manifest 里所有 html 收
if (!$out) {
foreach ($manifest as $it) {
$t = (string)$it['type'];
if (strpos($t, 'html') !== false) {
$out[] = (string)$it['href'];
}
}
}
return $out;
}
/** 封面:找出图并存到临时文件(调用方再交给 Upload) */
private static function epubCover(string $path, string $opf, string $opfDir, array $manifest): ?array
{
$href = '';
if (preg_match('#<meta\s+[^>]*name\s*=\s*["\']cover["\'][^>]*content\s*=\s*["\']([^"\']+)["\']#is', $opf, $m)) {
$id = trim($m[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
if ($href === '') {
if (preg_match('#<meta\s+[^>]*content\s*=\s*["\']([^"\']+)["\'][^>]*name\s*=\s*["\']cover["\']#is', $opf, $m2)) {
$id = trim($m2[1]);
$href = (string)($manifest[$id]['href'] ?? '');
}
}
if ($href === '') {
foreach ($manifest as $it) {
if (strpos((string)$it['props'], 'cover-image') !== false) {
$href = (string)$it['href'];
break;
}
}
}
if ($href === '') {
foreach ($manifest as $it) {
if (strpos((string)$it['type'], 'image/') === 0
&& stripos((string)$it['href'], 'cover') !== false) {
$href = (string)$it['href'];
break;
}
}
}
if ($href === '') {
return null;
}
if (!class_exists('ZipArchive')) {
return null;
}
$zip = new \ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$full = self::joinPath($opfDir, $href);
$bin = $zip->getFromName($full);
if ($bin === false) {
$bin = $zip->getFromName(ltrim($href, '/'));
}
$zip->close();
if ($bin === false || $bin === '') {
return null;
}
$tmp = tempnam(sys_get_temp_dir(), 'fbcov');
if ($tmp === false || @file_put_contents($tmp, $bin) === false) {
return null;
}
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
if (strpos($mime, 'image/') !== 0) {
@unlink($tmp);
return null;
}
return ['tmp' => $tmp, 'mime' => $mime];
}
/**
* XHTML → 纯文本(保留段落换行)。
*
* ⚠️ 不是"去标签"就完事:
* ① 先砍掉 script / style / head(不然 CSS 与 JS 会混进正文);
* ② 块级标签换成换行(<p><div><br><h*>),否则整章粘成一团;
* ③ 实体还原(&nbsp; &amp; …)之后再 strip_tags;
* ④ 连续空行压成一个 —— 电子书的源文件里空行多到能占一半篇幅。
*/
private static function xhtmlToText(string $html): array
{
$title = '';
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
$title = trim(html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
$body = $html;
if (preg_match('#<body\b[^>]*>(.*)</body>#is', $body, $m2)) {
$body = $m2[1];
if ($title === '' && preg_match('#<h[1-4][^>]*>(.*?)</h[1-4]>#is', $body, $m3)) {
$title = trim(html_entity_decode(strip_tags($m3[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
}
$body = preg_replace('#<(script|style|noscript)\b[^>]*>.*?</\1>#is', '', $body) ?? $body;
$body = preg_replace('#<br\s*/?>#i', "\n", $body) ?? $body;
$body = preg_replace('#</(p|div|h[1-6]|li|tr|blockquote)>#i', "\n", $body) ?? $body;
$body = html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = str_replace(["\r\n", "\r", "\xC2\xA0"], ["\n", "\n", ' '], $body);
$body = preg_replace('/[ \t]+\n/', "\n", $body) ?? $body;
$body = preg_replace('/\n{3,}/', "\n\n", $body) ?? $body;
$body = preg_replace('/^\n+|\n+$/', '', $body) ?? $body;
return [mb_substr($title, 0, 300, 'UTF-8'), $body];
}
// ---------------- TXT ----------------
private static function parseTxt(string $path, string $name): array
{
$raw = (string)@file_get_contents($path);
if ($raw === '') {
return ['ok' => false, 'error' => '这个文本文件是空的', 'meta' => [], 'chapters' => []];
}
$text = self::toUtf8($raw);
$chapters = self::splitChapters($text);
return ['ok' => true, 'error' => '', 'meta' => self::guessFromName($name), 'chapters' => $chapters];
}
/**
* 编码兜底:UTF-8 → GB18030(中文电子书最常见的老编码)→ BIG5 → Latin1。
* ⚠️ 不认编码就硬读,中文会整页乱码 —— 那是"看起来解析了、其实全是问号"。
*/
private static function toUtf8(string $raw): string
{
if (substr($raw, 0, 3) === "\xEF\xBB\xBF") {
return substr($raw, 3);
}
if (mb_check_encoding($raw, 'UTF-8')) {
return $raw;
}
foreach (['GB18030', 'BIG-5', 'ISO-8859-1'] as $enc) {
$s = @iconv($enc, 'UTF-8//IGNORE', $raw);
if ($s !== false && $s !== '') {
return $s;
}
}
return $raw;
}
/**
* 切章:认"第X章/第X节/序/前言/后记/Chapter N"这类行;
* 认不出来就按 5000 字一段(不然一本长篇会变成一整页,浏览器直接卡死)。
*/
private static function splitChapters(string $text): array
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
$lines = explode("\n", $text);
$re = '/^\s*(第\s*[0-9零一二三四五六七八九十百千万]+\s*[章节回部篇卷集]\s*'
. '|chapter\s+[0-9ivx]+\b'
. '|序章|序言|前言|自序|后记|附录|尾声|楔子)/iu';
$chapters = [];
$curTitle = '';
$buf = '';
$flush = static function () use (&$chapters, &$curTitle, &$buf): void {
$body = trim($buf);
if ($body !== '') {
$chapters[] = ['title' => $curTitle, 'content' => mb_substr($body, 0, self::CHAPTER_MAX, 'UTF-8')];
}
$buf = '';
};
foreach ($lines as $ln) {
$t = trim($ln);
if ($t !== '' && mb_strlen($t, 'UTF-8') <= 60 && preg_match($re, $t)) {
$flush();
$curTitle = mb_substr($t, 0, 200, 'UTF-8');
continue;
}
$buf .= $ln . "\n";
}
$flush();
if (count($chapters) <= 1) {
// 没切出来 → 按字数均分
$chapters = [];
$n = mb_strlen($text, 'UTF-8');
$size = 5000;
$parts = (int)max(1, ceil($n / $size));
for ($i = 0; $i < $parts; $i++) {
$seg = mb_substr($text, $i * $size, $size, 'UTF-8');
if (trim($seg) === '') {
continue;
}
$chapters[] = ['title' => '第 ' . ($i + 1) . ' 段', 'content' => $seg];
}
}
$out = [];
$seq = 0;
foreach ($chapters as $c) {
if (count($out) >= self::CHAPTER_LIMIT) {
break;
}
$seq++;
$out[] = ['seq' => $seq, 'title' => (string)$c['title'], 'content' => (string)$c['content']];
}
return $out;
}
// ---------------- 文件名猜书名 / 作者 ----------------
/**
* 从 "三体(全三册)- 刘慈欣.epub" 这类文件名里猜。
* ⚠️ 只是**兜底**:猜错了用户一眼能改,比空着强(空着要一个字一个字敲)。
*/
private static function guessFromName(string $name): array
{
$base = pathinfo((string)$name, PATHINFO_FILENAME);
$base = trim(preg_replace('/[\[\((【].*?[\]\))】]/u', '', $base) ?? $base);
$author = '';
$title = $base;
foreach ([' - ', '—', '-', '_'] as $sep) {
if (strpos($base, $sep) !== false) {
$parts = explode($sep, $base);
$title = trim((string)array_pop($parts));
$author = trim(implode(' ', $parts));
break;
}
}
// "作者 书名" 反过来也常见:名字很短的那一段更像作者
if ($author === '' && preg_match('/^(.+?)\s{1}(.+)$/u', $base, $m)) {
$author = trim($m[1]);
$title = trim($m[2]);
}
$title = trim(preg_replace('/\.(epub|txt|pdf|mobi|azw3?)$/i', '', $title) ?? $title);
return [
'title' => mb_substr($title, 0, 300, 'UTF-8'),
'subtitle' => '',
'author' => mb_substr($author, 0, 200, 'UTF-8'),
'publisher' => '',
'language' => '',
'isbn' => '',
'published' => '',
'summary' => '',
];
}
public static function formatWord(string $ext): string
{
$map = ['epub' => 'EPUB', 'txt' => 'TXT', 'pdf' => 'PDF', 'mobi' => 'MOBI',
'azw' => 'AZW', 'azw3' => 'AZW3', 'doc' => 'DOC', 'docx' => 'DOCX'];
return $map[strtolower($ext)] ?? strtoupper($ext);
}
/** 这个文件能不能直接放进浏览器内嵌阅读(PDF 就是) */
public static function inlinable(?string $fmt): bool
{
return strtolower((string)$fmt) === 'pdf';
}
// ---------------- 小工具 ----------------
/** @return array<string,string> */
private static function attrs(string $s): array
{
$out = [];
if (preg_match_all('/([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(["\'])(.*?)\2/s', $s, $m, PREG_SET_ORDER)) {
foreach ($m as $x) {
$out[strtolower($x[1])] = $x[3];
}
}
return $out;
}
private static function dirOf(string $p): string
{
$p = str_replace('\\', '/', $p);
$i = strrpos($p, '/');
return $i === false ? '' : substr($p, 0, $i + 1);
}
/** 把 opf 目录与 href 拼成 zip 内的完整路径(顺手去掉 ../ 与锚点) */
private static function joinPath(string $dir, string $href): string
{
$href = preg_replace('/#.*$/', '', $href) ?? $href;
$href = str_replace('\\', '/', trim(urldecode($href)));
$full = $dir . $href;
// 消掉 a/b/../c
$parts = [];
foreach (explode('/', str_replace('\\', '/', $full)) as $seg) {
if ($seg === '' || $seg === '.') {
continue;
}
if ($seg === '..') {
array_pop($parts);
continue;
}
$parts[] = $seg;
}
return implode('/', $parts);
}
}