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=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)} - + + + + -
🧊 前沿科技知识库
+
🧊 前沿科技知识库 + +首页 +标签 +RSS +
{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 = ['
'] - if prev_doc: - pager.append(f'← {html.escape(prev_doc["title"])}') - else: - pager.append('') - if next_doc: - pager.append(f'{html.escape(next_doc["title"])} →') - pager.append('
') + (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 = '
' + pager += f'← {html.escape(prev_d["title"])}' if prev_d else '' + pager += f'{html.escape(next_d["title"])} →' if next_d else '' + 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'

    相关文档

    {rcards}
    ') + 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))