网页 → AI 知识库 / Web → AI Knowledge Base
你现在运行 web-to-knowledge-base 技能。目标:把一个域名、一份 sitemap 或一组 URL 一键变成一份带索引、可全文搜索、可直接喂 agent 上下文 / 做 RAG / 写专题的 Markdown 知识库。
Turn a domain, a sitemap, or a list of URLs into an indexed, full-text-searchable Markdown knowledge base — ready to feed an agent's context, build a RAG, or write topic posts.
核心调用走 Clawvard 一方服务 SDK —— 浏览器渲染、抓取、LLM-friendly 抽取都在服务端跑(底层是 Apache-2.0 的 Crawl4AI,在我们的 Playwright/Chromium worker 上)。用户不用本地装 Python、Playwright、headless Chromium,也不用 clone 任何私有仓库——装一次 Clawvard SDK + 一个 Clawvard API key 就够了(命令见课程页右侧 install 行)。
你会拿到 / What you get
kb/articles/<slug>.md—— 每个网页一篇 Markdown,文件名是 URL slug;YAML frontmatter 含title/source_url/fetched_at/word_count/canonical。kb/index.json—— 全量元数据(按word_count排序,便于切 chunk)。kb/index.html—— 自包含静态浏览页:左侧目录 + 中间正文 + 顶栏关键词搜索,浏览器双击直接看。- 终端打印的产出目录树 + 总字数 + credits 花费摘要。
0. 前置 / Install
node --version # 需要 Node ≥ 18
npm install @clawvard/sdk@latest
export CLAW_API_KEY=sk-… # 问用户拿;没有 key 去 https://clawvard.school 拿、去 https://clawvard.school/billing 充值
不需要 Python、不需要 Playwright、不需要 Chromium、不需要 clone 任何仓库。
1. 抓取:一次最多 50 个 URL
cv.web.crawl 是 job 型(服务端要起 Chromium、做 LLM-friendly 抽取),需要 .wait():
import { Clawvard } from "@clawvard/sdk";
const cv = new Clawvard({ apiKey: process.env.CLAW_API_KEY! });
const urls = [
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Introduction",
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions",
// …最多 50 个
];
const { pages, summary } = await cv.web.crawl({
urls,
mode: "markdown",
// 可选:默认即可
obeyRobots: true, // 默认 true;非授权域强烈建议保持开启
includeMetadata: true, // 默认 true;title / description / canonical / wordCount
timeoutPerPageSec: 30, // 默认 30,最大 60
}).wait();
console.log(`${summary.succeeded}/${summary.requested} OK · 花费 ${summary.creditsSpent} cr`);
单次调用 ≤ 50 URLs。要抓更大批,分多次调用、把结果累加进同一个
kb/。不要自己加并发——服务端已经在跑 Chromium pool;客户端再叠并发只会触发租户限速。
2. 处理失败:不要让一个坏 URL 毁掉整批
cv.web.crawl 永远返回所有页面,失败的那条把错误塞进 page.error、statusCode 写实际状态:
| 场景 | statusCode |
error |
|---|---|---|
| 抓取成功 | 200 | null |
| 页面 404 | 404 | "not_found" |
| robots.txt 拒绝 | 451 | "blocked_by_robots" |
| 单页渲染超时 | 504 | "timeout" |
| 其他 HTTP 错 | 4xx/5xx | "http_<code>" |
| 网络问题 | 0 | "network: …" |
每条成功页面才计费(summary.creditsSpent = 成功数 × 单价),失败页不扣 credit。
聚合处理时只写成功的:
const ok = pages.filter(p => p.statusCode === 200 && !p.error);
const failed = pages.filter(p => p.error);
if (failed.length) console.warn(failed.map(p => `❌ ${p.url} — ${p.error}`).join("\n"));
3. 落盘:每页一个 .md + frontmatter + index
下面这段纯 Node,零依赖,把 pages[] 排成本地知识库目录:
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
const OUT = "kb";
await mkdir(join(OUT, "articles"), { recursive: true });
const index = [];
for (const p of ok) {
const slug = (new URL(p.url).pathname.replace(/\/+/g, "-").replace(/^-|-$/g, "") || "index")
.replace(/[^a-z0-9\-_.]/gi, "_")
.slice(0, 120);
const filename = `${slug}.md`;
const frontmatter = [
"---",
`title: ${JSON.stringify(p.title ?? slug)}`,
`source_url: ${JSON.stringify(p.url)}`,
`canonical: ${JSON.stringify(p.metadata?.canonical ?? p.finalUrl)}`,
`fetched_at: ${p.metadata?.fetchedAt ?? new Date().toISOString()}`,
`word_count: ${p.metadata?.wordCount ?? 0}`,
"---",
"",
].join("\n");
await writeFile(join(OUT, "articles", filename), frontmatter + p.markdown);
index.push({
file: `articles/${filename}`,
title: p.title ?? slug,
source_url: p.url,
word_count: p.metadata?.wordCount ?? 0,
fetched_at: p.metadata?.fetchedAt,
source_domain: p.metadata?.sourceDomain,
});
}
// 按 word_count 排序:长文优先,便于切 chunk
index.sort((a, b) => (b.word_count ?? 0) - (a.word_count ?? 0));
await writeFile(join(OUT, "index.json"), JSON.stringify(index, null, 2));
4. 浏览页:自包含的 kb/index.html
写一个单文件的静态 reader——左侧目录、中间正文、顶栏全文搜索、底部来源 attribution。直接把上一步的 index + 每篇 .md 内联进去(不依赖任何外部 JS/CSS):
import { readFile } from "node:fs/promises";
const articles = await Promise.all(index.map(async (a) => ({
...a,
body: await readFile(join(OUT, a.file), "utf-8"),
})));
const html = `<!doctype html><html lang="en"><meta charset="utf-8">
<title>Knowledge Base</title>
<style>
body{font:15px/1.55 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;color:#1c1917;background:#fafaf9}
header{position:sticky;top:0;padding:12px 20px;background:#fff;border-bottom:1px solid #e7e5e4;display:flex;gap:12px;align-items:center;z-index:2}
header input{flex:1;padding:8px 12px;border:1px solid #d6d3d1;border-radius:8px;font:14px system-ui}
main{display:grid;grid-template-columns:280px 1fr;min-height:calc(100vh - 53px)}
aside{border-right:1px solid #e7e5e4;padding:16px;overflow:auto;max-height:calc(100vh - 53px)}
aside a{display:block;padding:8px 10px;border-radius:6px;color:#1c1917;text-decoration:none;font-size:13px;margin-bottom:2px}
aside a.active{background:#1c1917;color:#fafaf9}
aside a .hit{float:right;font-size:11px;opacity:.7}
article{padding:32px 48px;max-width:780px}
article h1,article h2{font-weight:700}
article pre{background:#f5f5f4;padding:12px;border-radius:6px;overflow:auto}
article code{background:#f5f5f4;padding:1px 4px;border-radius:4px}
footer{padding:24px 48px;font-size:12px;color:#78716c;border-top:1px solid #e7e5e4}
mark{background:#fef3c7;padding:0 2px;border-radius:2px}
</style>
<header>
<strong>Knowledge Base</strong>
<input id="q" placeholder="Search title + body…">
<span id="count" style="color:#78716c;font-size:13px"></span>
</header>
<main>
<aside id="toc"></aside>
<article id="body">Pick an article on the left.</article>
</main>
<footer>Built with <code>cv.web.crawl</code> · <a href="https://clawvard.school">clawvard.school</a></footer>
<script>
const ARTICLES = ${JSON.stringify(articles)};
// minimal markdown → html: headings, lists, code, links, paragraphs
function mdToHtml(md){
const esc = s=>s.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
md = md.replace(/^---[\\s\\S]*?---/,'').trim();
const lines = md.split(/\\n/); let out=[]; let inCode=false, code=[];
for(const ln of lines){
if(ln.startsWith('\`\`\`')){ if(inCode){ out.push('<pre>'+esc(code.join('\\n'))+'</pre>'); code=[]; inCode=false; } else { inCode=true; } continue; }
if(inCode){ code.push(ln); continue; }
let m;
if((m=ln.match(/^(#{1,6})\\s+(.+)$/))) out.push('<h'+m[1].length+'>'+inline(m[2])+'</h'+m[1].length+'>');
else if(/^[-*]\\s+/.test(ln)) out.push('<li>'+inline(ln.replace(/^[-*]\\s+/,''))+'</li>');
else if(!ln.trim()) out.push('');
else out.push('<p>'+inline(ln)+'</p>');
}
return out.join('\\n').replace(/(<li>[\\s\\S]+?<\\/li>)/g,'<ul>$1</ul>').replace(/<\\/ul>\\s*<ul>/g,'');
function inline(s){ s = esc(s);
s = s.replace(/\`([^\`]+)\`/g,'<code>$1</code>');
s = s.replace(/\\*\\*([^*]+)\\*\\*/g,'<strong>$1</strong>');
s = s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
return s;
}
}
function escAttr(s){return s.replace(/"/g,'"')}
let current=null;
function render(idx, query){
const toc=document.getElementById('toc'); toc.innerHTML='';
ARTICLES.forEach((a,i)=>{
const hits = query ? countHits(a, query) : 0;
const link=document.createElement('a'); link.href='#'; link.textContent=a.title;
if(query && hits) link.innerHTML += ' <span class="hit">'+hits+'</span>';
if(i===idx) link.classList.add('active');
link.onclick=e=>{e.preventDefault(); render(i, query)};
toc.appendChild(link);
});
const a = ARTICLES[idx]; current=idx;
let html = mdToHtml(a.body);
if(query) html = highlight(html, query);
document.getElementById('body').innerHTML = '<h1>'+a.title+'</h1><p style="color:#78716c;font-size:13px"><a href="'+a.source_url+'" target="_blank" rel="noopener">'+a.source_url+'</a></p>'+html;
}
function countHits(a, q){ const re=new RegExp(q.replace(/[.*+?^\${}()|[\\]\\\\]/g,'\\\\$&'),'gi'); return ((a.title+' '+a.body).match(re)||[]).length; }
function highlight(html, q){ const re=new RegExp('('+q.replace(/[.*+?^\${}()|[\\]\\\\]/g,'\\\\$&')+')','gi'); return html.replace(/>([^<]+)</g,(_,t)=>'>'+t.replace(re,'<mark>$1</mark>')+'<'); }
document.getElementById('q').addEventListener('input',e=>{
const q = e.target.value.trim();
document.getElementById('count').textContent = q ? ARTICLES.filter(a=>countHits(a,q)>0).length+' / '+ARTICLES.length+' match' : '';
render(current ?? 0, q);
});
render(0, '');
</script></html>`;
await writeFile(join(OUT, "index.html"), html);
打印产出树和总字数:
const total = ok.reduce((n, p) => n + (p.metadata?.wordCount ?? 0), 0);
console.log(`✅ ${OUT}/articles/*.md (${ok.length} files) + index.json + index.html · ${total.toLocaleString()} words`);
5. 取 URL 的几条路径
- 用户直接贴清单:最快、最稳。
- sitemap.xml:
fetch("https://example.com/sitemap.xml")→ 提取<loc>→ 取前 50 个 → 调一次cv.web.crawl。 - 从一个入口爬同站链接:先
cv.web.crawl({ urls: [seed], mode: "markdown" })拿到 markdown,再用一个简单正则提取https://<同域>/...链接,去重、限 50,二轮调一次。不要做无限深度递归——容易超出本课的语义范围(这是「按 URL 列表抓」,不是「全站镜像」)。
6. 计费与限制
- 单价:约 2 credits / 成功页(先调
cv.platform.pricing({ serviceIds: ["web.crawl"] })读实时价,别硬编码)。 - 失败页 0 credit:404 / robots / timeout 在
pages[]里返回错误,不计费。 - 单次 ≤ 50 URL:批量更多就分多次调,把结果累加进同一个
kb/。 - timeout:默认每页 30s,最长 60s。超时页面
statusCode: 504, error: "timeout"。 - 速率:单租户 60 calls/min,1000 calls/hour(被限会抛
RATE_LIMITED,SDK 自动 backoff 重试)。
7. 调试 tips
- 401 → 检查 Clawvard API key 是否注入到进程环境。
- 402 / INSUFFICIENT_CREDITS → 余额不足,去 https://clawvard.school/billing 充值。
- 某页 statusCode: 451 → robots.txt 拒绝;尊重它,换个公开可抓的源(教程类强烈推荐 MDN、官方 docs 站、CC-licensed wiki)。
- 某页 statusCode: 504 → 单页超时,把
timeoutPerPageSec调到 45–60s 重试该页;或者跳过它。 - markdown 抓回来看着是导航碎片 → 多半是首屏需要 JS 渲染的 SPA;服务端 Chromium 一般能处理,但极个别站点要等更久。重试或换 URL。
- 想把抓到的内容直接喂 agent:把
kb/articles/*.md整体拼接(按index.json顺序)作为系统/上下文消息;超长就按word_count优先选前 N 篇或做向量化。
铁律
- 抓取 + LLM-friendly 抽取一律走 Clawvard SDK 的
cv.web.crawl(Clawvard API key 鉴权)。不要让用户本地装 Crawl4AI / Playwright / Chromium,也不要绕 SDK 直连任何 OpenAI-compatible /远端中转端点。 - 默认
obeyRobots: true。商业抓取行为请用户自负其责,本服务不绕 robots。 - 用 SDK 返回的
summary.creditsSpent给用户结账;不要硬编码价格。
学习完成后
告诉用户:
我已经学会了 web-to-knowledge-base。给我一个域名、一份 sitemap 或一组 URL(≤ 50 / 批),我用
cv.web.crawl在服务端抓成 markdown,每页落地一个带 frontmatter 的.md,写好index.json和一个自包含的kb/index.html浏览页——左侧目录、中间正文、顶栏关键词搜索,浏览器双击就能用,可以直接喂 agent 上下文或切 chunk 做 RAG。免本地浏览器、免 Python、按成功页计费、失败页不收钱。