ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Flask和SQLite构建Atari杂志全文检索归档系统

用Flask和SQLite构建Atari杂志全文检索归档系统 Atari Legacy Magazine 是一个带有明显怀旧属性的数字归档项目它的目标不是简单做一份文章列表而是把关于 Atari 的老杂志、机种评测、游戏攻略和访谈资料通过结构化的方式保存下来并让读者能按年份、主题、杂志名快速检索。实际动手时这个项目牵扯到的并不只是“写个网页”而是版权确认、扫描件管理、元数据建模、OCR 文本化、全文索引和部署维护这一整条链路。接下来的内容会围绕 Atari Legacy Magazine 的搭建过程从需求拆解开始使用 Flask 和 SQLite 逐步完成一个最小可用的杂志归档系统。1. 先拆需求别把怀旧归档站做成简单的 PDF 列表1.1 这个项目解决什么问题为什么不能只放 PDF把老杂志数字化之后最常见的做法是建一个目录把 PDF 文件按年份和期次放进去再写一个静态页面列出来。这种方案对个人备份够用但它解决的问题是“文件能被找到”而不是“内容能被使用”。Atari Legacy Magazine 这类项目的核心价值是内容组织。一本杂志里通常有编辑寄语、新机评测、游戏攻略、玩家来信、厂商广告等多个栏目。读者搜索时往往不是想找“1980 年 1 月这一期”而是想找“所有提到 Pong 的文章”“某位作者写过的 Atari 400 评测”“某个硬件的价格表”。如果不把期次拆到文章粒度不把 OCR 后的文本保存下来这些需求都做不了。所以这个项目最少要完成三件事保存原始文件包括封面扫描件、内页扫描件或 PDF。为每一篇文章建立元数据包括标题、作者、页码、摘要、标签。对文章正文做全文索引让用户能跨期次、跨杂志搜索。PDF 是原始资料但在业务模型里只能算文件载体。更合理的模型是杂志是出版物的集合期次是杂志的一本文章是期次里的内容单元标签是跨期次的内容维度。1.2 核心功能拆分与最小可用范围在动手写代码前可以先把功能分成“必须做”“先不做”“以后扩展”三类。这样能避免项目一开始就陷入复杂的后台管理、权限系统或自动采集任务。功能是否优先说明杂志、期次、文章数据管理优先系统的基础结构没有这些就没有浏览和检索扫描件和封面文件存储优先至少要约定目录规则避免文件乱放文章正文全文检索优先区分普通列表和知识库的关键能力按年份、标签筛选优先让检索结果能按期刊背景缩小范围管理后台先不做可以用命令行导入脚本代替OCR 自动任务先做简化版先离线生成文本文件再手动导入用户评论、收藏、账号体系暂缓对归档站不是核心后期可扩展最小可用范围可以定义为一套命令行导入流程加三个页面首页列出所有杂志杂志详情页列出期次搜索页返回文章列表。再加上一个后台接口用于返回 JSON 格式的搜索结果。完成这个闭环后再逐步增加管理界面和自动流程。1.3 技术选型为什么用 Flask SQLite FTS5 起步Atari Legacy Magazine 的典型使用场景是个人或小团队维护的专题网站数据量在几万到几十万篇文章之间并发访问不会太高。这种场景不需要一开始就上重型数据库和微服务架构用 Flask 加 SQLite 可以更快跑通。方案适合场景优点劣势Flask SQLite FTS5个人归档站、低并发工具站点环境简单SQLite 单文件易备份FTS5 原生支持全文检索高并发写入能力弱复杂管理后台要自己实现Django PostgreSQL多作者内容平台、管理后台重ORM 和 Admin 生态成熟PostgreSQL 全文检索能力强项目结构偏重初期开发成本高Next.js PostgreSQL偏前端交互的社区展示站前后端一体交互体验好需要同时维护 Node 服务和数据库部署链路更长Spring Boot MySQL大型团队、企业级平台生态完善适合多人协作对个人归档项目过重SQLite 在低并发读多写少的场景下足够稳定而且一个.db文件可以直接复制备份。FTS5 是 SQLite 自带的全文索引模块适合英文和大多数按空格分词的文本。如果后期要处理大量中文内容可以再引入分词表或者把文本迁移到 PostgreSQL 的 tsvector。先选轻量方案不等于锁死架构。2. 环境准备与项目结构先把依赖固定下来2.1 Python 环境和依赖清单这个项目使用 Python 3.10 以上版本建议先创建虚拟环境再安装依赖。以下命令适用于 LinuxmacOS 和 Windows 的激活命令略有差异。python3 -m venv .venv source .venv/bin/activate python --version pip install -U pip在项目根目录创建requirements.txt示例内容如下Flask3.0.3 requests2.32.3 beautifulsoup44.12.3 PyYAML6.0.2 gunicorn22.0.0其中 Flask 用于 Web 服务requests 和 beautifulsoup4 用于后续采集或解析 HTML 元数据PyYAML 用来读取导入脚本的元数据文件gunicorn 在部署阶段启动服务。版本号是示例落地前要结合当前 Python 环境确认兼容性建议锁定版本后写入 requirements。安装依赖pip install -r requirements.txt需要注意如果只需要跑通基本功能requests 和 beautifulsoup4 可以暂时不装。但一旦准备做自动采集就会用到它们。为了避免后面反复改依赖先把常用依赖放进去。2.2 项目目录结构推荐使用如下目录结构把数据文件、模板、静态资源和导入脚本分开。atari-legacy-magazine/ ├── app.py ├── config.py ├── init_db.py ├── import_legacy.py ├── schema.sql ├── requirements.txt ├── data/ │ ├── meta/ │ │ └── sample.yaml │ └── scans/ │ └── atari-legacy/ │ └── 1980-01.pdf ├── static/ │ ├── css/ │ ├── img/ │ ├── covers/ │ └── js/ └── templates/ ├── base.html ├── index.html ├── magazine_detail.html ├── issue_detail.html ├── article_detail.html └── search.htmldata/meta存放每期杂志的元数据文件data/scans存放原始扫描件和 PDF。schema.sql用于初始化数据库init_db.py执行该文件import_legacy.py负责把元数据写入数据库。Web 层只负责查询和渲染这样导入与浏览逻辑能互相隔离。2.3 初始化数据库和基础配置在config.py中定义数据库路径和文件目录尽量让路径通过环境变量覆盖方便测试环境与生产环境切换。import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) DATABASE os.environ.get( ATARI_DB, os.path.join(BASE_DIR, data, legacy.db) ) SCAN_DIR os.environ.get( ATARI_SCAN_DIR, os.path.join(BASE_DIR, data, scans) ) COVER_DIR os.environ.get( ATARI_COVER_DIR, os.path.join(BASE_DIR, static, covers) ) DEBUG os.environ.get(ATARI_DEBUG, true).lower() true在app.py中实现一个连接 SQLite 的辅助函数每请求获取连接用完关闭。import sqlite3 from flask import Flask, g from config import DATABASE, DEBUG app Flask(__name__) app.config[DATABASE] DATABASE app.config[DEBUG] DEBUG def get_db(): if db not in g: g.db sqlite3.connect( app.config[DATABASE], detect_typessqlite3.PARSE_DECLTYPES, ) g.db.row_factory sqlite3.Row g.db.execute(PRAGMA foreign_keys ON) return g.db app.teardown_appcontext def close_db(error): db g.pop(db, None) if db is not None: db.close()init_db.py读取schema.sql并执行。为了后续方便可以用命令行参数指定数据库位置。import sqlite3 import sys from config import DATABASE def init_db(db_path): conn sqlite3.connect(db_path) with open(schema.sql, r, encodingutf-8) as f: conn.executescript(f.read()) conn.commit() conn.close() if __name__ __main__: db_path sys.argv[1] if len(sys.argv) 1 else DATABASE init_db(db_path) print(fdatabase initialized: {db_path})执行初始化后会在data目录下生成legacy.db。mkdir -p data python init_db.py这一步如果报错先检查data目录是否存在以及 Python 是否有权限写入当前目录。3. 数据建模杂志、期次、文章和标签的关系3.1 实体关系与字段设计核心模型可以拆成四张业务表加一张关联表。表名用途关键字段magazines杂志名称和出版信息id, name, publisher, start_year, end_year, descriptionissues某一期杂志id, magazine_id, issue_number, title, published_on, cover_path, pdf_patharticles期次内的文章id, issue_id, title, author, page_start, page_end, summary, ocr_texttags标签id, namearticle_tags文章与标签的多对多关联article_id, tag_id把杂志和期次分开是因为一本杂志有多期每一期有自己的出版日期、封面文件和 PDF 文件。把文章和期次分开是因为文章是检索的最小单位。如果只保存“某一期 PDF”搜索时无法定位到具体页也无法按文章展示结果。字段设计时要注意published_on使用 ISO 格式的日期字符串例如1980-01-15方便比较和排序。page_start和page_end是整数用于文章切分和 OCR 文本按页导入。ocr_text是长文本字段内容来自扫描件的文字识别结果可能包含大量换行和噪声。标签使用多对多关联因为一篇文章可能有“Atari 400”“游戏评测”“1980”等多个主题标签。3.2 建表 SQL 与 SQLite 约束schema.sql示例PRAGMA foreign_keys ON; CREATE TABLE IF NOT EXISTS magazines ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, publisher TEXT, start_year INTEGER, end_year INTEGER, description TEXT ); CREATE TABLE IF NOT EXISTS issues ( id INTEGER PRIMARY KEY AUTOINCREMENT, magazine_id INTEGER NOT NULL, issue_number TEXT NOT NULL, title TEXT NOT NULL, published_on TEXT, cover_path TEXT, pdf_path TEXT, FOREIGN KEY (magazine_id) REFERENCES magazines(id) ON DELETE CASCADE, UNIQUE (magazine_id, issue_number) ); CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id INTEGER NOT NULL, title TEXT NOT NULL, author TEXT, page_start INTEGER, page_end INTEGER, summary TEXT, ocr_text TEXT, FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS article_tags ( article_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, PRIMARY KEY (article_id, tag_id), FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_issues_magazine_id ON issues(magazine_id); CREATE INDEX IF NOT EXISTS idx_articles_issue_id ON articles(issue_id); CREATE INDEX IF NOT EXISTS idx_articles_title ON articles(title);这里的UNIQUE (magazine_id, issue_number)用来防止同一本杂志导入重复期次。ON DELETE CASCADE保证删除杂志后相关期次和文章一起删除避免数据库里出现孤儿数据。使用 SQLite 时外键约束默认是关闭的所以连接数据库后要执行PRAGMA foreign_keys ON。这在前面get_db()中已经加入但导入脚本里也要记得执行否则删除父记录时子记录可能仍然残留。3.3 启用 SQLite FTS5 全文索引articles 表使用普通LIKE查询做关键词搜索在大数据量下会非常慢也无法按相关性排序。SQLite 从 3.9 开始支持 FTS5 全文索引适合这个规模的项目。创建 FTS5 虚拟表并让它的内容和 articles 表保持同步CREATE VIRTUAL TABLE IF NOT EXISTS fts_articles USING fts5( title, summary, body, author, contentarticles, content_rowidid, tokenizeporter unicode61 ); CREATE TRIGGER IF NOT EXISTS fts_articles_ai AFTER INSERT ON articles BEGIN INSERT INTO fts_articles(rowid, title, summary, body, author) VALUES (new.id, new.title, new.summary, new.ocr_text, new.author); END; CREATE TRIGGER IF NOT EXISTS fts_articles_ad AFTER DELETE ON articles BEGIN INSERT INTO fts_articles(fts_articles, rowid, title, summary, body, author) VALUES (delete, old.id, old.title, old.summary, old.ocr_text, old.author); END; CREATE TRIGGER IF NOT EXISTS fts_articles_au AFTER UPDATE ON articles BEGIN INSERT INTO fts_articles(fts_articles, rowid, title, summary, body, author) VALUES (delete, old.id, old.title, old.summary, old.ocr_text, old.author); INSERT INTO fts_articles(rowid, title, summary, body, author) VALUES (new.id, new.title, new.summary, new.ocr_text, new.author); END;使用contentarticles方式创建外部内容表后FTS5 不复制原文到虚拟表只保存索引节省空间。触发器负责在新增、删除、更新文章时同步索引。需要特别说明FTS5 默认的unicode61分词器按空格和标点切分适合英文资料。如果资料是中文这种分词方式会把整句话当成一个 token导致检索效果很差。中文本地化通常需要额外建一个分词字段例如在导入时用 jieba 生成关键词字符串再放到 FTS5 表里。对于 Atari Legacy Magazine 的英文原版扫描件场景当前配置已经可用。4. 数据导入从扫描件到可检索文章4.1 文件目录约定和元数据清单导入流程的第一步不是写代码而是把扫描件和元数据文件组织好。目录命名要稳定建议按“杂志名/年份/期号”组织。data/scans/atari-legacy/1980/1980-01.pdf data/scans/atari-legacy/1980/1980-01-cover.jpg data/scans/atari-legacy/1980/1980-01-page-08.png data/scans/atari-legacy/1980/1980-01-page-09.png元数据文件建议放在独立目录使用 YAML 格式便于人工维护。一个元数据文件对应一期杂志。magazine: Atari Legacy issue_number: 1980-01 title: January 1980 published_on: 1980-01-15 cover: covers/atari-legacy-1980-01.jpg pdf: scans/atari-legacy/1980/1980-01.pdf articles: - title: Pong and Beyond: The Early Years author: John Doe page_start: 8 page_end: 14 summary: A short history of Pong and its influence on home consoles. tags: [Pong, History, Arcade] - title: Atari 400 Review author: Jane Smith page_start: 16 page_end: 22 summary: Hands-on review of the Atari 400 computer. tags: [Atari 400, Review, Hardware]命名规范的作用是让导入脚本能够按路径推断期次同时让 YAML 文件与扫描件一一对应。如果目录随意命名脚本里就要写大量判断逻辑而且很容易在重新导入时产生重复数据。4.2 用 Python 脚本读取目录并写入数据库import_legacy.py的核心思路是读取--meta目录下所有 YAML 文件对每个文件检查杂志、期次是否已存在再插入文章和标签。脚本需要支持重复执行因此插入前先查询是否已存在。import argparse import os import sqlite3 import yaml from config import DATABASE def connect(db_path): conn sqlite3.connect(db_path) conn.row_factory sqlite3.Row conn.execute(PRAGMA foreign_keys ON) return conn def get_or_create_magazine(conn, name): cur conn.execute( SELECT id FROM magazines WHERE name ?, (name,) ) row cur.fetchone() if row: return row[id] cur conn.execute( INSERT INTO magazines(name) VALUES (?), (name,) ) return cur.lastrowid def get_or_create_issue(conn, magazine_id, issue_number, title, published_on, cover_path, pdf_path): cur conn.execute( SELECT id FROM issues WHERE magazine_id ? AND issue_number ?, (magazine_id, issue_number), ) row cur.fetchone() if row: return row[id] cur conn.execute( INSERT INTO issues(magazine_id, issue_number, title, published_on, cover_path, pdf_path) VALUES (?, ?, ?, ?, ?, ?) , (magazine_id, issue_number, title, published_on, cover_path, pdf_path), ) return cur.lastrowid def import_yaml_file(conn, meta_path): with open(meta_path, r, encodingutf-8) as f: meta yaml.safe_load(f) magazine_id get_or_create_magazine(conn, meta[magazine]) issue_id get_or_create_issue( conn, magazine_id, meta[issue_number], meta[title], meta.get(published_on), meta.get(cover), meta.get(pdf), ) for article in meta.get(articles, []): cur conn.execute( INSERT INTO articles(issue_id, title, author, page_start, page_end, summary, ocr_text) VALUES (?, ?, ?, ?, ?, ?, ?) , ( issue_id, article[title], article.get(author), article.get(page_start), article.get(page_end), article.get(summary), article.get(ocr_text, ), ), ) article_id cur.lastrowid for tag_name in article.get(tags, []): cur conn.execute( SELECT id FROM tags WHERE name ?, (tag_name,) ) row cur.fetchone() if row: tag_id row[id] else: cur conn.execute( INSERT INTO tags(name) VALUES (?), (tag_name,) ) tag_id cur.lastrowid conn.execute( INSERT OR IGNORE INTO article_tags(article_id, tag_id) VALUES (?, ?), (article_id, tag_id), ) def main(): parser argparse.ArgumentParser(descriptionimport legacy magazine metadata) parser.add_argument(--meta, defaultdata/meta, helpmetadata directory) parser.add_argument(--db, defaultDATABASE, helpsqlite database path) args parser.parse_args() conn connect(args.db) try: for filename in sorted(os.listdir(args.meta)): if not filename.endswith((.yaml, .yml)): continue meta_path os.path.join(args.meta, filename) print(fimporting {meta_path}) import_yaml_file(conn, meta_path) conn.commit() print(import finished) except Exception: conn.rollback() raise finally: conn.close() if __name__ __main__: main()这段代码的关键点有三个标签不存在时先插入再建立关联。使用INSERT OR IGNORE避免重复关联。整个目录导入过程放在一个事务里一旦发现 YAML 格式错误或数据库约束冲突就回滚整个批次避免导入一半导致数据不完整。4.3 OCR 文本入库与后续清洗元数据中的ocr_text不是必须手动填写。更常见的做法是先离线用 OCR 工具识别扫描件再把识别结果写入 YAML 文件或单独文本文件。以下片段说明 Tesseract 的调用思路实际使用时需要结合本地环境和图片路径。# 需要额外安装 pytesseract 和 Pillow # import pytesseract # from PIL import Image # page Image.open(data/scans/atari-legacy/1980/1980-01-page-08.png) # text pytesseract.image_to_string(page, langeng)OCR 识别结果往往包含大量换行、页眉页脚和识别错误。建议导入前做两步清洗删除每页单独识别时出现的重复页眉页脚。根据page_start和page_end把文本切到对应文章下不要让整期杂志的文本混在一起。如果文章跨页需要在脚本中按页拼接。简单实现可以读取从page_start到page_end的所有页面文本用换行连接后写入ocr_text。注意OCR 和入库存量文件都涉及版权。不要对没有授权或自己无权的扫描件做公开上线内部整理也要注明来源公开发布前必须有明确的版权许可。5. 浏览与搜索的后端实现5.1 浏览页面的路由设计浏览页面至少需要四个路由首页、杂志详情、期次详情、文章详情。路由通过 URL 中的整数 ID 定位数据返回 HTML 模板。from flask import render_template app.route(/) def index(): db get_db() rows db.execute( SELECT m.id, m.name, m.publisher, m.start_year, m.end_year, m.description, COUNT(DISTINCT i.id) AS issue_count FROM magazines m LEFT JOIN issues i ON i.magazine_id m.id GROUP BY m.id ORDER BY m.name ).fetchall() return render_template(index.html, magazinesrows) app.route(/magazines/int:magazine_id) def magazine_detail(magazine_id): db get_db() magazine db.execute( SELECT * FROM magazines WHERE id ?, (magazine_id,) ).fetchone() issues db.execute( SELECT id, issue_number, title, published_on, cover_path FROM issues WHERE magazine_id ? ORDER BY published_on , (magazine_id,), ).fetchall() return render_template(magazine_detail.html, magazinemagazine, issuesissues)使用sqlite3.Row之后模板里可以用magazine.name的方式访问字段比数字下标更直观。每个路由都要对“数据不存在”的情况做处理否则用户访问不存在的 ID 会得到一个 500 错误更合理的是返回 404。5.2 搜索结果接口FTS5 排名和分页搜索接口设计成/api/search返回 JSON方便前端页面在输入框中异步请求。搜索核心是 FTS5 的MATCH查询。from flask import jsonify, request app.route(/api/search) def search_api(): db get_db() q request.args.get(q, ).strip() page max(1, request.args.get(page, 1, typeint)) per_page min(20, max(1, request.args.get(per_page, 10, typeint))) if not q: return jsonify({error: missing q, total: 0, items: []}), 400 search_query q.replace(, ) count_sql SELECT COUNT(*) FROM fts_articles WHERE fts_articles MATCH ? total db.execute(count_sql, (search_query,)).fetchone()[0] data_sql SELECT a.id, a.title, a.author, a.page_start, a.page_end, a.summary, a.issue_id, i.title AS issue_title, i.issue_number, i.published_on, m.name AS magazine_name, bm25(fts_articles) AS rank FROM fts_articles JOIN articles a ON a.id fts_articles.rowid JOIN issues i ON i.id a.issue_id JOIN magazines m ON m.id i.magazine_id WHERE fts_articles MATCH ? ORDER BY rank LIMIT ? OFFSET ? items db.execute( data_sql, (search_query, per_page, (page - 1) * per_page), ).fetchall() return jsonify({ q: q, total: total, page: page, per_page: per_page, items: [dict(row) for row in items], })这里有一个容易被忽略的坑FTS5 的MATCH查询语法里引号、括号、AND、OR、NOT 都有特殊含义。如果用户输入Pong OR History会被当成布尔查询。如果不希望用户使用复杂语法可以对输入做更严格的清洗比如只保留字母数字和空格或者把整个输入包成短语查询。上面的示例做了一层转义实际项目还要根据预期行为决定是否允许布尔语法。bm25(fts_articles)是 SQLite 内置的相关性排序函数数值越小表示越相关所以ORDER BY rank默认会把最相关的结果排前面。5.3 筛选年份与标签搜索接口还需要支持按年份和标签过滤。年份可以从published_on字段中截取标签要通过article_tags和tags表关联。year request.args.get(year, typeint) tag request.args.get(tag, ).strip() conditions [fts_articles MATCH ?] params [search_query] if year: conditions.append(substr(i.published_on, 1, 4) ?) params.append(str(year)) if tag: conditions.append( EXISTS ( SELECT 1 FROM article_tags at JOIN tags t ON t.id at.tag_id WHERE at.article_id a.id AND t.name ? ) ) params.append(tag) where_sql AND .join(conditions)过滤条件全部使用参数绑定不要用字符串拼接。年份字段如果没填或格式不对substr可能得不到预期结果所以发请求前要在前端做基础校验后端也要对year做范围限制。6. 页面模板与交互6.1 首页杂志封面网格首页通过index.html展示所有杂志。模板继承自base.html这里只给出关键片段。div classmagazine-grid {% for item in magazines %} a classmagazine-card href{{ url_for(magazine_detail, magazine_iditem.id) }} {% if item.cover %} img src{{ url_for(static, filenamecovers/ item.cover) }} alt{{ item.name }} {% else %} div classcover-placeholder{{ item.name }}/div {% endif %} h2{{ item.name }}/h2 p{{ item.publisher }} · {{ item.issue_count }} issues/p /a {% endfor %} /div使用url_for生成 URL可以避免硬编码路径。封面图统一放在static/covers目录如果没有封面就显示占位块。模板里要注意图片路径拼写大小写不一致经常导致图片不显示。6.2 文章详情页展示 OCR 全文与元数据文章详情页把文章的 OCR 文本展示出来同时显示所属杂志、期次、作者和页码。OCR 文本是纯文本不要用safe过滤器直接渲染成 HTML因为识别结果可能包含类似和的字符容易造成页面错乱也可能带来 XSS 风险。{% extends base.html %} {% block content %} h1{{ article.title }}/h1 p classmeta {{ magazine.name }} / {{ issue.title }} · {{ article.author or Unknown }} · page {{ article.page_start }}-{{ article.page_end }} /p {% if article.summary %} p classsummary{{ article.summary }}/p {% endif %} article classarticle-content pre{{ article.ocr_text }}/pre /article {% endblock %}使用pre可以保留 OCR 文本原有的换行和缩进比手动替换换行符更安全。页面样式上可以给.article-content设置适中的行高和最大宽度避免长文本行过宽影响阅读。6.3 搜索框与前端防抖搜索页面可以单独放在search.html也可以把搜索框放在导航栏输入时动态请求/api/search。为了避免每次按键都请求接口前端加入 300ms 防抖。let timer null; document.querySelector(#search-input).addEventListener(input, function (e) { clearTimeout(timer); const q e.target.value.trim(); if (q.length 2) { document.querySelector(#search-results).innerHTML ; return; } timer setTimeout(() { fetchResults(q); }, 300); }); async function fetchResults(q) { const resp await fetch(/api/search?q${encodeURIComponent(q)}); const data await resp.json(); renderResults(data); } function renderResults(data) { const container document.querySelector(#search-results); if (!data.items || data.items.length 0) { container.innerHTML pNo results/p; return; } const html data.items.map((item) div classsearch-item h3a href/articles/${item.id}${item.title}/a/h3 p${item.magazine_name} · ${item.issue_title} · ${item.author || Unknown}/p /div ).join(); container.innerHTML html; }前端渲染结果时标题是通过模板字符串直接插入 HTML 的。如果数据来自可信的数据库风险较低但如果未来引入用户生成内容必须改用 DOM API 或转义函数。搜索请求中的encodeURIComponent是必需的否则搜索词里包含、等字符时URL 会被截断或产生错误参数。7. 运行验证、常见坑和排查路径7.1 从空库到可搜索站的完整验证流程在完成代码和元数据文件后按以下顺序运行可以验证整个链路是否通# 1. 初始化数据库 mkdir -p data python init_db.py # 2. 导入一期示例数据 python import_legacy.py --meta data/meta --db data/legacy.db # 3. 启动开发服务器 flask --app app.py run --debug # 4. 在另一个终端请求搜索接口 curl http://127.0.0.1:5000/api/search?qPong如果一切正常curl会返回一段 JSON包含total和items。例如{ q: Pong, total: 1, page: 1, per_page: 10, items: [ { id: 1, title: Pong and Beyond: The Early Years, author: John Doe, page_start: 8, page_end: 14, issue_title: January 1980, magazine_name: Atari Legacy } ] }验证时要同时检查三件事搜索结果的数量是否正确、点开文章详情能否看到 OCR 全文、页面里的封面和 CSS 是否能正常加载。只看接口返回还不够要把页面点击路径也走一遍。7.2 常见问题排查表问题现象常见原因检查方式处理建议搜索时报no such table: fts_articles数据库初始化时没有执行 FTS5 建表语句查看schema.sql是否包含CREATE VIRTUAL TABLE用 SQLite 工具打开 db 查表重新执行python init_db.py或手动补建 FTS5 表和触发器搜索返回结果为空MATCH 查询词被 FTS5 当成语法关键字打印实际传给 SQLite 的 search_query对用户输入做转义或限制只能使用普通短语查询中文搜索不到结果FTS5 默认分词器不切分中文用SELECT * FROM fts_articles WHERE fts_articles MATCH 测试验证导入时额外生成分词字段或迁移到 PostgreSQL导入时报 UNIQUE 约束失败同一期次被重复导入查询issues表确认是否已有相同magazine_id issue_number调整导入脚本使用INSERT OR IGNORE或先查询再插入启动后数据库被锁SQLite 默认不开启 WAL多个进程同时写入会冲突查看日志中是否有database is locked连接后执行PRAGMA journal_modeWAL; PRAGMA busy_timeout5000;封面图片不显示文件路径或文件名大小写不一致在浏览器打开图片 URL看是否 404统一使用小写文件名路径用url_for生成7.3 一个完整错误日志排查示例下面是一个真实启动时容易遇到的错误sqlite3.OperationalError: no such table: fts_articles这个错误说明 FTS5 虚拟表没有创建。可能是在init_db.py之前就执行了其他脚本也可能先前的schema.sql里漏掉了 FTS5 部分。排查路径检查data/legacy.db是否已经存在如果存在可能是旧版本。打开 SQLite 命令行执行.tables确认有没有fts_articles。如果表不存在先备份现有数据再确认schema.sql中有 CREATE VIRTUAL TABLE
返回列表