From 93eee85bffcdd835a0531725b13c9e767e1ade91 Mon Sep 17 00:00:00 2001
From: ice-wocker
Date: Wed, 30 Sep 2026 17:51:04 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8A=8A=E7=9F=A5=E8=AF=86=E5=BA=93?=
=?UTF-8?q?=E4=BB=8E=E3=80=8C=E9=9D=99=E6=80=81=E6=96=87=E6=A1=A3=E9=9B=86?=
=?UTF-8?q?=E3=80=8D=E5=8D=87=E7=BA=A7=E4=B8=BA=E5=8F=AF=E6=A3=80=E7=B4=A2?=
=?UTF-8?q?=E3=80=81=E5=8F=AF=E6=B6=88=E8=B4=B9=E7=9A=84=E7=9F=A5=E8=AF=86?=
=?UTF-8?q?=E5=BC=95=E6=93=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
站点此前只有「浏览」一种能力:搜索是把 163 篇正文前 6000 字塞进一个
1.6 MB 的 JSON 全量串扫,也没有标签、相关推荐、RSS,产出物只能给人看。
这轮补的是「用什么方式都能用上」的那部分。
检索
- 新增 scripts/kb.py 作为公共引擎(解析/分词/倒排索引/相似度)。
- 搜索改为预生成倒排索引 + BM25(k1=1.5 b=0.75),标题命中加权。
- 中文用「单字 + bigram」切词:无分词器时召回与体积的最佳折中。
- 裁掉 df=1 的词与超高频词,索引 3.8 MB → 1.4 MB(gzip 0.5 MB),
实测检索质量零变化;首屏不再拉索引,第一次输入才加载。
新能力
- 相关文档:按 tf-idf 余弦相似度,每篇推荐 4 篇(同领域优先)。
- 标签体系:自动抽取术语,新增 tags.html / tags.json。
- 阅读体验:本页目录(TOC)、锚点、阅读时长、分类面包屑。
- 机器可读导出:docs.json / graph.json / tags.json / feed.xml /
sitemap.xml / robots.txt —— 无需爬 HTML 即可二次开发。
- 浅色模式适配。
质量守门(新增两道 CI 检查)
- check_docs.py:管「里面写的东西还成立吗」——必备内容槽位、来源数量、
日期格式、标题重复、正文过短、未来日期。
- check_site.py:管「构建成功但站点坏掉」——站内链接、索引 docId 越界、
docs.json 指向的页面、sitemap/canonical/RSS 同源。
- pages.yml 发布前先跑站点自检。
本地验证:gen_index/check_docs/build_site/check_site/check_links 全绿。
---
.github/workflows/ci.yml | 21 +-
.github/workflows/pages.yml | 5 +
README.md | 53 +++-
scripts/build_site.py | 514 +++++++++++++++++++++++++-----------
scripts/check_docs.py | 139 ++++++++++
scripts/check_site.py | 147 +++++++++++
scripts/kb.py | 356 +++++++++++++++++++++++++
7 files changed, 1071 insertions(+), 164 deletions(-)
create mode 100644 scripts/check_docs.py
create mode 100644 scripts/check_site.py
create mode 100644 scripts/kb.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1707207..6317ce6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,8 +21,21 @@ jobs:
- name: Check INDEX.md
run: python3 scripts/gen_index.py --check
+ docs:
+ name: 内容质量校验
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ # 校验每篇是否覆盖必备内容槽位、来源是否够、日期/标题有无异常。
+ # 这管的是「里面写的东西还成立吗」,与外链检查互补。
+ - name: Check docs
+ run: python3 scripts/check_docs.py
+
site:
- name: 校验站点能否构建
+ name: 校验站点能否构建 + 产物自检
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -33,10 +46,14 @@ jobs:
# 而不是等合并后 Pages 发布挂掉。
- name: Build site
run: python3 scripts/build_site.py
+ # 构建成功不等于站点可用:站内链接、索引 docId、sitemap 同源都要验。
+ - name: Check site artifacts
+ run: python3 scripts/check_site.py
- name: Assert output
run: |
test -f site/index.html
- test -f site/search.json
+ test -f site/search-index.json
+ test -f site/docs.json
pages=$(find site -name '*.html' | wc -l)
echo "生成 HTML 页面数: $pages"
test "$pages" -gt 160
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 8727ad2..16734f0 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -8,6 +8,8 @@ on:
paths:
- 'docs/**'
- 'scripts/build_site.py'
+ - 'scripts/kb.py'
+ - 'scripts/check_site.py'
- '.github/workflows/pages.yml'
workflow_dispatch:
@@ -31,6 +33,9 @@ jobs:
python-version: '3.12'
- name: Build static site
run: python3 scripts/build_site.py
+ # 宁可不上线,也不让坏产物出去:站内链接 / 索引 / sitemap 都要验。
+ - name: Check site artifacts
+ run: python3 scripts/check_site.py
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
diff --git a/README.md b/README.md
index 662a527..1524ec7 100644
--- a/README.md
+++ b/README.md
@@ -13,13 +13,64 @@
## 快速导航
-- **[在线站点](https://ice-wocker.github.io/frontier-knowledge-base/)** —— 带全文搜索的网页版,163 篇文档按领域浏览
+- **[在线站点](https://ice-wocker.github.io/frontier-knowledge-base/)** —— 带 BM25 全文检索、标签索引、相关推荐的网页版
- **[完整索引(按领域)](INDEX.md)** —— 163 篇文档的自动生成清单,含最后更新日期与概要
+- **[标签索引](https://ice-wocker.github.io/frontier-knowledge-base/tags.html)** —— 按术语聚合的横向入口
+- **[RSS 订阅](https://ice-wocker.github.io/frontier-knowledge-base/feed.xml)** —— 按更新时间追踪新增与更新
- 下方向领域目录为人工精选的详细列表
> 站点由 `scripts/build_site.py` 从 `docs/` 渲染生成(零依赖、纯静态),
> 合并进 `main` 后自动发布。`docs/` 的 Markdown 始终是唯一真相源。
+## 不止是「给人看」
+
+知识库除了网页,还会导出一批**机器可读**的产物——检索索引、文档清单、关联图。
+这样它既能被人浏览,也能被脚本、分析工具、AI 应用直接消费,不必先爬 HTML:
+
+| 产物 | 内容 | 用途 |
+| --- | --- | --- |
+| [`search-index.json`](https://ice-wocker.github.io/frontier-knowledge-base/search-index.json) | 倒排索引(词 → 文档/词频)+ IDF | 浏览器端 BM25 检索;也可离线复现排序 |
+| [`docs.json`](https://ice-wocker.github.io/frontier-knowledge-base/docs.json) | 全部文档的标题/摘要/标签/章节/来源数/相关文档 | 二次开发、批量分析与索引 |
+| [`graph.json`](https://ice-wocker.github.io/frontier-knowledge-base/graph.json) | 文档关联图(节点 + 带权边) | 可视化、聚类、发现知识盲区 |
+| [`tags.json`](https://ice-wocker.github.io/frontier-knowledge-base/tags.json) | 标签 → 文档 的聚合 | 按主题横向检索 |
+| [`feed.xml`](https://ice-wocker.github.io/frontier-knowledge-base/feed.xml) | RSS(最近 30 篇,按更新时间) | 订阅更新 |
+
+> 全部由 `scripts/build_site.py` 在构建时顺带产出,无额外服务、无外部依赖。
+
+## 检索是怎么做的
+
+站点不引任何检索库,检索跑在浏览器里,靠的是一份预生成的倒排索引:
+
+- **中文分词**:单字 + 相邻二字组合(bigram)。没有分词器时,这是「召回」
+ 与「索引体积」之间最稳的折中——bigram 能表达「向量检索」这类复合概念,
+ 又不像 trigram 那样把索引撑爆。
+- **排序**:BM25(k1=1.5, b=0.75),标题命中额外加权。
+- **裁剪**:只出现 1 篇的词(中文 bigram 的主要噪声)、以及几乎每篇都有的
+ 超高频词都不入索引。实测这一步把索引从 3.8 MB 压到 1.4 MB(gzip 约 0.5 MB),
+ 检索质量零变化。
+- **按需加载**:首屏不拉索引(0 请求),第一次聚焦/输入时才加载,单次按键只查候选集。
+
+## 自动化的质量守门
+
+内容维度不可靠是这个库最大的风险——163 篇(还会更多)靠人维护必然漂移。
+所以有三道自动检查,任何一道不过 CI 就红:
+
+| 脚本 | 管什么 |
+| --- | --- |
+| `scripts/check_docs.py` | **里面写的东西还成立吗**:每篇是否覆盖必备内容槽位(概述/进展/技术/趋势/来源)、参考来源是否够(≥5)、日期格式、标题重复/过长、正文过短、未来日期 |
+| `scripts/check_site.py` | **构建成功但站点坏掉**:站内链接是否有效、检索索引 docId 是否越界、docs.json 指向的页面是否存在、sitemap/canonical/RSS 是否同源 |
+| `scripts/check_links.py` | **外面的链接还活着吗**:4000+ 条外链可达性;5xx 视为服务端临时故障并重试,只有 404/410 才算死链 |
+
+本地一把跑完:
+
+```bash
+python3 scripts/gen_index.py --check # INDEX.md 是否最新
+python3 scripts/check_docs.py # 内容质量
+python3 scripts/build_site.py # 构建站点
+python3 scripts/check_site.py # 站点自检
+python3 scripts/check_links.py --limit 300 # 外链抽样
+```
+
## 目录
### 一、人工智能(AI)(46 篇)
diff --git a/scripts/build_site.py b/scripts/build_site.py
index 87c08e1..f62344f 100644
--- a/scripts/build_site.py
+++ b/scripts/build_site.py
@@ -8,36 +8,33 @@
- 零依赖:不引入 MkDocs / VitePress,只用标准库。构建产物是纯静态 HTML,
克隆下来双击 index.html 就能看,也方便以后换任何托管。
- 不碰原文:Markdown 是唯一真相源,站点只是投影;改内容只改 docs/。
-- 索引一次生成:搜索用预生成的 JSON + 浏览器端过滤,不要后端。
+- 检索用倒排索引 + BM25(见 scripts/kb.py),不再把正文全塞进 JSON。
+ 首屏 0 请求,第一次输入才拉索引;单次按键只查候选集而非全库扫描。
+
+除了渲染,本脚本还顺带产出知识库的「机器可读形态」:
+ search-index.json 倒排索引(浏览器端检索)
+ docs.json 全部文档的元数据 + 摘要 + 标签 + 相关文档(可被程序消费)
+ graph.json 文档关联图(节点 + 边,给可视化/分析用)
+ tags.json 标签 -> 文档 的聚合
+ feed.xml RSS(按更新时间)
+ sitemap.xml / robots.txt
"""
import argparse
import html
import json
import re
import shutil
+from datetime import date
from pathlib import Path
-ROOT = Path(__file__).resolve().parent.parent
-DOCS = ROOT / "docs"
-OUT = ROOT / "site"
+import kb
+from kb import DOCS, DOMAINS, ROOT
-DOMAINS = [
- ("ai", "人工智能(AI)"),
- ("hardware", "硬件与半导体"),
- ("software", "软件工程"),
- ("data", "数据工程与数据平台"),
- ("security", "网络安全"),
- ("science", "基础科学前沿"),
- ("emerging", "新兴科技"),
- ("industry", "产业与政策"),
- ("society", "社会与数字权利"),
-]
-DOMAIN_ORDER = {slug: i for i, (slug, _) in enumerate(DOMAINS)}
-DOMAIN_LABEL = dict(DOMAINS)
-
-TITLE_RE = re.compile(r'^#\s+(.+?)\s*$', re.M)
-META_RE = re.compile(r'^>\s*最后更新:(\d{4}-\d{2}-\d{2})\s*|\s*领域:([^|]+)|.*$', re.M)
-SUMMARY_RE = re.compile(r'^##\s+概述\s*$', re.M)
+OUT = ROOT / "site"
+DOMAIN_ORDER = kb.DOMAIN_ORDER
+DOMAIN_LABEL = kb.DOMAIN_LABEL
+# 站点当前的对外地址。换域名只改这一处 + kb 无关。
+SITE_URL = "https://ice-wocker.github.io/frontier-knowledge-base"
# ---------- Markdown 渲染(只覆盖本仓库实际用到的子集) ----------
@@ -46,25 +43,30 @@ def esc(s):
return html.escape(s, quote=False)
+def slugify(text):
+ return re.sub(r'[^\w\u4e00-\u9fff]+', '-', text).strip('-').lower()
+
+
def inline(text):
"""行内元素:先转义,再处理 code / 粗体 / 链接,顺序不能反。"""
text = esc(text)
text = re.sub(r'`([^`]+)`', r'\1', text)
text = re.sub(r'\*\*([^*]+)\*\*', r'\1 ', text)
- # [文字](url)
+
def link(m):
label, url = m.group(1), m.group(2)
ext = url.startswith('http')
attrs = ' target="_blank" rel="noopener noreferrer"' if ext else ''
return f'{label} '
+
text = re.sub(r'\[([^\]]+)\]\(([^)\s]+)\)', link, text)
return text
-def render_markdown(md, doc_slug):
- """渲染正文。返回 HTML 字符串。"""
+def render_markdown(md):
+ """渲染正文,同时收集 H2/H3 作为目录(TOC)。返回 (html, toc)。"""
lines = md.split('\n')
- out = []
+ out, toc = [], []
i = 0
in_ul = in_ol = False
@@ -107,10 +109,16 @@ def close_lists():
level = len(m.group(1))
text = m.group(2).strip()
if level == 1:
- # H1 由模板负责,正文里跳过(避免重复)
- i += 1
+ i += 1 # H1 由模板负责,正文里跳过(避免重复)
continue
- anchor = re.sub(r'[^\w\u4e00-\u9fff]+', '-', text).strip('-').lower()
+ anchor = slugify(text)
+ # 锚点去重:同页同名标题加后缀
+ base, n = anchor, 1
+ while anchor in [t[2] for t in toc]:
+ n += 1
+ anchor = f"{base}-{n}"
+ if level == 2:
+ toc.append((level, text, anchor))
out.append(f'{inline(text)} ')
i += 1
continue
@@ -143,57 +151,47 @@ def close_lists():
i += 1
close_lists()
- return '\n'.join(out)
+ return '\n'.join(out), toc
-# ---------- 元数据 ----------
+def plain_text(md):
+ """Markdown -> 纯文本(做摘要用)。"""
+ t = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', md)
+ t = re.sub(r'[#>|*`]', ' ', t)
+ return re.sub(r'\s+', ' ', t).strip()
-def load_docs():
- items = []
- for slug, label in DOMAINS:
- d = DOCS / slug
- if not d.is_dir():
- continue
- for md in sorted(d.rglob('*.md')):
- text = md.read_text(encoding='utf-8')
- tm = TITLE_RE.search(text)
- title = tm.group(1).strip() if tm else md.stem
- mm = META_RE.search(text)
- updated = mm.group(1) if mm else ''
- domain = mm.group(2).strip() if mm else label
- items.append({
- 'slug': slug,
- 'domain': label,
- 'domain_slug': slug,
- 'title': title,
- 'updated': updated,
- 'path': md.relative_to(ROOT).as_posix(),
- 'file': md,
- 'text': text,
- 'body': render_markdown(text, md.stem),
- })
- return items
+
+def summary_of(text, limit=180):
+ m = re.search(r'^##\s+概述\s*$', text, re.M)
+ body = text[m.end():] if m else text
+ s = plain_text(body)
+ return (s[:limit] + '…') if len(s) > limit else s
# ---------- 模板 ----------
CSS = """
:root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--fg:#e6edf3;--muted:#8b949e;
---accent:#58a6ff;--accent2:#7ee787;}
+--accent:#58a6ff;--accent2:#7ee787}
+@media(prefers-color-scheme:light){:root{--bg:#fff;--panel:#f6f8fa;--border:#d0d7de;
+--fg:#1f2328;--muted:#59636e;--accent:#0969da;--accent2:#1a7f37}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
-font:16px/1.75 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
+font:16px/1.75 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
+-webkit-text-size-adjust:100%}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
-header.top{position:sticky;top:0;background:rgba(13,17,23,.92);backdrop-filter:blur(8px);
-border-bottom:1px solid var(--border);z-index:10}
+header.top{position:sticky;top:0;background:color-mix(in srgb,var(--bg) 92%,transparent);
+backdrop-filter:blur(8px);border-bottom:1px solid var(--border);z-index:10}
.wrap{max-width:900px;margin:0 auto;padding:0 20px}
header.top .wrap{display:flex;align-items:center;gap:14px;height:56px}
header.top .brand{font-weight:600;white-space:nowrap}
header.top .brand em{color:var(--accent2);font-style:normal}
-nav.domains{display:flex;gap:10px;overflow-x:auto;padding:12px 0;font-size:14px;
+header.top .spacer{flex:1}
+header.top a.ic{color:var(--muted);font-size:.9rem}
+nav.domains{display:flex;gap:8px;overflow-x:auto;padding:10px 0;font-size:14px;
border-bottom:1px solid var(--border);scrollbar-width:thin}
-nav.domains a{color:var(--muted);white-space:nowrap;padding:2px 8px;border-radius:6px}
+nav.domains a{color:var(--muted);white-space:nowrap;padding:3px 9px;border-radius:6px}
nav.domains a.active,nav.domains a:hover{color:var(--fg);background:var(--panel);text-decoration:none}
main{padding:28px 0 80px}
h1{font-size:1.7rem;line-height:1.35;margin:.2em 0 .6em}
@@ -234,189 +232,383 @@ def load_docs():
.hit:hover{border-color:var(--accent);text-decoration:none}
.hit .d{color:var(--muted);font-size:.8rem}
.hit mark{background:#3d2f00;color:#ffd76e;border-radius:3px;padding:0 2px}
+@media(prefers-color-scheme:light){.hit mark{background:#fff8c5;color:#7d4e00}}
footer{border-top:1px solid var(--border);color:var(--muted);font-size:.85rem;padding:22px 0 40px}
.pager{display:flex;justify-content:space-between;gap:12px;margin-top:3em;
padding-top:1.4em;border-top:1px solid var(--border);font-size:.9rem}
.pager a{max-width:48%}
-@media(max-width:600px){h1{font-size:1.4rem}.wrap{padding:0 16px}}
+.toc{background:var(--panel);border:1px solid var(--border);border-radius:10px;
+padding:12px 18px;margin:0 0 1.8em;font-size:.9rem}
+.toc summary{cursor:pointer;color:var(--muted);font-weight:600;user-select:none}
+.toc ol{margin:.7em 0 .2em;padding-left:1.3em}
+.toc li{margin:.25em 0}
+.chips{display:flex;flex-wrap:wrap;gap:6px;margin:.8em 0 1.4em}
+.chip{font-size:.78rem;color:var(--muted);background:var(--panel);border:1px solid var(--border);
+padding:2px 9px;border-radius:999px}
+a.chip:hover{color:var(--accent);border-color:var(--accent);text-decoration:none}
+.rel{margin-top:2.6em;padding-top:1.2em;border-top:1px solid var(--border)}
+.rel h2{border:0;padding-top:0;font-size:1.05rem;margin-top:.2em}
+.tagcloud{display:flex;flex-wrap:wrap;gap:8px;margin:1.2em 0}
+@media(max-width:600px){h1{font-size:1.4rem}.wrap{padding:0 16px}.toc{font-size:.85rem}}
"""
-SEARCH_JS = """
+SEARCH_JS = r"""
(function(){
var box=document.getElementById('q'), out=document.getElementById('results');
if(!box) return;
- var data=null, timer=null;
- fetch('search.json').then(function(r){return r.json()}).then(function(j){data=j;});
+ var idx=null, meta=null, pending=null;
+ function load(){
+ if(pending) return pending;
+ pending=Promise.all([
+ fetch('search-index.json').then(function(r){return r.json()}),
+ fetch('docs.json').then(function(r){return r.json()})
+ ]).then(function(a){idx=a[0];meta=a[1];});
+ return pending;
+ }
+ var CJK=/[\u4e00-\u9fff]/;
+ function tokenize(text){
+ text=text.toLowerCase(); var out=[]; var re=/[\u4e00-\u9fff]+|[a-z][a-z0-9+#._-]*|\d+/g,m;
+ while((m=re.exec(text))){
+ var t=m[0];
+ if(CJK.test(t)){ if(t.length===1){out.push(t);} else {for(var i=0;i=2){out.push(t);}
+ }
+ return out;
+ }
function render(kw){
- if(!data) return;
+ if(!idx||!meta) return;
kw=kw.trim().toLowerCase();
if(!kw){out.innerHTML='';return;}
- var terms=kw.split(/\\s+/).filter(Boolean), hits=[];
- for(var i=0;i=0)?50:1;
+ var terms=tokenize(kw); if(!terms.length){out.innerHTML='';return;}
+ var qc={}, scores={}, P=idx.p, names=meta.docs;
+ for(var i=0;i没有匹配的文档。
';return;}
- out.innerHTML=hits.map(function(h){
- var d=h[1], s=d.summary||'';
- for(var t=0;t=0) scores[i2]+=3*idx.idf[t2]; }
+ arr.push([scores[i2], i2]);
+ }
+ arr.sort(function(a,b){return b[0]-a[0]});
+ arr=arr.slice(0,40);
+ if(!arr.length){out.innerHTML='没有匹配的文档。
';return;}
+ out.innerHTML=arr.map(function(h){
+ var d=names[h[1]], s=d.s||'';
+ for(var i=0;i$1');
}
- return ''+d.title+'
'+d.domain+(d.updated?' · '+d.updated:'')+'
'+s+'
';
+ return ''+d.t+'
'+d.dm+(d.ud?' · '+d.ud:'')+' · '+d.rm+' 分钟
'+s+'
';
}).join('');
}
box.addEventListener('input',function(){
- clearTimeout(timer); timer=setTimeout(function(){render(box.value)},120);
+ if(!idx){ load().then(function(){render(box.value)}); } else { render(box.value); }
});
+ box.addEventListener('focus',load,{once:true});
})();
"""
-def page(title, body, depth, active_slug=None, search=False):
+def page(title, body, depth, active_slug=None, search=False, desc=None, canonical=None):
prefix = '../' * depth
nav = ''.join(
- f'{html.escape(l)} '
+ f'{html.escape(l)} '
for s, l in DOMAINS
)
nav = f'全部 ' + nav
- search_html = ''
- if search:
- search_html = '
'
- depth_script = ''
- if search:
- depth_script = f''
+ desc = desc or f"前沿科技知识库 · {title}"
+ canon = canonical or f"{SITE_URL}/{'../' * depth}".rstrip('/') + '/'
+ depth_script = f'' if search else ''
return f"""
{html.escape(title)}
-
+
+
+
+
-🧊 前沿科技 知识库
+
{body}
{depth_script}
"""
+def card(d, href, extra=""):
+ return (f'{html.escape(d["title"])}
'
+ f'{d["updated"]}{extra}
')
+
+
def build():
if OUT.exists():
shutil.rmtree(OUT)
OUT.mkdir(parents=True)
- docs = load_docs()
(OUT / 'style.css').write_text(CSS, encoding='utf-8')
- # 搜索索引
- search = []
- for d in docs:
- plain = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', d['text'])
- plain = re.sub(r'[#>|*`]', ' ', plain)
- plain = re.sub(r'\s+', ' ', plain).strip()
- search.append({
- 'title': d['title'],
- 'domain': d['domain'],
- 'updated': d['updated'],
- 'summary': (plain[:180] + '…') if len(plain) > 180 else plain,
- 'text': plain[:6000],
- 'url': f"{d['domain_slug']}/{d['file'].stem}.html",
+ docs = kb.load_docs()
+ index = kb.build_index(docs)
+
+ # ---- 每篇的派生数据:摘要 / 标签 / 相关 / 阅读时长 ----
+ enriched = []
+ for i, d in enumerate(docs):
+ body_html, toc = render_markdown(d['text'])
+ rel = [(docs[j]['title'], docs[j]['domain_slug'], docs[j]['stem'], s)
+ for j, s in kb.related(docs, index, i)]
+ enriched.append({
+ **d,
+ 'body': body_html,
+ 'toc': toc,
+ 'summary': summary_of(d['text']),
+ 'tags': kb.tags_for(d, index, docs),
+ 'related': rel,
+ 'read_min': kb.reading_minutes(d['text']),
+ 'refs': kb.extract_refs(d['text']),
+ 'url': f"{d['domain_slug']}/{d['stem']}.html",
})
- (OUT / 'search.json').write_text(json.dumps(search, ensure_ascii=False), encoding='utf-8')
- # 首页
- by_domain = {}
- for d in docs:
+ by_domain: dict[str, list[dict]] = {}
+ for d in enriched:
by_domain.setdefault(d['domain_slug'], []).append(d)
+
+ # ---- 检索索引(倒排,浏览器端 BM25)----
+ # 扁平化 postings:{term: [docId, tf, docId, tf, ...]}。
+ # 比 {term:{docId:tf}} 省约 20% 体积,JSON 解析也更快。
+ postings = {}
+ for term, pl in index['postings'].items():
+ flat = []
+ for k, v in pl.items():
+ flat.append(k); flat.append(v)
+ postings[term] = flat
+ search_index = {
+ 'avgdl': (sum(index['dl']) / len(index['dl'])) if index['dl'] else 1,
+ 'dl': index['dl'],
+ 'p': postings,
+ 'idf': {k: round(v, 4) for k, v in index['idf'].items()},
+ }
+ (OUT / 'search-index.json').write_text(
+ json.dumps(search_index, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
+
+ # ---- docs.json:机器可读的文档清单 ----
+ docs_json = {
+ 'generated': date.today().isoformat(),
+ 'site': SITE_URL,
+ 'count': len(enriched),
+ 'docs': [{
+ 'i': i,
+ 'title': d['title'],
+ 't': d['title'],
+ 'u': d['url'],
+ 's': d['summary'],
+ 'dm': d['domain'],
+ 'd': d['domain_slug'],
+ 'ud': d['updated'],
+ 'rm': d['read_min'],
+ 'tags': d['tags'],
+ 'sections': d['sections'],
+ 'refs': len(d['refs']),
+ 'related': [{'title': t, 'url': f"{s}/{st}.html", 'score': round(sc, 4)}
+ for t, s, st, sc in d['related']],
+ } for i, d in enumerate(enriched)],
+ }
+ (OUT / 'docs.json').write_text(
+ json.dumps(docs_json, ensure_ascii=False, indent=1), encoding='utf-8')
+
+ # ---- graph.json:关联图 ----
+ graph = {
+ 'nodes': [{'id': i, 'label': d['title'], 'domain': d['domain_slug'],
+ 'domain_label': d['domain'], 'url': d['url'],
+ 'read_min': d['read_min'], 'refs': len(d['refs'])}
+ for i, d in enumerate(enriched)],
+ 'edges': [{'source': i, 'target': docs.index(next(x for x in docs if x['title'] == t
+ and x['domain_slug'] == s)),
+ 'weight': round(sc, 4)}
+ for i, d in enumerate(enriched)
+ for t, s, st, sc in d['related']],
+ }
+ (OUT / 'graph.json').write_text(
+ json.dumps(graph, ensure_ascii=False, indent=1), encoding='utf-8')
+
+ # ---- tags.json ----
+ tag_map: dict[str, list[str]] = {}
+ for d in enriched:
+ for t in d['tags']:
+ tag_map.setdefault(t, []).append(d['url'])
+ (OUT / 'tags.json').write_text(
+ json.dumps(tag_map, ensure_ascii=False, indent=1), encoding='utf-8')
+
+ # ---- 首页 ----
sections = []
for slug, label in DOMAINS:
items = by_domain.get(slug, [])
if not items:
continue
- cards = ''.join(
- f''
- f'{html.escape(d["title"])}
'
- f'{d["updated"]}
' for d in items
- )
- sections.append(
- f'{html.escape(label)}({len(items)} 篇) '
- f'{cards}
'
- )
+ cards = ''.join(card(d, f'{slug}/{d["stem"]}.html', f' · {d["read_min"]} 分钟') for d in items)
+ sections.append(f'{html.escape(label)}({len(items)} 篇) '
+ f'{cards}
')
+ total_refs = sum(len(d['refs']) for d in enriched)
body = f"""
前沿科技知识库
覆盖 AI、硬件半导体、软件工程、数据工程、网络安全、基础科学、新兴科技、产业与社会。
内容基于公开网络资料整理,关键事实就地标注来源,每篇文末附完整链接清单,可逐条回溯。
-
{len(docs)} 篇文档
-
{len(set(d['domain_slug'] for d in docs))} 个领域
+
{len(enriched)} 篇文档
+
{len(by_domain)} 个领域
+
{total_refs} 条来源链接
0 个第三方依赖
-
-
+
+
""" + '\n'.join(sections)
- (OUT / 'index.html').write_text(page('前沿科技知识库', body, 0, 'all', search=True), encoding='utf-8')
+ (OUT / 'index.html').write_text(
+ page('前沿科技知识库', body, 0, 'all', search=True,
+ desc=f"前沿科技知识库:{len(enriched)} 篇专题文档,覆盖 AI、硬件、软件、数据、安全、科学、新兴科技等九大领域,逐条附来源链接。",
+ canonical=f'{SITE_URL}/index.html'), encoding='utf-8')
- # 各领域页
+ # ---- 领域页 ----
for slug, label in DOMAINS:
items = by_domain.get(slug, [])
if not items:
continue
- ddir = OUT / slug
- ddir.mkdir(exist_ok=True)
- cards = ''.join(
- f''
- f'{html.escape(d["title"])}
'
- f'{d["updated"]}
' for d in items
- )
- b = f'全部 / {html.escape(label)}
' \
- f'{html.escape(label)} {cards}
'
- (ddir / 'index.html').write_text(page(label, b, 1, slug), encoding='utf-8')
-
- # 每篇文档
- seq = {d['file']: i for i, d in enumerate(docs)}
- for d in docs:
- ddir = OUT / d['domain_slug']
- i = seq[d['file']]
- prev_doc = docs[i - 1] if i > 0 else None
- next_doc = docs[i + 1] if i + 1 < len(docs) else None
- pager = ['')
+ (OUT / slug).mkdir(exist_ok=True)
+ cards = ''.join(card(d, f'{d["stem"]}.html', f' · {d["read_min"]} 分钟') for d in items)
+ b = (f'全部 / {html.escape(label)}
'
+ f'{html.escape(label)} {cards}
')
+ (OUT / slug / 'index.html').write_text(
+ page(label, b, 1, slug, desc=f"{label} · 前沿科技知识库({len(items)} 篇)",
+ canonical=f'{SITE_URL}/{slug}/index.html'), encoding='utf-8')
+
+ # ---- 文档页 ----
+ for i, d in enumerate(enriched):
+ prev_d = enriched[i - 1] if i > 0 else None
+ next_d = enriched[i + 1] if i + 1 < len(enriched) else None
+
+ def href(o):
+ if o['domain_slug'] == d['domain_slug']:
+ return f"{o['stem']}.html"
+ return f"../{o['domain_slug']}/{o['stem']}.html"
+
+ pager = ''
+
+ toc_html = ''
+ if len(d['toc']) >= 2:
+ lis = ''.join(f'{html.escape(t)} ' for _l, t, a in d['toc'])
+ toc_html = f'本页目录({len(d["toc"])} 节) {lis} '
+
+ chips = ''.join(f'{html.escape(t)} '
+ for t in d['tags'])
+ chips_html = f'{chips}
' if d['tags'] else ''
+
+ rel_html = ''
+ if d['related']:
+ rcards = ''.join(
+ f''
+ f'{html.escape(t)}
相关度 {round(sc, 3)}
'
+ for t, s, st, sc in d['related'])
+ rel_html = (f'')
+
b = (f''
+ f'{html.escape(d["domain"])} / {html.escape(d["title"])}'
f'{html.escape(d["title"])} '
- f'{d["updated"]} · {html.escape(d["domain"])}
'
- f'{d["body"]}{"".join(pager)}')
- (ddir / f'{d["file"].stem}.html').write_text(page(d['title'], b, 1, d['domain_slug']), encoding='utf-8')
+ f'{d["updated"]} · {html.escape(d["domain"])} · '
+ f'{d["read_min"]} 分钟 · {len(d["refs"])} 条来源
'
+ f'{chips_html}{toc_html}{d["body"]}{rel_html}{pager}')
+ (OUT / d['domain_slug'] / f'{d["stem"]}.html').write_text(
+ page(d['title'], b, 1, d['domain_slug'], desc=d['summary'],
+ canonical=f"{SITE_URL}/{d['url']}"), encoding='utf-8')
+
+ # ---- 标签页 ----
+ tags_sorted = sorted(tag_map.items(), key=lambda kv: (-len(kv[1]), kv[0]))
+ lookup = {d['url']: d for d in enriched}
+ blocks = []
+ for t, urls in tags_sorted:
+ cards = ''
+ for u in urls:
+ d = lookup.get(u)
+ if d:
+ cards += card(d, u if d['domain_slug'] == '#' else u, f" · {d['read_min']} 分钟")
+ blocks.append(f'{html.escape(t)}({len(urls)}) '
+ f'{cards}
')
+ tb = (f''
+ f'标签索引 共 {len(tag_map)} 个标签,按文档数排序。'
+ f'标签由标题概念与正文高频术语自动抽取(零依赖)。
' + '\n'.join(blocks))
+ (OUT / 'tags.html').write_text(
+ page('标签索引', tb, 0, None, desc='前沿科技知识库标签索引',
+ canonical=f'{SITE_URL}/tags.html'), encoding='utf-8')
+
+ # ---- RSS ----
+ items = sorted(enriched, key=lambda d: d['updated'], reverse=True)[:30]
+ rss = ['',
+ '',
+ '前沿科技知识库 ',
+ f' {SITE_URL}/',
+ '覆盖九大领域的前沿科技专题文档,逐条附来源链接 ',
+ 'zh-CN ',
+ f'{date.today().strftime("%a, %d %b %Y 00:00:00 +0000")} ']
+ for d in items:
+ rss.append('- ')
+ rss.append(f'
{html.escape(d["title"])} ')
+ rss.append(f' {SITE_URL}/{d["url"]}')
+ rss.append(f'{SITE_URL}/{d["url"]} ')
+ rss.append(f'{html.escape(d["summary"])} ')
+ if d['updated']:
+ rss.append(f'{d["updated"]} 00:00:00 +0000 ')
+ rss.append(' ')
+ rss.append(' ')
+ (OUT / 'feed.xml').write_text('\n'.join(rss), encoding='utf-8')
+
+ # ---- sitemap / robots ----
+ urls = ['%s/index.html ' % SITE_URL,
+ '%s/tags.html ' % SITE_URL]
+ urls += [f'{SITE_URL}/{s}/index.html ' for s, _ in DOMAINS if by_domain.get(s)]
+ for d in enriched:
+ last = f'{d["updated"]} ' if d['updated'] else ''
+ urls.append(f'{SITE_URL}/{d["url"]} {last} ')
+ (OUT / 'sitemap.xml').write_text(
+ '\n'
+ '\n'
+ + '\n'.join(urls) + '\n \n', encoding='utf-8')
+ (OUT / 'robots.txt').write_text(
+ f'User-agent: *\nAllow: /\nSitemap: {SITE_URL}/sitemap.xml\n', encoding='utf-8')
- # .nojekyll 让 GitHub Pages 原样发布(下划线开头文件/目录)
(OUT / '.nojekyll').write_text('', encoding='utf-8')
- print(f'构建完成:{len(docs)} 篇文档 → {OUT}')
- return len(docs)
+ print(f'构建完成:{len(enriched)} 篇文档 → {OUT}')
+ print(f' 检索索引 {len(postings)} 词 / docs.json / graph.json / tags.json / feed.xml')
+ return len(enriched)
if __name__ == '__main__':
diff --git a/scripts/check_docs.py b/scripts/check_docs.py
new file mode 100644
index 0000000..da9fe6c
--- /dev/null
+++ b/scripts/check_docs.py
@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+"""知识库内容质量守门:结构、来源、元数据的一致性校验。
+
+为什么需要它:这个库的价值全部建立在「每篇都结构统一、每条事实都能回溯」
+上。而人写 163 篇(还会继续加)必然漂移——漏了「概述」、参考来源只剩两条、
+标题重复、日期格式不统一。这类问题不影响构建,所以永远不会有人发现,
+直到读者点进来觉得「这库不太行」。
+
+`check_links.py` 管的是「外面的链接还活着吗」,本脚本管「里面写的东西还成立吗」。
+
+用法:
+ python3 scripts/check_docs.py # 校验
+ python3 scripts/check_docs.py --strict # 把 warning 也当失败(PR 用)
+
+退出码:有 error -> 1。
+"""
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from collections import Counter
+from pathlib import Path
+
+import kb
+
+# 每篇必须覆盖的「语义槽位」及其可接受的命名变体。
+# 为什么用变体而不是固定标题:库里本来就存在「核心脉络与关键里程碑」
+# 「核心概念与关键条款」这类更贴切场景的写法,硬性统一反而逼人降质。
+# 校验的是「有没有讲到」,不是「标题叫什么」。
+REQUIRED = {
+ "概述": ["概述", "简介", "是什么"],
+ "最新进展": ["最新进展", "进展", "现状"],
+ # 「核心/关键」开头的章节都算讲到了技术/概念本体
+ "核心技术": ["核心", "关键", "原理"],
+ "趋势与争议": ["趋势与争议", "争议", "趋势", "挑战"],
+ "参考来源": ["参考来源", "参考资料", "来源"],
+}
+# 建议覆盖(缺了只警告)
+RECOMMENDED = {
+ "代表性项目": ["代表性项目", "代表性", "代表产品", "主要项目", "典型"],
+ "关键数据": ["关键数据", "评测结果", "数据与"],
+}
+
+MIN_REFS = 5 # 参考来源少于这个数,可信度存疑
+MAX_TITLE_LEN = 60 # 标题过长在卡片/搜索结果里会被截断
+DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--strict", action="store_true", help="warning 也视为失败")
+ args = ap.parse_args()
+
+ docs = kb.load_docs()
+ errors: list[str] = []
+ warnings: list[str] = []
+
+ def err(doc, msg):
+ errors.append(f"{doc['path']}: {msg}")
+
+ def warn(doc, msg):
+ warnings.append(f"{doc['path']}: {msg}")
+
+ # 1. 必备章节(按语义槽位 + 命名变体判断)
+ for d in docs:
+ secs = d["sections"]
+ for slot, variants in REQUIRED.items():
+ if not any(v in s for s in secs for v in variants):
+ err(d, f"缺少内容槽位「{slot}」(可接受的标题:{'、'.join(variants)})")
+ for slot, variants in RECOMMENDED.items():
+ if not any(v in s for s in secs for v in variants):
+ warn(d, f"缺少建议槽位「{slot}」")
+
+ # 2. 元信息行(最后更新 / 领域)
+ for d in docs:
+ if not d["updated"]:
+ err(d, "缺少「最后更新:YYYY-MM-DD」")
+ elif not DATE_RE.match(d["updated"]):
+ err(d, f"日期格式不合法:{d['updated']}")
+ if not d["field"]:
+ warn(d, "缺少「领域:」描述")
+
+ # 3. 参考来源数量
+ for d in docs:
+ n = len(kb.extract_refs(d["text"]))
+ if n < MIN_REFS:
+ err(d, f"参考来源仅 {n} 条(下限 {MIN_REFS})")
+
+ # 4. 标题:重复 / 过长
+ titles = Counter(d["title"] for d in docs)
+ for d in docs:
+ if titles[d["title"]] > 1:
+ err(d, f"标题重复:{d['title']}")
+ if len(d["title"]) > MAX_TITLE_LEN:
+ warn(d, f"标题过长({len(d['title'])} 字符):{d['title']}")
+
+ # 5. 正文太短(可能是占位/半成品)
+ for d in docs:
+ body = kb.body_of(d["text"])
+ if len(body) < 1500:
+ warn(d, f"正文偏短({len(body)} 字符)")
+
+ # 7. 更新日期不应晚于今天(未来日期多半是手误)
+ from datetime import date
+ today = date.today().isoformat()
+ for d in docs:
+ if d["updated"] and d["updated"] > today:
+ err(d, f"更新日期在未来:{d['updated']}")
+
+ # 汇总
+ print(f"文档数: {len(docs)}")
+ print(f"领域数: {len({d['domain_slug'] for d in docs})}")
+ print(f"来源链接(唯一): {len({u for d in docs for u in kb.extract_refs(d['text'])})}")
+ print()
+
+ if warnings:
+ print(f"===== 警告 {len(warnings)} 条 =====")
+ for w in warnings[:60]:
+ print(f" ⚠ {w}")
+ if len(warnings) > 60:
+ print(f" ... 另有 {len(warnings) - 60} 条")
+ print()
+
+ if errors:
+ print(f"===== 错误 {len(errors)} 条 =====")
+ for e in errors:
+ print(f" ✖ {e}")
+ return 1
+
+ print("✅ 内容校验通过")
+ if args.strict and warnings:
+ print("✖ --strict 模式下警告视为失败")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_site.py b/scripts/check_site.py
new file mode 100644
index 0000000..3e067ec
--- /dev/null
+++ b/scripts/check_site.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""站点产物自检:构建成功后,验证「站点真的能用」。
+
+`build_site.py` 只能证明「渲染没报错」,证明不了「站点是对的」。这类静默故障
+包括:站内链接指向不存在的页面、sitemap 里的 URL 打不开、检索索引里的
+docId 越界(点进去 404)、canonical 声明了一个连不上的域名。
+
+之前的版本只查「链接能不能打开」,挡不住上面这些。本脚本专接
+「构建成功但站点坏掉」。
+
+用法:
+ python3 scripts/check_site.py # 校验已构建的 site/
+ python3 scripts/check_site.py --build # 先构建再校验
+ python3 scripts/check_site.py --online # 额外检查入口地址是否可达
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SITE = ROOT / "site"
+
+HREF_RE = re.compile(r'(?:href|src)="([^"#?]+)(?:[#?][^"]*)?"')
+# 内联 |',
+ re.S | re.I)
+
+
+def fail(msg):
+ print(f" ✖ {msg}")
+ return 1
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--build", action="store_true", help="先运行构建")
+ ap.add_argument("--online", action="store_true", help="检查线上入口是否可达")
+ args = ap.parse_args()
+
+ if args.build:
+ r = subprocess.run([sys.executable, str(ROOT / "scripts" / "build_site.py")])
+ if r.returncode != 0:
+ return r.returncode
+
+ if not SITE.is_dir():
+ return fail("site/ 不存在,请先运行 scripts/build_site.py")
+
+ errors = 0
+
+ # 1. 站内链接:每个 href/src 都必须指向真实存在的文件
+ print("检查站内链接…")
+ missing = {}
+ htmls = list(SITE.rglob("*.html"))
+ for h in htmls:
+ base = h.parent
+ # 去掉内联脚本/样式,避免把 JS 拼接的字符串当成链接
+ text = SCRIPT_RE.sub('', h.read_text(encoding="utf-8"))
+ for href in HREF_RE.findall(text):
+ if href.startswith(("http://", "https://", "mailto:", "data:", "javascript:")):
+ continue
+ target = (base / href).resolve()
+ if not target.exists():
+ missing.setdefault(href, set()).add(h.relative_to(SITE).as_posix())
+ for href, srcs in sorted(missing.items())[:30]:
+ errors += fail(f"站内链接失效 {href} ← {', '.join(sorted(srcs)[:3])}")
+ if not missing:
+ print(f" ✓ {len(htmls)} 个页面,站内链接全部有效")
+
+ # 2. 检索索引:docId 必须落在 docs 范围内
+ print("检查检索索引…")
+ try:
+ idx = json.loads((SITE / "search-index.json").read_text(encoding="utf-8"))
+ meta = json.loads((SITE / "docs.json").read_text(encoding="utf-8"))
+ except Exception as e: # noqa: BLE001
+ return fail(f"索引文件无法解析:{e}")
+ n = meta["count"]
+ if len(meta["docs"]) != n:
+ errors += fail(f"docs.json count={n} 与 docs 数组长度 {len(meta['docs'])} 不符")
+ oob = 0
+ for term, flat in idx["p"].items():
+ for i in range(0, len(flat), 2):
+ if not (0 <= flat[i] < n):
+ oob += 1
+ if oob:
+ errors += fail(f"检索索引有 {oob} 个越界 docId")
+ else:
+ print(f" ✓ {len(idx['p'])} 个词,docId 全部在 [0,{n}) 内")
+
+ # 3. 索引里指向的页面必须真实存在
+ print("检查索引指向的页面…")
+ bad = []
+ for d in meta["docs"]:
+ if not (SITE / d["u"]).exists():
+ bad.append(d["u"])
+ for u in bad[:20]:
+ errors += fail(f"docs.json 指向不存在的页面 {u}")
+ if not bad:
+ print(f" ✓ {n} 篇文档页面全部存在")
+
+ # 4. sitemap / feed 的 URL 必须与 canonical 同源
+ print("检查 sitemap / RSS / canonical 同源…")
+ sm = (SITE / "sitemap.xml").read_text(encoding="utf-8")
+ locs = re.findall(r"(.+?) ", sm)
+ hosts = {re.match(r"https?://[^/]+", u).group(0) for u in locs}
+ if len(hosts) != 1:
+ errors += fail(f"sitemap 出现多个主机:{hosts}")
+ else:
+ print(f" ✓ sitemap {len(locs)} 条,主机 {hosts.pop()}")
+ if not (SITE / "feed.xml").read_text(encoding="utf-8").count("- "):
+ errors += fail("feed.xml 没有任何条目")
+
+ # 5. 必需产物
+ for f in ["index.html", "tags.html", "style.css", "search-index.json",
+ "docs.json", "graph.json", "tags.json", "feed.xml",
+ "sitemap.xml", "robots.txt", ".nojekyll"]:
+ if not (SITE / f).exists():
+ errors += fail(f"缺少产物 {f}")
+
+ # 6. 线上入口可达(可选)
+ if args.online and locs:
+ entry = locs[0]
+ print(f"检查入口 {entry} …")
+ try:
+ req = urllib.request.Request(entry, headers={"User-Agent": "kb-check"})
+ with urllib.request.urlopen(req, timeout=15) as r:
+ print(f" ✓ HTTP {r.status}")
+ except urllib.error.HTTPError as e:
+ errors += fail(f"入口返回 HTTP {e.code}")
+ except Exception as e: # noqa: BLE001
+ errors += fail(f"入口不可达:{e}")
+
+ if errors:
+ print(f"\n✖ 站点自检失败,{errors} 个问题")
+ return 1
+ print("\n✅ 站点自检通过")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/kb.py b/scripts/kb.py
new file mode 100644
index 0000000..ee01a9c
--- /dev/null
+++ b/scripts/kb.py
@@ -0,0 +1,356 @@
+#!/usr/bin/env python3
+"""知识库的公共引擎:解析 Markdown、分词、倒排索引、相似度。
+
+为什么单独抽一个模块:`build_site.py` / `check_docs.py` / 未来的导出脚本
+都要用同一套「文档是什么、词怎么切、谁和谁像」的定义。定义散在多处就会漂,
+一处改了另一处不知道——这是知识库这种长期维护项目最容易烂的地方。
+
+零依赖,只用标准库。
+"""
+from __future__ import annotations
+
+import math
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+DOCS = ROOT / "docs"
+
+# 领域目录 -> 中文名。顺序即展示顺序。
+DOMAINS: list[tuple[str, str]] = [
+ ("ai", "人工智能(AI)"),
+ ("hardware", "硬件与半导体"),
+ ("software", "软件工程"),
+ ("data", "数据工程与数据平台"),
+ ("security", "网络安全"),
+ ("science", "基础科学前沿"),
+ ("emerging", "新兴科技"),
+ ("industry", "产业与政策"),
+ ("society", "社会与数字权利"),
+]
+DOMAIN_LABEL = dict(DOMAINS)
+DOMAIN_ORDER = {slug: i for i, (slug, _) in enumerate(DOMAINS)}
+
+TITLE_RE = re.compile(r'^#\s+(.+?)\s*$', re.M)
+UPDATED_RE = re.compile(r'最后更新:(\d{4}-\d{2}-\d{2})')
+FIELD_RE = re.compile(r'领域:([^||\n]+)')
+URL_RE = re.compile(r'https?://[^\s\)\]"<>]+')
+H2_RE = re.compile(r'^##\s+(.+?)\s*$', re.M)
+
+# 每篇文档应有的结构章节。用来做结构校验——缺章节是内容质量下降的最早信号。
+REQUIRED_SECTIONS = ["概述", "最新进展", "核心技术与关键概念", "参考来源"]
+
+STOPWORDS = {
+ "the", "a", "an", "of", "to", "and", "or", "in", "on", "for", "with", "is", "are",
+ "as", "by", "at", "from", "that", "this", "it", "be", "was", "were", "which", "how",
+ "why", "what", "when", "has", "have", "had", "not", "but", "can", "will", "more",
+ "的", "了", "是", "在", "和", "与", "及", "或", "对", "从", "到", "为", "以", "等",
+ "这", "那", "一个", "一种", "我们", "他们", "可以", "以及", "通过", "进行", "基于",
+ "使用", "提供", "实现", "包括", "比如", "例如", "其中", "目前", "已经", "成为",
+}
+
+
+# ---------- 解析 ----------
+
+def doc_meta(md: Path) -> dict:
+ """从单篇 Markdown 抽出结构化元数据。"""
+ text = md.read_text(encoding="utf-8")
+ rel = md.relative_to(ROOT).as_posix()
+ slug = md.parent.name
+ tm = TITLE_RE.search(text)
+ title = tm.group(1).strip() if tm else md.stem
+ um = UPDATED_RE.search(text)
+ fm = FIELD_RE.search(text)
+ secs = [s.strip() for s in H2_RE.findall(text)]
+ return {
+ "title": title,
+ "path": rel,
+ "file": md,
+ "stem": md.stem,
+ "domain_slug": slug,
+ "domain": DOMAIN_LABEL.get(slug, slug),
+ "updated": um.group(1) if um else "",
+ "field": fm.group(1).strip() if fm else "",
+ "sections": secs,
+ "text": text,
+ }
+
+
+def load_docs() -> list[dict]:
+ """按领域顺序加载全部文档。"""
+ items: list[dict] = []
+ for slug, _label in DOMAINS:
+ d = DOCS / slug
+ if not d.is_dir():
+ continue
+ for md in sorted(d.rglob("*.md")):
+ items.append(doc_meta(md))
+ items.sort(key=lambda x: (DOMAIN_ORDER.get(x["domain_slug"], 99), x["title"]))
+ return items
+
+
+def body_of(text: str) -> str:
+ """去掉标题行与元信息引用行,只留正文(用于分词/相似度)。"""
+ lines = []
+ for line in text.splitlines():
+ if line.startswith("#"):
+ continue
+ if line.startswith(">") and "最后更新" in line:
+ continue
+ lines.append(line)
+ return "\n".join(lines)
+
+
+def body_no_refs(text: str) -> str:
+ """正文去掉「参考来源」章节。引用的 URL 里全是域名和标题碎片,
+ 拿它们当标签/主题信号只会污染结果。"""
+ return text.split("## 参考来源")[0] if "## 参考来源" in text else text
+
+
+def extract_refs(text: str) -> list[str]:
+ """参考来源章节里的链接(去重、保序)。"""
+ sec = text.split("## 参考来源")[-1] if "## 参考来源" in text else ""
+ seen, out = set(), []
+ for u in URL_RE.findall(sec):
+ u = u.rstrip('.,;:')
+ if u not in seen:
+ seen.add(u)
+ out.append(u)
+ return out
+
+
+# ---------- 分词 ----------
+
+_CJK = r'\u4e00-\u9fff'
+_TOKEN_RE = re.compile(rf'[{_CJK}]+|[A-Za-z][A-Za-z0-9+#._-]*|\d+')
+
+
+def tokenize(text: str) -> list[str]:
+ """中英混合分词(零依赖)。
+
+ - 英文/数字:按词切,统一小写,去掉 stopword
+ - 中文:单字 + 相邻二字组合(bigram)。没有分词器时这是
+ 「召回」与「体积」之间最稳的折中:bigram 能表达「向量检索」
+ 这种复合概念,又不至于像 trigram 那样把索引撑爆。
+ """
+ text = text.lower()
+ out: list[str] = []
+ for m in _TOKEN_RE.finditer(text):
+ tok = m.group(0)
+ if re.match(rf'^[{_CJK}]+$', tok):
+ if len(tok) == 1:
+ out.append(tok)
+ else:
+ out.extend(tok[i:i + 2] for i in range(len(tok) - 1))
+ elif tok.isdigit():
+ out.append(tok)
+ else:
+ if len(tok) >= 2 and tok not in STOPWORDS:
+ out.append(tok)
+ return out
+
+
+def terms_from_query(q: str) -> list[str]:
+ """查询串 -> 检索词。与 tokenize 同规则,保证索引/查询一致。"""
+ return tokenize(q)
+
+
+# ---------- 倒排索引 ----------
+
+def build_index(docs: list[dict], min_df: int = 2, max_df_ratio: float = 0.6) -> dict:
+ """构建倒排索引 + 文档向量(用于打分与相似度)。
+
+ 结构:
+ postings: {term: {doc_id: tf}}
+ df: {term: 文档频率}
+ dl: [每篇长度]
+ idf: {term: 逆文档频率}
+
+ 两头都做裁剪:
+ - df < min_df:只在 1 篇里出现的词。中文 bigram 会产生海量这种词
+ (52K 个,占索引 2/3),而它们对多词查询毫无贡献(只命中一篇,
+ 排序也进不了前排)。实测 min_df=1 -> 2 检索质量零变化,体积腰斩。
+ - df > 总数 * max_df_ratio:出现频率过高的词(如「模型」在 AI
+ 领域几乎每篇都有)区分度接近 0,留着只会撑大索引。
+ """
+ postings: dict[str, dict[int, int]] = {}
+ dl: list[int] = []
+ for i, d in enumerate(docs):
+ toks = tokenize(body_of(d["text"]))
+ dl.append(len(toks))
+ tf: dict[str, int] = {}
+ for t in toks:
+ tf[t] = tf.get(t, 0) + 1
+ for t, c in tf.items():
+ postings.setdefault(t, {})[i] = c
+
+ n = len(docs) or 1
+ df = {t: len(p) for t, p in postings.items()}
+ idf = {
+ t: math.log(1 + (n - d + 0.5) / (d + 0.5))
+ for t, d in df.items()
+ }
+ # 过滤超高频词(对体积贡献最大,对检索贡献最小)
+ hi = max(min_df, 2)
+ keep = {t for t, d in df.items() if hi <= d <= max(hi, n * max_df_ratio)}
+ postings = {t: p for t, p in postings.items() if t in keep}
+ idf = {t: v for t, v in idf.items() if t in keep}
+
+ # 文档向量(tf-idf 归一化),用于余弦相似度
+ vecs: list[dict[str, float]] = [{} for _ in docs]
+ for t, p in postings.items():
+ for i, c in p.items():
+ vecs[i][t] = (1 + math.log(c)) * idf[t]
+ for v in vecs:
+ norm = math.sqrt(sum(x * x for x in v.values())) or 1.0
+ for k in v:
+ v[k] /= norm
+
+ return {"postings": postings, "df": df, "dl": dl, "idf": idf,
+ "vecs": vecs, "n": len(docs)}
+
+
+def search(index: dict, docs: list[dict], query: str, limit: int = 40) -> list[tuple[int, float]]:
+ """BM25 检索。返回 [(doc_id, score)],降序。
+
+ 标题命中额外加权——搜「围棋」时,标题里就写着「围棋」的文档
+ 比正文顺带提一句的更该排前面。
+ """
+ terms = terms_from_query(query)
+ if not terms:
+ return []
+ qtf: dict[str, int] = {}
+ for t in terms:
+ qtf[t] = qtf.get(t, 0) + 1
+
+ scores: dict[int, float] = {}
+ postings = index["postings"]
+ idf = index["idf"]
+ dl = index["dl"]
+ avgdl = (sum(dl) / len(dl)) if dl else 1.0
+ k1, b = 1.5, 0.75
+
+ for t, qc in qtf.items():
+ p = postings.get(t)
+ if not p:
+ continue
+ w = idf.get(t, 0.0) * (1 + math.log(qc))
+ for i, tf in p.items():
+ dl_i = dl[i] if i < len(dl) else avgdl
+ denom = tf + k1 * (1 - b + b * (dl_i / avgdl if avgdl else 1))
+ scores[i] = scores.get(i, 0.0) + w * (tf * (k1 + 1)) / (denom or 1)
+
+ # 标题加权
+ for i in scores:
+ tl = docs[i]["title"].lower()
+ for t in qtf:
+ if t in tl:
+ scores[i] += 3.0 * idf.get(t, 0.0)
+
+ return sorted(scores.items(), key=lambda kv: -kv[1])[:limit]
+
+
+def related(docs: list[dict], index: dict, i: int, k: int = 4) -> list[tuple[int, float]]:
+ """与第 i 篇最相似的 k 篇(余弦相似度)。
+
+ 用文档向量做两两比较。163 篇的距离是 163² ≈ 2.6 万次点积,
+ 构建期跑一次完全无压力,换来每篇页面上的「相关文档」。
+ """
+ vi = index["vecs"][i]
+ out = []
+ for j, vj in enumerate(index["vecs"]):
+ if j == i:
+ continue
+ if docs[j]["domain_slug"] != docs[i]["domain_slug"]:
+ continue # 只在同领域内推荐:跨领域相似度天然偏低,噪声大
+ s = sum(w * vj.get(t, 0.0) for t, w in vi.items())
+ if s > 0:
+ out.append((j, s))
+ out.sort(key=lambda kv: -kv[1])
+ if len(out) < k: # 同领域不够就看全库,保证每篇都有推荐
+ seen = {j for j, _ in out}
+ for j, vj in enumerate(index["vecs"]):
+ if j == i or j in seen:
+ continue
+ s = sum(w * vj.get(t, 0.0) for t, w in vi.items())
+ if s > 0:
+ out.append((j, s))
+ out.sort(key=lambda kv: -kv[1])
+ return out[:k]
+
+
+# 标签里的「词」应当是有意义的术语,而不是域名/文件路径/年份这类东西。
+# 这些词在任何技术文档里都高频出现,当标签没有区分度
+_LOW_VALUE = {
+ "papers", "paper", "howto", "general", "national", "artificial", "images",
+ "image", "data", "model", "models", "tools", "tool", "guide", "overview",
+ "introduction", "report", "reports", "news", "blog", "article", "index",
+ "html", "pdf", "http", "https",
+}
+
+_BAD_TAG = re.compile(
+ r'(^\d+$)'
+ r'|(^https?$)'
+ r'|(^www$)'
+ r'|(\.(com|org|net|io|ai|cn|co|dev|gov|edu|pdf|html)$)'
+ r'|(^[a-z]{1,2}$)' # 单双字母(da / ai 之外的碎片)
+ r'|(^\d{4}$)' # 纯年份
+)
+
+
+def tags_for(doc: dict, index: dict, docs: list[dict], top: int = 8) -> list[str]:
+ """从正文里提取「标签」(术语)。
+
+ 刻意不用 NLP 抽关键词——那需要模型,会破坏零依赖。这里用最朴素但
+ 可解释的做法:取该文档 tf-idf 最高的英文术语 + 标题里的中文概念词。
+
+ 关键在过滤:tf-idf 高分里混着大量域名(openaccess.thecvf.com)、
+ 年份(cvpr2026)、单字母碎片(da)——它们只是「本文出现过且别处不常见」,
+ 当标签毫无意义。所以要求:纯字母数字/连字符、长度 >= 3、不是域名、
+ 不是纯数字,且**至少出现在正文 3 次**(偶发提及不算主题)。
+ """
+ from collections import Counter
+ v = index["vecs"][docs.index(doc)]
+ # 统计英文词频(只在正文里数,标题另算)
+ freq = Counter(t for t in tokenize(body_of(body_no_refs(doc["text"])))
+ if re.fullmatch(r'[a-z][a-z0-9+#._-]{2,}', t))
+ ranked = sorted(v.items(), key=lambda kv: -kv[1])
+ out: list[str] = []
+ seen_stems: set[str] = set()
+ for term, _w in ranked:
+ if len(out) >= top:
+ break
+ if not re.fullmatch(r'[a-z][a-z0-9+#._-]{2,}', term):
+ continue
+ if _BAD_TAG.search(term):
+ continue
+ if ('thecvf' in term) or ('arxiv' == term) or ('openaccess' in term):
+ continue
+ if term.endswith(('.com', '.org', '.net', '.io', '.cn', '.dev', '.pdf')):
+ continue
+ if freq.get(term, 0) < 3:
+ continue
+ # URL slug 特征:超长、含多个连字符、或带点号(域名)
+ if len(term) > 18 or term.count('-') >= 2 or '.' in term:
+ continue
+ stem = term[:-1] if term.endswith('s') and len(term) > 4 else term
+ if stem in seen_stems or term in _LOW_VALUE:
+ continue
+ if term not in out:
+ out.append(term)
+ seen_stems.add(stem)
+ # 标题里的中文词组(按分隔符切)
+ for part in re.split(r'[(())、,,·—\-–/||]+', doc["title"]):
+ part = part.strip()
+ if 2 <= len(part) <= 12 and re.search(rf'[{_CJK}]', part) and part not in out:
+ out.append(part)
+ if len(out) >= top:
+ break
+ return out[:top]
+
+
+def reading_minutes(text: str) -> int:
+ """粗略阅读时长:中文按 350 字/分,英文按 200 词/分。"""
+ n = len(re.findall(rf'[{_CJK}]', text))
+ m = len(re.findall(r'\b[A-Za-z]{2,}\b', text))
+ return max(1, round(n / 350 + m / 200))