简介百度百科系统超级模仿百度是一套基于HDWiki开源程序构建的百科建站资源主打复现百度百科的界面风格与浏览体验面向想要搭建独立知识库的个人站长、内容团队或学习者解决在线百科系统从零部署、日常维护到内容运营的完整需求。压缩包约1.95MB文件总数未明确列出内容以HDWiki程序文件、安装说明.txt以及document资料为主可参照完成运行环境检查、数据库创建、Web服务器配置与站点初始化。目前已有384人学习/下载适合PHP基础薄弱的初学者快速掌握百科系统安装与常用设置。凭借这套资源读者不仅能按照文档搭建一套可访问的百科站点还能熟悉词条创建、分类管理、用户权限审核等核心机制HDWiki的代码结构清晰、扩展方便可作为学习PHP开源项目、尝试二次开发或制作垂直领域知识库的实用起点。1. 百科系统超级模仿一个自带审核与全文检索的百科站源码包百科类网站的产品形态其实被验证得很透了一个顶部搜索框、一套分类导航、一组可编辑可审核的词条页。真正动手做的时候细节远比想象多——我见过不少团队想搭内部知识库最后都折在词条版本和全文搜索这两个点上。这个项目就是把百科站点的信息架构完整复刻成一套可运行的 Python 实现词条、分类、搜索、审核、版本历史全部包含数据落在单文件 SQLite 里源码拿到手后三分钟能起服务。适合两类人想快速落一个内部知识库的团队以及拿它当课设或工程案例来二次开发的开发者。终端用户看到的是一套正经百科站后端维护者看到的是清晰的目录和可替换的模块。2. 快速跑通技术选型、目录结构与三分钟启动2.1 技术栈为什么是 Flask SQLite FTS5先泼一盆冷水百科系统这类项目用重型框架属于杀鸡用牛刀。我拆过很多知识库项目同一套需求用 Django 和 Flask 都能做但 Flask 的透明度和可控性更适合这种“我要改内部逻辑”的场景——路由就是路由模板就是模板没有一层厚重的抽象黑匣子。数据库选 SQLite 也经过了权衡。百科站点的数据模型简单词条、分类、用户、版本几张表就能装下单文件存储反而方便备份和迁移。搜索是重点SQLite 从 3.35 版本开始内置 FTS5 全文索引对中文场景配合 unicode61 分词器几千条词条级别的检索响应在十毫秒级这已经覆盖了绝大多数内部知识库的需求。下面是几个方案的匹配度对比方案适合场景匹配度Flask SQLite FTS5内容型小站、课设、内部工具高Django PostgreSQL大型内容平台、复杂权限体系中偏重Node ExpressAPI 优先项目低百科需要服务端渲染这个技术组合最大的优势是零运维没有独立数据库进程没有消息队列开发者拿到源码包只需要装 Python 依赖就能跑环境变量也就一个 SQLite 文件路径。2.2 目录结构先看清每个文件的职责拿到源码后别急着跑先花两分钟看目录理解谁在干什么kbwiki/ ├── run.py # 应用入口启动 dev server ├── init_db.py # 建表 初始化分类和管理账号 建 FTS 索引 ├── requirements.txt # 依赖清单 ├── app/ │ ├── __init__.py # create_app 工厂注册蓝图和扩展 │ ├── config.py # 配置项数据库路径、密钥、上传目录 │ ├── models.py # User / Category / Entry / ContentVersion │ ├── views/ │ │ ├── main.py # 首页、词条详情、搜索、分类聚合 │ │ ├── auth.py # 登录、注册、登出 │ │ ├── entry.py # 新建词条、编辑词条、我的贡献 │ │ └── admin.py # 审核面板、分类管理、用户管理 │ ├── services/ │ │ ├── render.py # Markdown 渲染 HTML 白名单净化 │ │ ├── search.py # FTS5 全文检索封装、索引同步 │ │ └── category.py # 分类树递归工具、面包屑 │ ├── templates/ # Jinja2 模板首页、详情、编辑、搜索、审核 │ └── static/ # CSS 和少量前端交互 JS └── tests/ └── test_flow.py # 端到端回归测试views 目录按业务拆了四个蓝图main 是访客可见的公开页面auth 管身份entry 管词条生命周期admin 只给审核员和管理员用。services 目录是这段源码里最值得抄的部分——render 和 search 都是与框架无关的纯逻辑拿出来就能塞进别的项目。2.3 从源码到首页三步启动项目没有搞 Docker 编排因为不需要。依赖只有 Flask、Flask-SQLAlchemy、Flask-Login、Flask-WTF、markdown、bleach 这几个常用包安装很快cd kbwiki pip install -r requirements.txt python init_db.py python run.pyinit_db.py会创建所有表结构写入预置的分类导航人文、科技、历史、地理、生活等十几个顶级分类建一个默认管理员账号并打印到控制台顺手把 FTS5 虚拟表和初始索引建好。这个脚本默认只建空库不会清掉已有数据只有显式加--reset参数才会重建全部数据避免误操作丢内容。启动后浏览器访问http://127.0.0.1:5000顶部是搜索框下面按分类展示词条卡片登录后能看到“新建词条”按钮管理员在导航栏多出一个“审核”入口。想验证系统状态就用管理员账号随便建一条词条提交审核再去审核面板点通过全程走完就说明整条链路是通的。3. 核心模块拆解词条从编辑到审核再到发布的完整链路3.1 数据模型一个词条怎么同时保存草稿和线上版本百科类系统最容易被低估的是数据模型。表面上是“一个标题一段正文”实际要处理的是访客看到的内容、编辑正在改的内容、历史版本三份数据不能互相覆盖。这个项目用一张表里的两对字段解决# app/models.py class Entry(db.Model): __tablename__ entry id db.Column(db.Integer, primary_keyTrue) title db.Column(db.String(200), nullableFalse, indexTrue) uri db.Column(db.String(120), uniqueTrue, nullableFalse) summary db.Column(db.Text, default) # 编辑中的内容 content_md db.Column(db.Text, default) # 待审核的原始 Markdown content_html db.Column(db.Text, default) # 待审核的渲染结果 # 线上发布的内容 published_md db.Column(db.Text, default) # 访客看到的原始文本 published_html db.Column(db.Text, default) # 访客看到的 HTML category_id db.Column(db.Integer, db.ForeignKey(category.id)) author_id db.Column(db.Integer, db.ForeignKey(user.id)) status db.Column(db.String(16), defaultpending) version db.Column(db.Integer, default1) views db.Column(db.Integer, default0) created_at db.Column(db.DateTime, defaultdatetime.utcnow) updated_at db.Column(db.DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow)关键在content_md/published_md的分离编辑保存时只写content_md访客永远读published_md两个状态互不干扰。status字段有四个值draft草稿、pending待审核、rejected已驳回、accepted已发布。version每次发布递增配合版本表记录每次正式变更。版本表是这个设计的最后一块拼图每次审核通过时写入一条快照class ContentVersion(db.Model): __tablename__ content_version id db.Column(db.Integer, primary_keyTrue) entry_id db.Column(db.Integer, db.ForeignKey(entry.id), nullableFalse) version db.Column(db.Integer, nullableFalse) content_md db.Column(db.Text, default) content_html db.Column(db.Text, default) reason db.Column(db.String(200), default) # 编辑说明或审核意见 created_by db.Column(db.Integer, db.ForeignKey(user.id)) created_at db.Column(db.DateTime, defaultdatetime.utcnow)这样词条详情页底部就能展示“最近版本记录”哪一版是谁在什么时间出于什么原因改的全部留痕。实际运行中这个表还能用来做编辑对比和回滚后面避坑章节会讲到它怎么救场。3.2 编辑提交与审核发布流程控制在视图层词条编辑页面提交后走的是同一个入口不同的是普通用户提交后进入待审核队列管理员编辑则直接发布。看这段视图代码# app/views/entry.py app.route(/entry/uri/edit, methods[GET, POST]) login_required def entry_edit(uri): entry Entry.query.filter_by(uriuri).first_or_404() form EntryEditForm(objentry) if form.validate_on_submit(): entry.title form.title.data entry.uri slugify(form.title.data) entry.summary form.summary.data entry.content_md form.content.data entry.category_id form.category_id.data if entry.status accepted: entry.status pending # 已发布词条被编辑后进入待审 if current_user.role admin: publish_entry(entry, reason管理员直接编辑) db.session.commit() flash(变更已提交等待审核) return redirect(url_for(entry_detail, urientry.uri)) return render_template(entry_edit.html, formform, entryentry)注意slugify会把标题转成拼音加连字符的 URL 标识保证词条链接稳定可分享标题改了链接也不断。publish_entry被抽成了服务层函数普通用户提交和审核员通过复用同一个发布逻辑# app/services/entry_service.py def publish_entry(entry, reason): entry.published_md entry.content_md entry.published_html render_markdown(entry.content_md) entry.version 1 entry.status accepted db.session.add(ContentVersion( entry_identry.id, versionentry.version, content_mdentry.content_md, content_htmlentry.published_html, reasonreason, created_bycurrent_user.id, )) SearchService.sync_entry(entry.id) # 发布后立即同步搜索索引这里的顺序很讲究先更新线上字段再写版本快照最后同步搜索索引。如果中间任何一步失败事务回滚会让三个状态保持原子一致。审核视图同样复用publish_entry驳回时只改状态并记录审核日志# app/views/admin.py app.route(/admin/review/int:entry_id, methods[POST]) review_required def review_entry(entry_id): entry Entry.query.get_or_404(entry_id) action request.form.get(action) # accept / reject comment request.form.get(comment, ) if action accept: publish_entry(entry, reasoncomment) flash(已通过并发布) else: entry.status rejected db.session.add(AuditLog( entry_identry.id, actionreject, commentcomment, operator_idcurrent_user.id )) flash(已驳回并通知作者) db.session.commit() return redirect(url_for(review_panel))review_required是个简单的角色装饰器检查当前用户role是否是reviewer或admin。整个审核流没有引入工作流引擎靠的就是视图层对status的显式切换逻辑直观改起来也容易。3.3 Markdown 渲染与安全为什么存两列内容编辑器用的是纯 Markdown 文本域这样轻量、可版本 diff、不依赖重型富文本编辑器。渲染管线就一层但安全细节都在里面# app/services/render.py import markdown import bleach ALLOWED_TAGS [ p, h1, h2, h3, h4, ul, ol, li, pre, code, blockquote, a, strong, em, del, table, thead, tbody, tr, th, td, img, hr, br, ] ALLOWED_ATTRS { a: [href, title, target], img: [src, alt, title], code: [class], } def render_markdown(text): raw markdown.markdown( text or , extensions[extra, codehilite, sane_lists], ) return bleach.clean(raw, tagsALLOWED_TAGS, attributesALLOWED_ATTRS, stripTrue)extra扩展提供表格、脚注、定义列表这些百科词条常用的排版能力codehilite给代码块加高亮sane_lists让列表嵌套更符合直觉。然后bleach.clean是安全底线——把不在白名单里的标签全部剥掉href里的javascript:协议也会被过滤。这就是为什么模型里要存content_md和content_html两列每次渲染 Markdown 再清洗确实有开销但列表页、搜索摘要、分享卡片都需要 HTML 片段每次都现场渲染几百条不划算存两列等于做了一个最朴素的缓存层。编辑时改的是原始文本发布时重新生成 HTML 并覆盖逻辑闭环。4. 搜索引擎与分类树让内容在几千条词条里两秒命中4.1 分类树递归到子分类的面包屑与聚合页百科站点的分类是树形结构不是扁平的标签列表。这个项目用经典的单表邻接模型存树parent_id指向上级# app/models.py class Category(db.Model): __tablename__ category id db.Column(db.Integer, primary_keyTrue) parent_id db.Column(db.Integer, db.ForeignKey(category.id), default0) name db.Column(db.String(64), nullableFalse) slug db.Column(db.String(64), uniqueTrue, nullableFalse) sort_order db.Column(db.Integer, default0) children db.relationship( Category, backrefdb.backref(parent, remote_side[id]), order_byCategory.sort_order, )remote_side[id]告诉 SQLAlchemy 这是一个自引用的一对多关系category.parent拿到上级节点category.children拿到按排序好的直接子节点。查询某分类下所有词条时需要递归把子孙分类全部捞出来# app/services/category.py def collect_child_ids(cat): ids [cat.id] for child in cat.children: ids.extend(collect_child_ids(child)) return ids def build_breadcrumb(cat): chain [] cur cat while cur and cur.parent_id ! 0: chain.insert(0, cur) cur cur.parent if cur: chain.insert(0, cur) return chaincollect_child_ids返回的是一个包含自身和所有后代的 id 列表分类聚合页直接用Entry.category_id.in_(ids)查询build_breadcrumb构建“人文 文学 中国文学”这种导航链。这段代码对几百上千个分类规模完全够用每次请求递归深度一般不超过三层性能上没有压力。4.2 全文检索SQLite FTS5 的接入与同步搜索模块是整个项目中技术含量最高的部分。SQLite 的 FTS5 是虚拟表它维护自己的倒排索引和普通表的数据不同步必须手动写入或删除。初始化时创建虚拟表# app/services/search.py import re import sqlite3 from app import db FTS_TABLE entry_fts def ensure_fts(): with db.engine.raw_connection() as con: con.execute(f CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE} USING fts5(title, summary, content_md, tokenizeunicode61) )unicode61是 SQLite 默认分词器对中文做了基本的字符切分按空格和标点分词够支撑标题和摘要级别的检索。词条发布时把内容同步到 FTS 表def sync_entry(entry_id): entry Entry.query.get(entry_id) with db.engine.raw_connection() as con: con.execute(fDELETE FROM {FTS_TABLE} WHERE rowid ?, (entry.id,)) con.execute(f INSERT INTO {FTS_TABLE} (rowid, title, summary, content_md) VALUES (?, ?, ?, ?) , (entry.id, entry.title, entry.summary, entry.published_md))这里先删后插保证索引和词条数据始终一致。搜索函数返回带高亮片段的词条列表def search(query, limit20): keywords [kw for kw in re.split(r[\s,、], query.strip()) if kw] if not keywords: return [] match_expr OR .join( kw.replace(, ) for kw in keywords ) try: with db.engine.raw_connection() as con: cur con.execute(f SELECT e.id, e.uri, e.title, e.summary, snippet({FTS_TABLE}, 2, b, /b, …, 24) AS snip FROM {FTS_TABLE} f JOIN entry e ON e.id f.rowid WHERE {FTS_TABLE} MATCH ? ORDER BY rank LIMIT ? , (match_expr, limit)) rows cur.fetchall() except sqlite3.OperationalError: return [] return [dict(row) for row in rows]snippet函数从第 2 列summary截取命中上下文前后用b标签高亮返回 24 字符的片段。rank是 FTS5 内置的相关度分值按相关性倒序。搜索结果里e.status accepted条件其实没写在这一层——查询只看已发布内容待审草稿不会进入索引表从源头就保证了权限边界。4.3 搜索的容错用户输入不总是听话搜索框是最容易翻车的地方用户会输入各种奇奇怪怪的内容。FTS5 的 MATCH 语法里AND、OR、NOT、括号、引号都是关键字直接拼接进查询会报语法错误。上面的safe_match_expression逻辑单独拆出来看def safe_match_expression(raw_query): keywords [kw for kw in re.split(r[\s,、], raw_query.strip()) if kw] if not keywords: return return OR .join( kw.replace(, ) for kw in keywords )每个关键词都用双引号包成短语内部的双引号再加一倍转义这样用户输入“广州 AND 北京”会被当成三个普通词去匹配而不是触发逻辑运算。即便这样极端输入仍可能触发OperationalError所以search函数外层捕获了这个异常遇到解析失败直接返回空列表绝不抛到页面上让用户看 500。中文分词的现实约束也要说清楚unicode61按字面切分搜“百科系统”时如果词条正文写的是“百科类的系统”会匹配不上。这个量级的项目一般不做 jieba 分词靠标题和摘要的精准命中已经够用真遇到多年积累的万级词条库改造方向是引入 jieba 自定义分词器或者上 ElasticSearch在第六章展开。5. 避坑记录数据同步、权限与部署中的五个真实问题5.1 改了词条后台前台搜索还是旧内容现象词条详情页已经是新内容搜索摘要却还是旧文案。原因详情页读的是published_md搜索索引表存的是发布时的快照。如果通过后台管理界面直接改了数据库字段而不是走publish_entryFTS 表永远不会更新。我接手这个项目时第一周就在这上面吃过亏——手动执行了一条 UPDATE 修正错别字前台详情对了搜索摘要还是错的。解决所有内容变更统一走publish_entry它内部会调SearchService.sync_entry。实在要手动改库改完立刻执行一条sync_entry(entry_id)别偷懒。5.2 用户输入一句带引号的话搜索接口直接 500现象搜索框输入hello或者a AND b页面白屏报 OperationalError。原因FTS5 的 MATCH 语法会把引号和AND当成逻辑运算符裸拼进去就是语法错误。解决把每个关键词用双引号包裹并转义内部引号再包一层 try-except 捕获sqlite3.OperationalError。我后来还加了长度限制超过 50 个字符的查询直接拒绝不是为了性能是防止有人拿超长字符串测 FTS 解析栈。5.3 删除分类时把词条一起删了现象删了一个三级分类结果该分类下的词条全部消失连相邻分类也少了内容。原因分类表和词条表之间配了外键级联删除删分类把子分类和词条连带删除又赶上没有确认弹窗一次误点损失惨重。解决删分类接口先检查collect_child_ids是否为空有子分类或挂载词条的分类禁止删除只能改为disabled状态。这个项目的做法是分类模型加了一个is_active字段后台“删除”按钮实际执行的是软删除页面不再展示但数据还在。5.4 审核通过后才发现旧版本被覆盖回都回不去现象一篇词条经过三次编辑两次审核后想找回第一版的完整原文发现数据库里只有当前版本。原因最开始设计时只有一张entry表编辑直接覆盖content_md版本号只是记录着好看没有实际内容快照。解决补上ContentVersion表后每次发布写一条快照。后续我在词条详情页加了一个“历史版本”折叠区管理员可以对比任意相邻版本并一键回滚回滚操作本质是把旧版本的快照重新走一遍publish_entry。这算是这项目里性价比最高的一次改造。5.5 部署到 Linux 服务器后中文乱码、索引失效现象本地 Windows 跑得好好的用 systemd 部署到服务器后词条详情正常但搜索中文关键词永远无结果。原因两个问题叠在一起。第一是 FTS5 虚拟表是用旧库文件建的迁移时没有重建索引第二是 Python 进程的默认编码不是 UTF-8导致分词和存储时出现不一致。解决部署时先跑一遍init_db.py重建 FTS 表再在config.py里显式设置app.config[JSON_AS_ASCII] False并在启动脚本里声明export LANGzh_CN.UTF-8。从那以后我每次部署完都要搜一个中文字测试索引成了固定动作。6. 二次开发从换库到加状态机的改造路径6.1 想换成 MySQL改动清单在这整套系统换 MySQL 的改动量比想象中小主要是 SQLite 专属语法需要替换。先看表格位置要改什么config.py连接串换成mysqlpymysql://user:passhost/dbnamemodels.py字段类型基本不用动注意db.String(120)在 MySQL 下没问题services/search.pyFTS5 语法迁移为 MySQL FULLTEXT 索引 ngram 分词init_db.py建库脚本改成 MySQL 先建库再建表FTS5 是最大的迁移点。MySQL 侧用ALTER TABLE entry ADD FULLTEXT INDEX idx_search (title, summary, content_md) WITH PARSER ngram把MATCH ?查询改成WHERE MATCH(title, summary) AGAINST (? IN BOOLEAN MODE)高亮逻辑从snippet函数改成自己写截取。注意 MySQL 的全文索引对中文必须用 ngram 解析器否则只对英文生效。6.2 一条命令跑完核心链路回归项目带了端到端回归测试验证的是百科系统的生命线——贡献者从注册到搜索命中的完整链路# tests/test_flow.py def test_contributor_publish_flow(): # 注册并登录普通用户 client.post(/auth/register, data{username: contributor, password: abc12345}) client.post(/auth/login, data{username: contributor, password: abc12345}) # 新建词条进入待审核状态 rv client.post(/entry/new, data{ title: 边缘计算网关, summary: 一种靠近数据源头的计算设备, content: # 边缘计算网关\n\n本文介绍边缘场景下的网关设备……, category_id: 3, }, follow_redirectsTrue) assert 等待审核 in rv.get_data(as_textTrue) # 管理员登录审核通过 client.post(/auth/login, data{username: admin, password: admin123}) rv client.post(/admin/review/1, data{action: accept}, follow_redirectsTrue) assert 已通过并发布 in rv.get_data(as_textTrue) # 搜索能命中刚发布的词条 rv client.get(/search?q边缘计算) assert 边缘计算网关 in rv.get_data(as_textTrue)第一次跑这个测试记得先把/admin/review/1的 id 改成真实的词条 id或者从数据库查出Entry.id再拼接 URL。三个断言分别卡住三个关键环节提交转待审、审核转发布、发布转可搜索任何一环被破坏测试立刻红。从那以后我每次改完模型或搜索逻辑都把这条链路在提交前强制走一遍不是怕改坏是怕改得以为没坏。百科系统的核心就是审核流和数据一致性哪一步被绕过都会表现为“线上数据对不上”。这套流程帮我救回过好多次上线事故希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?