ARTICLE DETAIL

资讯详情

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

PDF解析、结构化与FTS5检索:Runningman游戏大全实战

PDF解析、结构化与FTS5检索:Runningman游戏大全实战 简介这份《Runningman游戏大全》文档面向团队活动策划者、综艺编导、聚会组织者及拓展培训教练系统整理了跑男类竞技游戏的玩法与规则帮助解决活动创意不足、游戏环节难以设计的问题。压缩包内仅含1个PDF文件体积约41KB以文字条目形式罗列游戏说明涵盖泥潭抢裤子、撕名牌、指压板接力、一杯茶时间、水池飞椅等经典项目也收录了对眼游戏、词语炸弹接龙、斗鸡摔跤、看眼色游戏等数十种低成本易落地的玩法。每个条目均标注参与人数、胜负判定与胜负条件部分附带变体规则与情侣战、间谍战等情景设定可直接改编为现场执行方案。目前已有81人学习下载适合需要快速攒出一整套游戏脚本、按主题检索并灵活组合环节的读者参考使用。1. 从「Runningman游戏大全.pdf」说起一份规则手册怎么变成能查的数据团建前一晚策划拿到一份叫Runningman游戏大全.pdf的文档两百多页每页一到两个游戏格式是「编号 游戏名 人数 道具 规则」。他想按「不用道具」「10 人以内」「室内可玩」三个条件筛出十个游戏结果 CtrlF 只能按关键词碰运气翻到第 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, sample8): 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: # type1 表示图像块0 是文本块 r fitz.Rect(blk[bbox]) img_area r.width * r.height print(fp{pno1} chars{len(text)} img{img_area/area:.0%} fbox{page.rect.width:.0f}x{page.rect.height:.0f}) doc.close() probe_pdf(Runningman游戏大全.pdf)fitz.Rect的bbox是(x0, y0, x1, y1)四点坐标单位是点1 点 1/72 英寸。A4 竖版大约是595 x 842如果探测输出的 box 是612 x 792说明原文件按 Letter 排的后面算页眉页脚阈值要跟着换。判定标准很直白现象判定处理路线chars 200 且 img 30%文本型直接走 PyMuPDF / pdfplumberchars 50 且 img 60%扫描型先纠偏、漂白加深再 OCRchars 100~200img 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_size10.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 outsize 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_flowTrue, 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_strategylines依赖 PDF 里真的画了表格线。如果输出全是None改成text让 pdfplumber 按文字对齐推断列边界snap_tolerance4表示 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. 把「游戏名 人数 道具 规则」抽成结构化 JSON3.1 标题正则与段落切分策略有了字号候选集还要过一遍文本形态。这本大全的编号格式高度统一1、撕名牌、02. 指压板接力、第 3 关 水枪大战都有正则要能同时吃掉。import re GAME_HEAD re.compile( r^\s*(?:第\s*)?(?Pno\d{1,3})\s*[、.)关]\s* r(?Pname[\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 outprops抽到的是整串「眼罩 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_length2, max_length24) players: Optional[str] None duration: Optional[str] None props: List[str] [] venue: Optional[str] None win: Optional[str] None rules: str Field(min_length10) 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(dpi300, colorspacefitz.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, contentgames, content_rowidid, tokenizetrigram ); INSERT INTO game_fts(rowid, name, props, rules, win) SELECT id, name, props, rules, win FROM games;contentgames是外部内容表模式索引只存倒排不存原文省一半空间代价是源表更新后必须手动同步 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_length1), 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 的语法对特殊字符敏感参数化能避免把AND、OR当成操作符。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:80Alist 负责列目录和直链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), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE) clahe cv2.createCLAHE(clipLimit2.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 度以内可以忽略。clipLimit2.5控制对比增强幅度超过 3 容易把浅色铅笔批注也拉成实心黑块。blockSize31必须是奇数C12是常数偏移调大到 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)重建倒排整个流程就闭环了。本文还有配套的精品资源点击获取
返回列表