PDF解析、结构化与FTS5检索:Runningman游戏大全实战
2026/9/17 14:44:11 网站建设 项目流程

简介:这份《Runningman游戏大全》文档面向团队活动策划者、综艺编导、聚会组织者及拓展培训教练,系统整理了跑男类竞技游戏的玩法与规则,帮助解决活动创意不足、游戏环节难以设计的问题。压缩包内仅含1个PDF文件,体积约41KB,以文字条目形式罗列游戏说明,涵盖泥潭抢裤子、撕名牌、指压板接力、一杯茶时间、水池飞椅等经典项目,也收录了对眼游戏、词语炸弹接龙、斗鸡摔跤、看眼色游戏等数十种低成本易落地的玩法。每个条目均标注参与人数、胜负判定与胜负条件,部分附带变体规则与情侣战、间谍战等情景设定,可直接改编为现场执行方案。目前已有81人学习下载,适合需要快速攒出一整套游戏脚本、按主题检索并灵活组合环节的读者参考使用。

1. 从「Runningman游戏大全.pdf」说起:一份规则手册怎么变成能查的数据

团建前一晚,策划拿到一份叫Runningman游戏大全.pdf的文档,两百多页,每页一到两个游戏,格式是「编号 + 游戏名 + 人数 + 道具 + 规则」。他想按「不用道具」「10 人以内」「室内可玩」三个条件筛出十个游戏,结果 Ctrl+F 只能按关键词碰运气,翻到第 80 页已经忘了第 20 页写过什么。这不是文档质量问题,是 PDF 这种载体天生只保证「看起来一样」,不保证「能被程序读懂」。

把这本游戏大全做成可检索数据的难点,其实不在文字识别。大部分这类 PDF 是 Word 导出的文本型文件,get_text()一行就能把字全抠出来。真正的坑在版面语义:哪些行是游戏标题、哪些行是上一段规则的换行续写、页眉的「Runningman游戏大全」和页码怎么剥掉、跨页的规则怎么拼回去。这就是 PDF解析 最容易被低估的一步——抽字容易,切段难。

下面按「摸版面 → 结构化 → 建索引 → 长期维护」的路径走一遍,目标产出是一份字段完整的 JSON,加上一个能按人数、道具、场地检索的小服务,顺带解决导出、打印、扫描件纠偏这些绕不开的活儿。

2. 摸清版面:PDF解析「Runningman游戏大全.pdf」的两套取词方案

2.1 先分型:文本型 PDF 还是扫描型 PDF

拿到文件先别写抽取逻辑,第一步是判断页面的「可读性」。同一份Runningman游戏大全.pdf里经常混着两种页:前 150 页是排版好的文本页,后面附录是扫描进去的图片页。抽样探测比全量解析快得多,也避免第一轮就跑几分钟。

import fitz # PyMuPDF def probe_pdf(path, sample=8): doc = fitz.open(path) total = doc.page_count step = max(1, total // sample) # 均匀抽样,避免只看前几页得出错误结论 for pno in range(0, total, step): page = doc[pno] text = page.get_text("text").strip() area = page.rect.width * page.rect.height img_area = 0.0 for blk in page.get_text("dict")["blocks"]: if blk["type"] == 1: # type==1 表示图像块,0 是文本块 r = fitz.Rect(blk["bbox"]) img_area += r.width * r.height print(f"p{pno+1} chars={len(text)} img={img_area/area:.0%} " f"box={page.rect.width:.0f}x{page.rect.height:.0f}") doc.close() probe_pdf("Runningman游戏大全.pdf")

fitz.Rectbbox(x0, y0, x1, y1)四点坐标,单位是点(1 点 = 1/72 英寸)。A4 竖版大约是595 x 842,如果探测输出的 box 是612 x 792,说明原文件按 Letter 排的,后面算页眉页脚阈值要跟着换。

判定标准很直白:

现象判定处理路线
chars > 200 且 img < 30%文本型直接走 PyMuPDF / pdfplumber
chars < 50 且 img > 60%扫描型先纠偏、漂白加深,再 OCR
chars 100~200,img 40%~60%混合型文本优先,空段落回退 OCR

提示:page.get_text("text")对空白页返回''而不是报错,所以统计前一定要.strip(),否则空格会把 chars 撑到几十。

2.2 PyMuPDF 的 blocks 抽取:拿到标题行的字号与坐标

判断出是文本型之后,接下来要回答「哪一行是游戏标题」。人眼靠字号和加粗,程序同样能用这两个特征。把每个 span 的字号打出来,找全文的第二个字号峰值(第一个通常是正文),那个值基本就是标题号。

import fitz from collections import Counter def size_histogram(path): doc = fitz.open(path) hist = Counter() for page in doc: for blk in page.get_text("dict")["blocks"]: if blk["type"] != 0: continue for line in blk["lines"]: for span in line["spans"]: if span["text"].strip(): hist[round(span["size"], 1)] += len(span["text"]) doc.close() return hist.most_common(6) print(size_histogram("Runningman游戏大全.pdf")) # 典型输出: [(10.5, 183420), (16.0, 9120), (12.0, 3400), ...]

输出里10.5是正文、16.0是标题、12.0可能是小标题或表格文字。这个分布因文件而异,所以不要写死阈值,用「正文字号 + 4」当动态门槛更稳。

拿到字号后,按行聚合并过滤:

def title_candidates(path, body_size=10.5): doc = fitz.open(path) out = [] for pno, page in enumerate(doc): for blk in page.get_text("dict")["blocks"]: if blk["type"] != 0: continue for line in blk["lines"]: spans = line["spans"] if not spans: continue text = "".join(s["text"] for s in spans).strip() size = round(max(s["size"] for s in spans), 1) y0 = round(line["bbox"][1], 1) # 字号大于正文、行长短合理、避开页面顶部 60 点的页眉区 if size >= body_size + 4 and 2 <= len(text) <= 24 and y0 > 60: out.append((pno + 1, size, y0, text)) doc.close() return out

size >= body_size + 4是经验值,太小会混进小标题,太大会漏掉压缩排版的标题。y0 > 60排除页眉,底部同理用y1 < page.rect.height - 50排除页码。这一步的产出是候选集,不是最终结果,下一章再用正则收口。

2.3 pdfplumber 的 word 级坐标与表格抽取

PyMuPDF 快,pdfplumber 细。这本书里有一部分游戏用三列表格写「人数 / 时长 / 道具」,纯文本流会把三列串成一坨,所以要靠 pdfplumber 的坐标能力救回来。

import pdfplumber with pdfplumber.open("Runningman游戏大全.pdf") as pdf: page = pdf.pages[10] # 页面尺寸同样是点单位 print(page.width, page.height) for w in page.extract_words(use_text_flow=True, extra_attrs=["size"])[:15]: print(round(w["top"], 1), round(w["x0"], 1), round(w["size"], 1), w["text"]) tables = page.extract_tables({ "vertical_strategy": "lines", # 以矢量线为列边界,比 text 策略稳 "horizontal_strategy": "lines", "snap_tolerance": 4, }) for t in tables: print(t)

vertical_strategy="lines"依赖 PDF 里真的画了表格线。如果输出全是None,改成"text"让 pdfplumber 按文字对齐推断列边界;snap_tolerance=4表示 4 点内的线视为同一条,太大容易把相邻列合并。extra_attrs=["size"]顺手把字号带出来,可以和 PyMuPDF 的结果交叉验证。

2.4 页眉页脚与页面尺寸的先验处理

在切段落之前,先把每页重复出现的噪音清掉。做法是扫全文档前两行和最后两行的文本,出现频率超过 60% 的直接判定为页眉页脚:

from collections import Counter def find_running_headers(path): doc = fitz.open(path) top, bottom = Counter(), Counter() for page in doc: lines = [l.strip() for l in page.get_text("text").splitlines() if l.strip()] if lines: top[lines[0]] += 1 bottom[lines[-1]] += 1 doc.close() n = max(1, doc.page_count) return [t for t, c in top.items() if c / n > 0.6], \ [b for t, c in bottom.items() if c / n > 0.6]

顺手记一下page.rect.width的众数。有些 PDF 是 A4 和 A5 混排,切段落时按尺寸分组,别用一套字节阈值套所有页。这个尺寸统计也就是常说的 pdf统计尺寸,离线批量处理用它来决定后续 OCR 的缩放倍数最省事。

3. 把「游戏名 + 人数 + 道具 + 规则」抽成结构化 JSON

3.1 标题正则与段落切分策略

有了字号候选集,还要过一遍文本形态。这本大全的编号格式高度统一,1、撕名牌02. 指压板接力第 3 关 水枪大战都有,正则要能同时吃掉。

import re GAME_HEAD = re.compile( r"^\s*(?:第\s*)?(?P<no>\d{1,3})\s*[、..))关]\s*" r"(?P<name>[\u4e00-\u9fa5A-Za-z·\-—\s]{2,24}?)\s*$" ) def split_by_head(lines): """lines: [(page, size, text), ...],返回 [(head, [正文行...]), ...]""" blocks, cur = [], None for page, size, text in lines: m = GAME_HEAD.match(text) if m: if cur: blocks.append(cur) cur = (m.group("no"), m.group("name"), page, []) elif cur is not None: cur[3].append(text) if cur: blocks.append(cur) return blocks

正则有三个细节值得说:\s*$保证整行匹配,避免把「规则正文里出现『3、所有人围成一圈』」误判成标题;(?:第\s*)?兼容带「第」的写法;{2,24}?用非贪婪,防止把后面的破折号内容一起吞。另外一定要和 2.2 的字号条件做「与」运算,只靠正则,正文里的编号列表会污染结果,一条规则里出现两三个「1、2、3」太常见了。

3.2 字段抽取:人数、时长、道具、胜负判定

切出游戏块之后,字段抽取基本是「关键词 + 就近取值」。这批字段的写法比标题更随性,所以每个字段配一条容错正则,取不到就留None,不要瞎猜。

FIELD_PATTERNS = { "players": re.compile(r"(?:人数|参与人数|适合人数|人数要求)\s*[::]?\s*" r"([0-9一二三四五六七八九十]{1,3}\s*(?:[-~~至到]\s*[0-9一二三四五六七八九十]{1,3})?\s*人?)"), "duration": re.compile(r"(?:时长|游戏时长|每轮|时间)\s*[::]?\s*" r"([0-9一二三四五六七八九十]{1,3}\s*(?:分钟|min|小时))"), "props": re.compile(r"(?:道具|器材|准备|物品)\s*[::]?\s*([^\n]{2,60})"), "venue": re.compile(r"(?:场地|环境)\s*[::]?\s*([^\n]{2,30})"), "win": re.compile(r"(?:胜负|判定|获胜条件|淘汰规则)\s*[::]?\s*([^\n]{2,80})"), } def extract_fields(body_lines): joined = "\n".join(body_lines) out = {} for key, pat in FIELD_PATTERNS.items(): m = pat.search(joined) out[key] = m.group(1).strip() if m else None out["rules"] = joined.strip() return out

props抽到的是整串「眼罩 6 个、气球 20 个、绳子」,落库前再按、,,切一次数组,检索时才能按单个道具命中。players保留原始字符串而不转成数字,因为「10 人以上」「8~12 人」这种区间写法转数字必然丢信息,排序时再单独解析。

3.3 用 Pydantic 兜底,让缺字段可定位

结构化最大的风险是「静默产出脏数据」——字段缺失但流程照跑,最后检索出一堆空壳游戏。用模型校验把不合格条目单独隔离出来。

from pydantic import BaseModel, Field, field_validator from typing import Optional, List class GameItem(BaseModel): no: int name: str = Field(min_length=2, max_length=24) players: Optional[str] = None duration: Optional[str] = None props: List[str] = [] venue: Optional[str] = None win: Optional[str] = None rules: str = Field(min_length=10) page: int raw: str = "" @field_validator("name") @classmethod def no_header_noise(cls, v: str) -> str: assert "游戏大全" not in v, "标题里混进了页眉" return v.strip()

校验失败不抛异常终止,而是写进quarantine.jsonl,带上页码和原始文本。跑完一遍看这个文件的条数:如果 200 个游戏里隔离了 3 个,人工瞄一眼就行;隔离了 40 个,说明 3.1 的正则或 2.2 的字号阈值要调。这个数字是整条流水线最实用的健康指标。

3.4 扫描页的 OCR 兜底与跨页拼接

附录里的扫描页走另一条路:先按 2.1 的判定挑出来,用 300 DPI 渲染成 PNG,再做 OCR。常见做法是page.get_pixmap(dpi=300, colorspace=fitz.csGRAY),灰度比彩色识别率高、体积小。识别完按 y 坐标把行合并,每行文本再喂回 3.1 的正则,和文本页共用同一套切分逻辑。

跨页游戏也要处理:如果某页最后一个游戏块没有匹配到「胜负判定」,而下一页第一行不是标题,就把两页的正文拼起来再抽一次字段。经验上跨页游戏不超过全书 5%,但漏掉一个用户刚好想找的游戏,体验就崩了。

4. 检索与交付:把结构化结果做成可查的「游戏大全」服务

4.1 SQLite FTS5 建索引,中文用 trigram 分词

两百多条数据用 SQLite 足够,关键是中文全文检索的分词器。默认的unicode61按空格切,中文整句会变成一个大 token,搜「撕名牌」命中不了「撕名牌大战」。SQLite 3.34 之后可以直接用trigram

CREATE TABLE games ( id INTEGER PRIMARY KEY, no INTEGER, name TEXT NOT NULL, players TEXT, duration TEXT, props TEXT, -- 逗号分隔的字符串 venue TEXT, win TEXT, rules TEXT, page INTEGER ); CREATE VIRTUAL TABLE game_fts USING fts5( name, props, rules, win, content='games', content_rowid='id', tokenize='trigram' ); INSERT INTO game_fts(rowid, name, props, rules, win) SELECT id, name, props, rules, win FROM games;

content='games'是外部内容表模式,索引只存倒排不存原文,省一半空间;代价是源表更新后必须手动同步 FTS 表,所以入库脚本里要跟一条INSERT INTO game_fts(game_fts) VALUES('rebuild')

trigram 的硬约束是查询串至少 3 个字符,搜「气球」两个字的词会直接返回空。实际使用中两字词太常见,所以查询层要写「MATCH 优先、LIKE 兜底」的双路逻辑,别指望一个分词器搞定。

4.2 FastAPI 两个接口:搜索与详情

服务只做两件事:按关键词搜,按 id 取详情。搜索接口把「场地」「人数」做成可选过滤条件,对应团建策划那三个筛选需求。

import sqlite3 from fastapi import FastAPI, Query, HTTPException app = FastAPI() DB = "games.db" def conn(): c = sqlite3.connect(DB) c.row_factory = sqlite3.Row return c @app.get("/games/search") def search(q: str = Query(min_length=1), venue: str | None = None, limit: int = 20): c = conn() if len(q) >= 3: # trigram 至少吃 3 个字符 sql = """SELECT g.id, g.name, g.players, g.venue, g.page FROM game_fts f JOIN games g ON g.id = f.rowid WHERE game_fts MATCH ?""" args = [q] else: sql = """SELECT id, name, players, venue, page FROM games WHERE name LIKE ? OR props LIKE ? OR rules LIKE ?""" args = [f"%{q}%"] * 3 if venue: # 场地作为二级过滤,放在最外层 sql += " AND venue LIKE ?" if "WHERE" in sql else " WHERE venue LIKE ?" args.append(f"%{venue}%") sql += " LIMIT ?" args.append(limit) rows = [dict(r) for r in c.execute(sql, args)] c.close() return {"total": len(rows), "items": rows} @app.get("/games/{gid}") def detail(gid: int): c = conn() row = c.execute("SELECT * FROM games WHERE id = ?", (gid,)).fetchone() c.close() if not row: raise HTTPException(404, "game not found") return dict(row)

limit默认 20 是有意的:结果太多说明查询词太泛,与其分页不如让用户加场地条件。game_fts MATCH ?的参数不能拼进 SQL 字符串,MATCH 的语法对特殊字符敏感,参数化能避免把ANDOR当成操作符。

4.3 交付形态:pdf转word、Markdown 导出与网页打印

结构化之后,导出就是顺手的事,也是策划最直观的感受。三种形态覆盖三种场景:

# 1) 结构化 JSON 转 Markdown,再用 Markdown PDF 插件或 pandoc 出稿 python export_md.py --db games.db --out 游戏大全.md pandoc 游戏大全.md -o 游戏大全.docx --reference-doc=模板.docx # 2) 只要 Word 做二次编辑,pandoc 一条命令;表格样式继承参考文档 # 3) 网页打印:前端加 @media print,隐藏筛选栏,按页断行

前端如果是 Vue 项目,用弹窗预览原 PDF 是高频需求。element-ui 的el-dialog里塞iframe指向静态资源即可,注意destroy-on-close要开,否则反复打开多个 PDF 会把内存吃掉。打印样式里加page-break-inside: avoid到每个游戏卡片上,避免一个游戏被切成两页。

如果想把「原 PDF + 结构化结果」一起给团队看,用容器挂一套预览最快:

services: alist: image: xhofe/alist:latest volumes: - ./docs:/opt/alist/data # 原 PDF 和导出文件都放这里 ports: - "5244:5244" onlyoffice: image: onlyoffice/documentserver:latest ports: - "8080:80"

Alist 负责列目录和直链,OnlyOffice 负责在线打开 docx 和 PDF 预览。两个容器放在同一台内网机器上,./docs目录里的文件更新后前端刷新即可看到,不需要重启服务。

注意:OnlyOffice 首次启动会初始化数据库,内存占用按 2 GB 起算,小机器上跑之前先看free -m

5. 长期维护:扫描件纠偏、质量校验与版本比对

5.1 歪斜校正与漂白加深

扫描页 OCR 失败,九成是斜了或者底灰太重。这两步预处理在 OpenCV 里十来行就能解决,参数比算法重要。

import cv2, numpy as np img = cv2.imread("scan_012.png", cv2.IMREAD_GRAYSCALE) bw = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] coords = np.column_stack(np.where(bw > 0)).astype(np.float32) angle = cv2.minAreaRect(coords)[-1] angle = -(90 + angle) if angle < -45 else -angle # minAreaRect 的角度区间是 [-90, 0) h, w = img.shape M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) rot = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8)) out = cv2.adaptiveThreshold(clahe.apply(rot), 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 12) cv2.imwrite("fixed_012.png", out)

minAreaRect求的是前景像素最小外接矩形,整页文字都是前景,角度偏差 0.3 度以内可以忽略。clipLimit=2.5控制对比增强幅度,超过 3 容易把浅色铅笔批注也拉成实心黑块。blockSize=31必须是奇数,C=12是常数偏移,调大到 20 会把细笔画抹掉,调小到 5 底灰去不干净,31/12 是中文印刷体上比较稳的一组。

5.2 解析质量的自动校验

每次重新生成 JSON 都跑一遍校验,比人工抽查靠谱。

指标合理阈值不达标时先看哪里
游戏条数与上版差异 < 5%标题正则是否漏改
字段覆盖率(props/players)> 80%关键词正则、原文换行位置
隔离条数< 3%3.3 的校验规则太严还是太松
页码连续性每页都有归属跨页拼接逻辑

覆盖率掉到 60% 以下,多半是 PDF 生成工具换了,字段标签从「人数:」变成了「参与人数」,改正则比重跑 OCR 便宜得多。

5.3 两份 PDF 的差异比对,找出新增游戏

游戏大全会更新版本,别每次全量重建。用名称集合先做粗筛,再对同名游戏做规则文本比对:

import difflib, json old = {g["name"]: g for g in json.load(open("v1.json"))} new = {g["name"]: g for g in json.load(open("v2.json"))} print("新增:", sorted(set(new) - set(old))) print("删除:", sorted(set(old) - set(new))) for name in sorted(set(old) & set(new)): a, b = old[name]["rules"], new[name]["rules"] if a != b: ratio = difflib.SequenceMatcher(None, a, b).ratio() if ratio < 0.9: # 相似度低于 0.9 才认为规则真改了 print(f"{name} 规则变更,相似度 {ratio:.2f}")

ratio()返回 0 到 1 的相似度,0.9 这条线是经验值:低于它就值得人看,高于它一般只是空格和标点差异。最后把新增和变更的条目写回索引,用INSERT ... ON CONFLICT(name) DO UPDATE做增量更新,再跟一条INSERT INTO game_fts(game_fts) VALUES('rebuild')重建倒排,整个流程就闭环了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询