diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1707207..7a3f23b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,13 +33,10 @@ jobs: # 而不是等合并后 Pages 发布挂掉。 - name: Build site run: python3 scripts/build_site.py - - name: Assert output - run: | - test -f site/index.html - test -f site/search.json - pages=$(find site -name '*.html' | wc -l) - echo "生成 HTML 页面数: $pages" - test "$pages" -gt 160 + # 站点自检:站内引用、canonical/sitemap 一致性、检索链路完整性。 + # 这一步专门用来接住「构建成功但页面/搜索坏掉」这类静默故障。 + - name: Check site output + run: python3 scripts/check_site.py links: name: 外链可达性检查 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 8727ad2..62ca9d4 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -8,6 +8,7 @@ on: paths: - 'docs/**' - 'scripts/build_site.py' + - 'scripts/check_site.py' - '.github/workflows/pages.yml' workflow_dispatch: @@ -31,6 +32,9 @@ jobs: python-version: '3.12' - name: Build static site run: python3 scripts/build_site.py + # 发布前先自检:宁可不上线,也不要让「搜索坏了 / 导航 404」的产物出去 + - name: Check site output + 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..e9535e2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,19 @@ > 站点由 `scripts/build_site.py` 从 `docs/` 渲染生成(零依赖、纯静态), > 合并进 `main` 后自动发布。`docs/` 的 Markdown 始终是唯一真相源。 +## 检索是怎么做的 + +首屏不加载任何索引。第一次在搜索框里输入时,才去拉 +`search-index.json`(文档元数据 + 分片清单)和 8 个倒排分片。 + +- 索引是**倒排**的:词 -> 文档 id 列表,按词哈希分成 8 片 +- 中文用 2~4 字滑窗切词,拉丁词整体保留;只出现一次的 n-gram 不入索引 +- 合计约 845 KB(gzip 后约 300 KB),比早期「把每篇正文前 6000 字塞进单个 JSON」 + 的方案(gzip 后约 600 KB、且每次按键都要全量子串扫描)更小也更快 + +改动检索链路时请同时跑 `scripts/check_site.py`——它会校验索引声明的每个分片 +都存在、索引里的每个 url 都能落到真实页面。 + ## 目录 ### 一、人工智能(AI)(46 篇) diff --git a/scripts/build_site.py b/scripts/build_site.py index 87c08e1..144dbb8 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -21,6 +21,10 @@ DOCS = ROOT / "docs" OUT = ROOT / "site" +# 站点对外地址:canonical / og:url / sitemap / feed 都从这一处生成。 +# 换域名只改这一行。 +SITE_URL = "https://ice-wocker.github.io/frontier-knowledge-base" + DOMAINS = [ ("ai", "人工智能(AI)"), ("hardware", "硬件与半导体"), @@ -238,77 +242,322 @@ def load_docs(): .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}} +.vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} +.skip{position:absolute;left:-9999px;top:0;z-index:40;background:var(--panel); +border:1px solid var(--accent);border-radius:0 0 9px 0;padding:9px 14px} +.skip:focus{left:0} +:focus-visible{outline:2px solid var(--accent);outline-offset:2px} +nav.domains{overflow-x:auto;white-space:nowrap;scrollbar-width:none; +-webkit-overflow-scrolling:touch} +nav.domains::-webkit-scrollbar{display:none} +nav.domains .wrap{display:flex;gap:18px} +nav.domains a{padding:2px 0} +html{-webkit-text-size-adjust:100%;text-size-adjust:100%} +@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}} +@media(max-width:600px){h1{font-size:1.4rem}.wrap{padding:0 16px} +header.top .wrap{padding:10px 16px}nav.domains a{font-size:.9rem}} +@media print{header.top,nav.domains,footer,.searchbox{display:none}} """ -SEARCH_JS = """ +SEARCH_JS = r""" (function(){ + // 检索流程:查询词 -> 需要的分片 -> 倒排取候选 -> 打分排序 -> 高亮。 + // 整个索引 8 片、gzip 后共约 250 KB,且**不在首屏加载**: + // 用户第一次敲字才拉,拉完记住。首屏因此只有一个几 KB 的 HTML。 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;}); - function render(kw){ - if(!data) 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; - } - if(ok) hits.push([score,d]); + var INDEX=null, shards={}, inflight={}, timer=null, ready=null; + + function esc(s){return String(s).replace(/[&<>]/g,function(c){return {'&':'&','<':'<','>':'>'}[c]})} + + // 与构建端 _terms 保持一致:拉丁词 + 中文 2~4 字滑窗 + function termsOf(text){ + text=text.toLowerCase(); var out=[], re=/[0-9a-z][0-9a-z+#._-]{1,23}|[\u4e00-\u9fff]{2,6}/g, m; + while((m=re.exec(text))){ + var tok=m[0]; + if(tok.charCodeAt(0)<0x4e00){ out.push(tok); continue; } + for(var n=2;n<=4;n++) + for(var i=0;i+n<=tok.length;i++) out.push(tok.substr(i,n)); } + return out; + } + + function loadShard(name){ + if(shards[name]) return Promise.resolve(shards[name]); + if(inflight[name]) return inflight[name]; + inflight[name]=fetch('search/'+name+'.json') + .then(function(r){ if(!r.ok) throw new Error(r.status); return r.json() }) + .then(function(j){ shards[name]=j; return j }) + .catch(function(){ shards[name]={}; return {} }); + return inflight[name]; + } + + function ensureReady(){ + if(ready) return ready; + ready=fetch('search-index.json').then(function(r){ + if(!r.ok) throw new Error(r.status); + return r.json(); + }).then(function(j){ + INDEX=j; + return Promise.all(j.buckets.map(loadShard)); + }).then(function(){ return true; }); + return ready; + } + + function show(msg){ out.innerHTML='

'+esc(msg)+'

' } + + function render(kw){ + kw=(kw||'').trim().toLowerCase(); + if(!kw){ out.innerHTML=''; return; } + if(!INDEX){ show('正在载入检索索引…'); return; } + var phrases=kw.split(/\s+/).filter(Boolean), terms=[]; + phrases.forEach(function(p){ terms=terms.concat(termsOf(p)) }); + if(!terms.length){ show('没有匹配的文档。'); return; } + + var scores={}, cover={}; + INDEX.buckets.forEach(function(name){ + var sh=shards[name]; if(!sh) return; + terms.forEach(function(t){ + var ids=sh[t]; if(!ids) return; + for(var i=0;i=0) s+=2.5; + else if(dl.indexOf(p)>=0) s+=0.6; + }); + hits.push([s,d]); + }); hits.sort(function(a,b){return b[0]-a[0]}); hits=hits.slice(0,40); - if(!hits.length){out.innerHTML='

没有匹配的文档。

';return;} + if(!hits.length){ show('没有匹配的文档。试试更短的关键词,比如「智能体」「向量」。'); return; } out.innerHTML=hits.map(function(h){ - var d=h[1], s=d.summary||''; - for(var t=0;t$1'); - } - return '
'+d.title+'
'+d.domain+(d.updated?' · '+d.updated:'')+'
'+s+'
'; + var d=h[1], txt=d.summary||''; + phrases.forEach(function(p){ + var pat=p.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); + txt=txt.replace(new RegExp('('+pat+')','ig'),'$1'); + }); + return '
'+esc(d.title)+'
'+ + '
'+esc(d.domain)+(d.updated?' · '+d.updated:'')+'
'+ + '
'+txt+'
'; }).join(''); } + + function kick(){ + ensureReady().then(function(){ render(box.value) }) + .catch(function(){ show('检索索引载入失败,请刷新重试。') }); + } + + // 首次输入才载入;聚焦时也预热一下,让第一次按键就有结果 + box.addEventListener('focus',kick,{once:true}); box.addEventListener('input',function(){ - clearTimeout(timer); timer=setTimeout(function(){render(box.value)},120); + clearTimeout(timer); + timer=setTimeout(function(){ kick() },80); + }); + box.addEventListener('keydown',function(e){ + if(e.key==='Escape'){box.value='';out.innerHTML='';return} + if(e.key==='Enter'){var f=out.querySelector('a.hit'); if(f){e.preventDefault(); f.click();}} }); })(); """ -def page(title, body, depth, active_slug=None, search=False): + + +# ---------- 检索用的极简分词 ---------- + +_TERM_RE = re.compile(r'[0-9a-z][0-9a-z+#._-]{1,23}|[\u4e00-\u9fff]{2,6}') + + +def _terms(text, min_gram=3): + """把文本切成检索词。 + + 中文没有空格,用滑窗切 n-gram:3-gram 已经能给出足够的区分度, + 2-gram 只在标题/领域里保留(正文全切 2-gram 会让词表翻倍, + 而「工程」「模型」这类词几乎不分文档)。 + """ + text = text.lower() + out = [] + for m in _TERM_RE.finditer(text): + tok = m.group(0) + if not ('\u4e00' <= tok[0] <= '\u9fff'): + out.append(tok) + continue + for n in range(min_gram, 5): + for i in range(len(tok) - n + 1): + out.append(tok[i:i + n]) + return out + + +SHARD_BUCKETS = 8 + +# 分片里只保留「出现 >= 2 次」的词:只出现一次的 n-gram 对检索没有贡献, +# 却占了索引里的大头(中文滑窗产生的生僻组合基本都是这一档)。 +MIN_DF = 2 + + +def _bucket(term): + """稳定的分片号。不能用内置 hash()——它每个进程都加盐,构建和校验会不一致。""" + import hashlib + return int(hashlib.md5(term.encode("utf-8")).hexdigest()[:8], 16) % SHARD_BUCKETS + + +def _bucket_name(n): + return f"b{n}" + + +# ---------- 图标与分享图(零依赖) ---------- + +FAVICON = """ + + + + + + + +""" + + +def _png_chunk(tag, data): + import struct, zlib + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + + +def _make_icon(size=180): + """手写一张 180×180 的 PNG(深底 + 三条横线 + 中心点),不引 Pillow。""" + import math, struct, zlib + bg, line, dot = (0x0b, 0x0f, 0x14), (0x7e, 0xe7, 0x87), (0x5a, 0xd1, 0xff) + rows = [] + for y in range(size): + row = bytearray() + for x in range(size): + px = bg + for ly in (0.31, 0.5, 0.69): + if abs(y + .5 - size * ly) < size * 0.022 and size * .28 < x < size * .72: + px = line + if (x - size / 2) ** 2 + (y - size / 2) ** 2 < (size * 0.06) ** 2: + px = dot + row += bytes(px) + rows.append(bytes(row)) + raw = b"".join(b"\x00" + r for r in rows) + return (b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)) + + _png_chunk(b"IDAT", zlib.compress(raw, 9)) + + _png_chunk(b"IEND", b"")) + + +APPLE_ICON = _make_icon() + +OG_IMAGE = """ + +前沿科技知识库 +163 篇专题 · 9 个领域 · 关键事实逐条附来源 +AI · 硬件半导体 · 软件工程 · 数据 · 安全 · 基础科学 · 新兴科技 · 产业 · 社会 + + +""" + + +def _sibling_href(current, other): + """同领域用文件名,跨领域要走 ..//.html。 + + 此前跨领域的上一篇/下一篇只写了文件名,结果 16 条导航全是 404—— + 这个错误在构建阶段完全看不出来,只有真的点一下才会发现。 + """ + name = other["file"].stem + ".html" + if other["domain_slug"] == current["domain_slug"]: + return name + return f'../{other["domain_slug"]}/{name}' + + +def page(title, body, depth, active_slug=None, search=False, desc=None, path=None, + doc=None, prev_doc=None, next_doc=None, n_docs=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'' + n_docs = n_docs if n_docs is not None else len(list(DOCS.rglob('*.md'))) + depth_script = f'' if search else '' + + desc = desc or f"{title} · 前沿科技知识库" + url = f"{SITE_URL}/{path}" if path else SITE_URL + "/" + # 文档页补上「上下篇」和结构化数据:一篇文章被单独搜到时, + # 搜索引擎需要知道它属于哪个站点、什么时间更新。 + prevnext = '' + if doc and (prev_doc or next_doc): + links = [] + if prev_doc: + links.append(f'') + else: + links.append('') + if next_doc: + links.append(f'') + prevnext = '' + ldjson = '' + if doc: + ldjson = ('') return f""" {html.escape(title)} - + + + + + + + + + + + + + + +{ldjson} -
🧊 前沿科技知识库
-
-
+ +
+ +
{body} +{prevnext}