#用 OpenClaw 搭建一个“鼠鼠研究站”同款个人研究网站

这份文档是给另一位同学的 OpenClaw / AI Agent 使用的。目标是在自己的服务器上搭建一个和“鼠鼠研究站”类似的个人研究归档网站:用 Markdown 写内容,自动构建成静态 HTML,由 Nginx 对外服务,并让 Agent 以后能通过一句“推送网站”自动新增文章、构建、发布、返回链接。


#0. 最终效果

搭好之后,你会得到一个静态研究站,包含:

  • 首页:展示最近更新、内容结构、高频标签、重要收藏;
  • 每日调研:日常 paper/news/idea 简报;
  • 论文精读:单篇论文解读;
  • 主题归档:长期方向综述、技术脉络、概念整理;
  • 实验分析:训练系统、性能复现、源码口径核查、实验记录;
  • 标签页:自动生成 tag-xxx.html
  • 重要性收藏页:自动生成 importance-5.html 等;
  • 文章页:从 Markdown + YAML frontmatter 自动生成;
  • 支持代码块、标题锚点、表格、行内/块级数学公式、图片。

典型使用方式:

用户:把今天关于 xxx 的调研推送网站
Agent:整理内容 -> 写入 content/topics/xxx.md -> 运行发布脚本 -> 返回公网链接

#1. 前置条件

服务器建议:

  • Linux / Ubuntu;
  • 已安装 OpenClaw;
  • 有 Node.js;
  • 有 Python 3;
  • 有 Nginx;
  • 如果要 HTTPS,需要一个域名,并能把域名 A 记录解析到服务器公网 IP。

检查命令:

node -v
python3 --version
nginx -v

如果没有 Node.js / Nginx,可安装:

sudo apt update
sudo apt install -y nodejs npm nginx python3

如果系统自带 Node.js 太老,建议用 NodeSource 或 nvm 安装较新版本。这个站点构建脚本只用 Node.js 标准库,通常 Node 18+ 即可。


#2. 推荐目录结构

建议在 OpenClaw workspace 下建一个项目:

mkdir -p /root/.openclaw/workspace/research-site
cd /root/.openclaw/workspace/research-site

完整结构:

research-site/
├── package.json
├── content/
│   ├── daily/
│   ├── papers/
│   ├── topics/
│   └── experiments/
├── public/
│   └── images/
├── scripts/
│   ├── build.mjs
│   └── publish.py
└── dist/

含义:

  • content/daily/:每日调研、晨读、新闻简报;
  • content/papers/:单篇论文精读;
  • content/topics/:长期主题、方向综述、技术脉络;
  • content/experiments/:实验分析、训练系统、性能复现;
  • public/:静态资源,比如图片;
  • scripts/build.mjs:把 Markdown 生成 HTML;
  • scripts/publish.py:构建并做健康检查;
  • dist/:最终由 Nginx 服务的静态站点目录。

#3. 一键初始化项目

可以让 OpenClaw 直接执行下面这个初始化脚本。它会创建目录、写入 package.json、写入一个最小可用版 build.mjspublish.py,并生成一篇示例文章。

注意:下面脚本里的 SITE_TITLESITE_DESCRIPTIONPUBLIC_URL 可以改成自己的。

cd /root/.openclaw/workspace
mkdir -p research-site
cd research-site

mkdir -p content/daily content/papers content/topics content/experiments public/images scripts dist

cat > package.json <<'EOF'
{
  "name": "research-site",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "build": "node scripts/build.mjs",
    "serve": "python3 -m http.server 4080 -d dist"
  }
}
EOF

cat > scripts/build.mjs <<'EOF'
import fs from 'fs';
import path from 'path';

const root = path.resolve(process.cwd());
const siteRoot = root;
const contentRoot = path.join(siteRoot, 'content');
const distRoot = path.join(siteRoot, 'dist');

const SITE_TITLE = '个人研究站';
const SITE_SUBTITLE = '把每天调研、论文速读和长期主题笔记沉淀成一个更适合浏览器阅读的清爽网站。';
const SITE_FOOTER = '个人研究站 · 自动整理调研 / 论文 / 历史主题';

fs.rmSync(distRoot, { recursive: true, force: true });
fs.mkdirSync(distRoot, { recursive: true });

const escapeHtml = (s='') => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const escapeAttr = (s='') => escapeHtml(s).replace(/"/g, '&quot;');
const slugify = (s='') => String(s).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi,'-').replace(/^-+|-+$/g,'');

function parseFrontmatter(raw) {
  if (!raw.startsWith('---\n')) return { meta: {}, body: raw };
  const end = raw.indexOf('\n---\n', 4);
  if (end === -1) return { meta: {}, body: raw };
  const fm = raw.slice(4, end).trim();
  const body = raw.slice(end + 5);
  const meta = {};
  for (const line of fm.split(/\n/)) {
    const idx = line.indexOf(':');
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim();
    const val = line.slice(idx + 1).trim();
    meta[key] = val;
  }
  return { meta, body };
}

function inferMeta(section, body, file) {
  const lines = body.split('\n').map(x => x.trim()).filter(Boolean);
  const title = lines.find(x => x.startsWith('# '))?.replace(/^#\s+/,'') || path.basename(file, '.md');
  const dateMatch = path.basename(file).match(/(20\d{2}-\d{2}-\d{2})/);
  let summary = lines.find(x => !x.startsWith('#')) || '';
  summary = summary.replace(/^[-*]\s+/, '').slice(0, 180);
  return { title, date: dateMatch?.[1] || '', summary, tags: [] };
}

function protectMath(text) {
  const store = [];
  let out = '';
  const stash = (html) => {
    const token = `@@MATH${store.length}@@`;
    store.push(html);
    return token;
  };
  for (let i = 0; i < text.length; i++) {
    if (text[i] === '\\' && text[i + 1] === '(' && (i === 0 || text[i - 1] !== '\\')) {
      const end = text.indexOf('\\)', i + 2);
      if (end !== -1) {
        const raw = text.slice(i + 2, end);
        out += stash(`<span class="math-inline" data-expr="${escapeAttr(raw)}"></span>`);
        i = end + 1;
        continue;
      }
    }
    if (text[i] === '$' && text[i + 1] !== '$' && (i === 0 || text[i - 1] !== '\\')) {
      let j = i + 1;
      while (j < text.length) {
        if (text[j] === '$' && text[j - 1] !== '\\') break;
        j++;
      }
      if (j < text.length) {
        const raw = text.slice(i + 1, j);
        out += stash(`<span class="math-inline" data-expr="${escapeAttr(raw)}"></span>`);
        i = j;
        continue;
      }
    }
    out += text[i];
  }
  return { text: out, store };
}

function restoreMath(text, store) {
  let out = text;
  for (let i = 0; i < store.length; i++) out = out.replace(`@@MATH${i}@@`, store[i]);
  return out;
}

function renderBlockMath(expr) {
  return `<div class="math-block" data-expr="${escapeAttr(expr.trim())}"></div>`;
}

function splitTableRow(line) {
  let s = line.trim();
  if (!s.includes('|')) return null;
  if (s.startsWith('|')) s = s.slice(1);
  if (s.endsWith('|')) s = s.slice(0, -1);
  const cells = [];
  let cur = '';
  let escaped = false;
  for (const ch of s) {
    if (escaped) { cur += ch; escaped = false; continue; }
    if (ch === '\\') { escaped = true; cur += ch; continue; }
    if (ch === '|') { cells.push(cur.trim()); cur = ''; continue; }
    cur += ch;
  }
  cells.push(cur.trim());
  return cells;
}

function parseTableSeparator(line) {
  const cells = splitTableRow(line);
  if (!cells || cells.length < 2) return null;
  const aligns = [];
  for (const cell of cells) {
    const compact = cell.replace(/\s+/g, '');
    if (!/^:?-{3,}:?$/.test(compact)) return null;
    const left = compact.startsWith(':');
    const right = compact.endsWith(':');
    aligns.push(left && right ? 'center' : right ? 'right' : left ? 'left' : '');
  }
  return aligns;
}

function renderTable(headers, aligns, rows) {
  const alignAttr = (i) => aligns[i] ? ` style="text-align:${aligns[i]}"` : '';
  const thead = `<thead><tr>${headers.map((cell, i) => `<th${alignAttr(i)}>${inlineMd(cell)}</th>`).join('')}</tr></thead>`;
  const tbody = rows.length ? `<tbody>${rows.map(row => `<tr>${headers.map((_, i) => `<td${alignAttr(i)}>${inlineMd(row[i] || '')}</td>`).join('')}</tr>`).join('')}</tbody>` : '';
  return `<div class="table-wrap"><table>${thead}${tbody}</table></div>`;
}

function renderImage(line) {
  const m = line.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)$/);
  if (!m) return null;
  const [, alt='', src='', title=''] = m;
  const caption = title || alt;
  return `<figure class="article-figure"><img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" loading="lazy" />${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : ''}</figure>`;
}

function inlineMd(text) {
  const protectedMath = protectMath(text);
  let s = escapeHtml(protectedMath.text);
  s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
  s = s.replace(/(?<!\*)\*([^*]+?)\*(?!\*)/g, '<em>$1</em>');
  s = s.replace(/`(.+?)`/g, '<code>$1</code>');
  s = s.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
  s = s.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noreferrer">$2</a>');
  return restoreMath(s, protectedMath.store);
}

function mdToHtml(md) {
  const lines = md.replace(/\r/g,'').split('\n');
  let html = '';
  let listType = null;
  let inCode = false;
  let inBlockquote = false;
  let inBlockMath = false;
  let blockMathLines = [];
  let blockMathEnd = null;

  const closeList = () => { if (listType) { html += `</${listType}>`; listType = null; } };
  const closeQuote = () => { if (inBlockquote) { html += '</blockquote>'; inBlockquote = false; } };
  const closeBlockMath = () => {
    if (inBlockMath) {
      closeList(); closeQuote();
      html += renderBlockMath(blockMathLines.join('\n'));
      inBlockMath = false;
      blockMathLines = [];
      blockMathEnd = null;
    }
  };

  for (let i = 0; i < lines.length; i++) {
    let line = lines[i];
    if (line.startsWith('```')) {
      closeBlockMath(); closeList(); closeQuote();
      if (!inCode) { html += '<pre><code>'; inCode = true; }
      else { html += '</code></pre>'; inCode = false; }
      continue;
    }
    if (inCode) { html += escapeHtml(line) + '\n'; continue; }

    const trimmed = line.trim();
    if (trimmed === '$$' || trimmed === '\\[') {
      if (inBlockMath && trimmed === blockMathEnd) closeBlockMath();
      else if (!inBlockMath) {
        closeList(); closeQuote(); inBlockMath = true;
        blockMathEnd = trimmed === '$$' ? '$$' : '\\]';
        blockMathLines = [];
      } else blockMathLines.push(line);
      continue;
    }
    if (inBlockMath) {
      if (trimmed === blockMathEnd) closeBlockMath();
      else blockMathLines.push(line);
      continue;
    }

    const singleLineBlockMath = trimmed.match(/^\$\$([\s\S]+)\$\$$/) || trimmed.match(/^\\\[([\s\S]+)\\\]$/);
    if (singleLineBlockMath) { closeList(); closeQuote(); html += renderBlockMath(singleLineBlockMath[1]); continue; }
    if (/^---+$/.test(trimmed)) { closeList(); closeQuote(); html += '<hr />'; continue; }
    if (/^>\s?/.test(line)) {
      closeList();
      if (!inBlockquote) { html += '<blockquote>'; inBlockquote = true; }
      html += `<p>${inlineMd(line.replace(/^>\s?/,''))}</p>`;
      continue;
    } else closeQuote();

    const heading = line.match(/^(#{1,6})\s+(.+)$/);
    if (heading) {
      closeList();
      const level = heading[1].length;
      const text = heading[2].trim();
      const id = slugify(text);
      html += `<h${level} id="${id}"><a class="heading-anchor" href="#${id}" aria-label="链接到本节">#</a>${inlineMd(text)}</h${level}>`;
      continue;
    }

    const imageHtml = renderImage(trimmed);
    if (imageHtml) { closeList(); html += imageHtml; continue; }

    const maybeHeader = splitTableRow(line);
    const maybeAligns = i + 1 < lines.length ? parseTableSeparator(lines[i + 1]) : null;
    if (maybeHeader && maybeAligns && maybeHeader.length >= 2 && maybeHeader.length === maybeAligns.length) {
      closeList(); closeQuote();
      const rows = [];
      i += 2;
      while (i < lines.length) {
        const rowCells = splitTableRow(lines[i]);
        const rowTrimmed = lines[i].trim();
        if (!rowTrimmed || !rowCells || rowCells.length < 2) break;
        while (rowCells.length < maybeHeader.length) rowCells.push('');
        rows.push(rowCells.slice(0, maybeHeader.length));
        i++;
      }
      i--;
      html += renderTable(maybeHeader, maybeAligns, rows);
      continue;
    }

    const unordered = line.match(/^[-*]\s+(.+)$/);
    const ordered = line.match(/^\d+[.)]\s+(.+)$/);
    if (unordered || ordered) {
      const nextType = unordered ? 'ul' : 'ol';
      if (listType && listType !== nextType) closeList();
      if (!listType) { html += `<${nextType}>`; listType = nextType; }
      html += `<li>${inlineMd((unordered || ordered)[1])}</li>`;
      continue;
    }

    closeList();
    if (!trimmed) { html += '<div class="spacer"></div>'; continue; }
    html += `<p>${inlineMd(line)}</p>`;
  }
  closeBlockMath(); closeList(); closeQuote();
  if (inCode) html += '</code></pre>';
  return html;
}

function walk(dir) {
  const out = [];
  if (!fs.existsSync(dir)) return out;
  for (const name of fs.readdirSync(dir)) {
    const full = path.join(dir, name);
    const stat = fs.statSync(full);
    if (stat.isDirectory()) out.push(...walk(full));
    else if (name.endsWith('.md')) out.push(full);
  }
  return out;
}

const sectionLabels = { daily: '每日调研', papers: '论文精读', topics: '主题归档', experiments: '实验分析' };
const sections = ['daily','papers','topics','experiments'];
const allItems = [];

for (const section of sections) {
  const files = walk(path.join(contentRoot, section));
  for (const file of files) {
    const raw = fs.readFileSync(file, 'utf8');
    const { meta, body } = parseFrontmatter(raw);
    const inferred = inferMeta(section, body, file);
    const title = meta.title || inferred.title;
    const date = meta.date || inferred.date;
    const summary = meta.summary || inferred.summary;
    const tags = meta.tags ? meta.tags.split(',').map(s => s.trim()).filter(Boolean) : inferred.tags;
    const importance = meta.importance ? Number(meta.importance) : 0;
    const slug = slugify(path.relative(contentRoot, file).replace(/\.md$/, ''));
    const url = `${slug}.html`;
    allItems.push({ section, file, title, date, summary, tags, importance, url, bodyHtml: mdToHtml(body) });
  }
}

allItems.sort((a,b) => String(b.date).localeCompare(String(a.date)) || a.title.localeCompare(b.title));

const css = `
:root{color-scheme:light;--bg:#f5f3ee;--card:#ffffff;--text:#111827;--muted:#6b7280;--line:#e7e2d8;--accent:#2563eb;--accent-soft:#eff6ff;--accent-2:#0f766e;--article-bg:#fffdf8;--article-line:#ebe3d6;--article-text:#243041;--article-code:#f1f5f9;--shadow:0 18px 50px rgba(15,23,42,.08)}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:radial-gradient(circle at 20% 0%,#fff7ed 0,#f8fafc 34%,#f5f3ee 100%);color:var(--text);line-height:1.75;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}a{color:var(--accent);text-decoration:none;text-underline-offset:3px}a:hover{text-decoration:underline}
.wrap{max-width:1160px;margin:0 auto;padding:34px 20px 84px}.hero{margin-bottom:26px}.hero h1{font-size:42px;line-height:1.08;margin:0 0 12px;letter-spacing:-.03em}.hero p{margin:0;color:var(--muted);max-width:840px}.eyebrow{display:inline-block;padding:6px 10px;border-radius:999px;background:var(--accent-soft);color:var(--accent);font-size:12px;font-weight:750;letter-spacing:.02em;margin-bottom:14px}.nav{display:flex;gap:12px;flex-wrap:wrap;margin:22px 0 32px}.nav a{padding:8px 14px;border:1px solid var(--line);border-radius:999px;background:rgba(255,255,255,.86);color:#374151;box-shadow:0 4px 16px rgba(15,23,42,.035)}.nav a.primary{background:var(--text);color:#fff;border-color:var(--text)}
.grid{display:grid;grid-template-columns:2.1fr 1fr;gap:22px}.col{display:flex;flex-direction:column;gap:18px}.card{background:rgba(255,255,255,.9);border:1px solid var(--line);border-radius:24px;padding:24px;box-shadow:0 10px 30px rgba(15,23,42,.05);backdrop-filter:blur(10px)}.hero-card{padding:30px;background:linear-gradient(135deg,#ffffff 0%,#f8fbff 58%,#fff7ed 100%)}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:22px}.stat{padding:16px;border:1px solid var(--line);border-radius:18px;background:#fff}.stat b{display:block;font-size:26px;line-height:1.1}.stat span{color:var(--muted);font-size:13px}.card h2,.card h3{margin:0 0 10px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.section-head .hint{color:var(--muted);font-size:13px}.meta{font-size:13px;color:var(--muted);margin-bottom:8px}.list{display:flex;flex-direction:column;gap:14px}.item{padding-bottom:14px;border-bottom:1px solid var(--line)}.item:last-child{border-bottom:none;padding-bottom:0}.tag{display:inline-block;padding:3px 8px;border-radius:999px;background:#eef2ff;color:#1d4ed8;font-size:12px;margin-right:6px;margin-top:8px}.spacer{height:12px}
.article-wrap{display:flex;justify-content:center}.article{max-width:860px;width:100%;background:linear-gradient(180deg,var(--article-bg) 0%,#ffffff 100%);border:1px solid var(--article-line);box-shadow:var(--shadow);padding:46px 56px 40px;border-radius:30px;position:relative;overflow:hidden}.article:before{content:"";position:absolute;left:0;right:0;top:0;height:5px;background:linear-gradient(90deg,#2563eb,#0f766e,#f97316);opacity:.9}.article-header{margin-bottom:30px;padding-bottom:22px;border-bottom:1px solid #efe6d9}.article .meta{display:flex;gap:9px;flex-wrap:wrap;align-items:center;margin-bottom:0}.article .meta-badge{display:inline-block;padding:5px 10px;border-radius:999px;background:#eef2ff;color:#1d4ed8;font-size:12px;font-weight:650}.article .meta-badge:nth-child(2n){background:#ecfeff;color:#0f766e}.article .meta-badge:nth-child(3n){background:#fff7ed;color:#0369a1}.article h1{font-size:43px;line-height:1.13;margin:12px 0 20px;letter-spacing:-0.035em;color:#0f172a;font-weight:850}.article h2{margin-top:48px;margin-bottom:16px;font-size:28px;line-height:1.24;letter-spacing:-0.02em;color:#101827;padding-top:18px;border-top:1px solid #f0e7db}.article h3{margin-top:32px;margin-bottom:11px;font-size:21px;line-height:1.36;color:#172033}.heading-anchor{float:left;margin-left:-1.05em;width:1em;opacity:0;color:#94a3b8;font-weight:650}.article h1:hover .heading-anchor,.article h2:hover .heading-anchor,.article h3:hover .heading-anchor{opacity:1;text-decoration:none}.article p,.article li{font-size:17px;line-height:1.96;color:var(--article-text)}.article p{margin:0 0 15px}.article ul,.article ol{padding-left:1.45rem;margin:9px 0 20px}.article li{margin:8px 0}.article strong{color:#0f172a;font-weight:760;background:linear-gradient(transparent 63%,rgba(14,165,233,.14) 0)}.article code{background:var(--article-code);padding:2px 7px;border-radius:7px;font-size:.92em;color:#1e293b;border:1px solid #e2e8f0}.article pre{background:#0f172a;color:#e5e7eb;padding:18px 20px;border-radius:16px;overflow:auto;border:1px solid #1f2937;margin:22px 0}.article pre code{background:transparent;padding:0;color:inherit;border:none}.article .table-wrap{margin:24px 0 30px;overflow-x:auto;border:1px solid #e7decf;border-radius:16px;background:rgba(255,255,255,.72)}.article table{width:100%;border-collapse:separate;border-spacing:0;min-width:620px;font-size:15px;line-height:1.75}.article th,.article td{padding:11px 14px;border-right:1px solid #e9e1d4;border-bottom:1px solid #e9e1d4;vertical-align:top;color:var(--article-text)}.article th{background:linear-gradient(180deg,#f8fafc,#f1f5f9);color:#0f172a;font-weight:760;white-space:nowrap}.article blockquote{margin:22px 0;padding:16px 19px;border-left:4px solid #0f766e;background:linear-gradient(90deg,#f8fafc,#fff);border-radius:0 16px 16px 0}.article hr{border:none;border-top:1px solid #ece3d7;margin:30px 0}.article-figure{margin:28px 0;text-align:center}.article-figure img{max-width:100%;border-radius:18px;border:1px solid #e5e7eb;box-shadow:0 12px 28px rgba(15,23,42,.08)}.article-figure figcaption{margin-top:8px;color:var(--muted);font-size:14px}.math-inline{display:inline-block;vertical-align:middle;max-width:100%}.math-block{display:block;overflow-x:auto;padding:8px 0;margin:18px 0}.footer{margin-top:40px;color:var(--muted);font-size:13px}.timeline{display:grid;gap:12px}.timeline .item{border:1px solid var(--line);border-radius:18px;padding:16px;background:#fff}
@media (max-width: 900px){.wrap{padding:24px 14px 64px}.grid,.stats{grid-template-columns:1fr}.hero h1{font-size:32px}.article{padding:32px 22px 26px;border-radius:22px}.article h1{font-size:31px}.article h2{font-size:23px;margin-top:36px}.article h3{font-size:19px}.article p,.article li{font-size:16px;line-height:1.9}.heading-anchor{display:none}}
@media (prefers-color-scheme: dark){:root{--card:#111827;--text:#e5e7eb;--muted:#9ca3af;--line:#273244;--article-bg:#101827;--article-line:#283548;--article-text:#d6dde8;--article-code:#172033}body{background:radial-gradient(circle at 20% 0%,#1e293b 0,#0b1020 45%,#070b14 100%)}.card,.stat,.timeline .item{background:rgba(17,24,39,.9)}.nav a{background:#111827;color:#d1d5db}.article{background:linear-gradient(180deg,#111827,#0f172a)}.article h1,.article h2,.article h3,.article strong{color:#f8fafc}.article code{border-color:#334155}.article-header,.article-nav,.article h2{border-color:#263244}.article .table-wrap{background:rgba(15,23,42,.75);border-color:#334155}.article th,.article td{border-color:#334155}.article th{background:linear-gradient(180deg,#1e293b,#172033);color:#f8fafc}}
`;

function layout(title, content) {
  return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${escapeHtml(title)}</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"><style>${css}</style></head><body><div class="wrap">${content}<div class="footer">${escapeHtml(SITE_FOOTER)}</div></div><script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script><script>document.addEventListener('DOMContentLoaded',()=>{if(!window.katex)return;document.querySelectorAll('.math-inline').forEach(el=>{try{window.katex.render(el.dataset.expr||'',el,{throwOnError:false,displayMode:false})}catch(e){}});document.querySelectorAll('.math-block').forEach(el=>{try{window.katex.render(el.dataset.expr||'',el,{throwOnError:false,displayMode:true})}catch(e){}});});</script></body></html>`;
}

const latest = allItems.slice(0, 10);
const latestDaily = allItems.filter(x => x.section === 'daily').slice(0, 16);
const latestPapers = allItems.filter(x => x.section === 'papers').slice(0, 16);
const latestTopics = allItems.filter(x => x.section === 'topics').slice(0, 20);
const latestExperiments = allItems.filter(x => x.section === 'experiments').slice(0, 20);
const recentTags = [...new Set(allItems.flatMap(x => x.tags))].slice(0, 16);
const importantItems = allItems.filter(x => x.importance >= 1).sort((a,b) => b.importance - a.importance || String(b.date).localeCompare(String(a.date)));
const tagSlug = (tag) => `tag-${slugify(tag)}.html`;
const importanceSlug = (n) => `importance-${n}.html`;
const stars = (n) => '★'.repeat(n) + '☆'.repeat(5-n);

const indexHtml = layout(SITE_TITLE, `
  <section class="hero">
    <div class="eyebrow">Research Archive · Personal Knowledge Site</div>
    <div class="card hero-card">
      <h1>${escapeHtml(SITE_TITLE)}</h1>
      <p>${escapeHtml(SITE_SUBTITLE)}</p>
      <nav class="nav"><a class="primary" href="index.html">首页</a><a href="daily.html">每日调研</a><a href="papers.html">论文精读</a><a href="topics.html">主题归档</a><a href="experiments.html">实验分析</a></nav>
      <div class="stats"><div class="stat"><b>${allItems.length}</b><span>总文章数</span></div><div class="stat"><b>${latestDaily.length}</b><span>每日调研</span></div><div class="stat"><b>${latestPapers.length}</b><span>论文精读</span></div><div class="stat"><b>${latestTopics.length}</b><span>主题归档</span></div></div>
    </div>
  </section>
  <section class="grid">
    <div class="col">
      <div class="card"><div class="section-head"><h2>最近更新</h2><div class="hint">按时间倒序</div></div><div class="list">${latest.map(x => `<div class="item"><div class="meta">${sectionLabels[x.section]}${x.date ? ' · ' + x.date : ''}</div><h3><a href="${x.url}">${escapeHtml(x.title)}</a></h3><div>${escapeHtml(x.summary || '')}</div><div>${x.tags.map(t => `<a class="tag" href="${tagSlug(t)}">${escapeHtml(t)}</a>`).join('')}</div></div>`).join('')}</div></div>
      <div class="card"><div class="section-head"><h2>最近调研时间线</h2><div class="hint">每日积累</div></div><div class="timeline">${latestDaily.slice(0,6).map(x => `<div class="item"><div class="meta">${x.date || '未标日期'}</div><h3><a href="${x.url}">${escapeHtml(x.title)}</a></h3><div>${escapeHtml(x.summary || '')}</div></div>`).join('')}</div></div>
    </div>
    <div class="col">
      <div class="card"><div class="section-head"><h2>内容结构</h2><div class="hint">适合长期积累</div></div><div class="list"><div class="item"><h3><a href="daily.html">每日调研</a></h3><div>按日期查看每天的研究任务、论文早读和临时分析。</div></div><div class="item"><h3><a href="papers.html">论文精读</a></h3><div>单篇论文的详细解读、背景判断和链接整理。</div></div><div class="item"><h3><a href="topics.html">主题归档</a></h3><div>围绕长期关注方向,持续沉淀技术主题笔记。</div></div><div class="item"><h3><a href="experiments.html">实验分析</a></h3><div>训练系统、性能复现、源码口径核查和实验结果分析。</div></div></div></div>
      <div class="card"><div class="section-head"><h2>最近主题</h2><div class="hint">知识库入口</div></div><div class="list">${latestTopics.slice(0,8).map(x => `<div class="item"><h3><a href="${x.url}">${escapeHtml(x.title)}</a></h3><div>${escapeHtml(x.summary || '')}</div></div>`).join('')}</div></div>
      <div class="card"><div class="section-head"><h2>重要收藏</h2><div class="hint">1~5 星收藏</div></div><div>${[5,4,3,2,1].map(n => `<a class="tag" href="${importanceSlug(n)}">${stars(n)}</a>`).join('')}</div><div class="list" style="margin-top:14px">${importantItems.slice(0,5).map(x => `<div class="item"><div class="meta">${x.importance ? stars(x.importance) + ' · ' : ''}${sectionLabels[x.section]}${x.date ? ' · ' + x.date : ''}</div><h3><a href="${x.url}">${escapeHtml(x.title)}</a></h3><div>${escapeHtml(x.summary || '')}</div></div>`).join('')}</div></div>
      <div class="card"><div class="section-head"><h2>高频标签</h2><div class="hint">快速进入常看方向</div></div><div>${recentTags.map(t => `<a class="tag" href="${tagSlug(t)}">${escapeHtml(t)}</a>`).join('')}</div></div>
    </div>
  </section>
`);
fs.writeFileSync(path.join(distRoot, 'index.html'), indexHtml);

function renderSectionPage(name, title, items) {
  const html = layout(title, `<section class="hero"><div class="eyebrow">${sectionLabels[name]}</div><h1>${title}</h1><p>${name === 'daily' ? '按天沉淀研究任务、论文早读和临时调研。' : name === 'papers' ? '单篇论文精读,适合系统阅读。' : name === 'experiments' ? '训练系统、性能复现、源码口径核查和实验结果分析。' : '围绕长期关注方向归档整理的主题笔记。'}</p><nav class="nav"><a href="index.html">首页</a><a class="primary" href="${name}.html">${title}</a><a href="daily.html">每日调研</a><a href="papers.html">论文精读</a><a href="topics.html">主题归档</a><a href="experiments.html">实验分析</a></nav></section><div class="card"><div class="section-head"><h2>${title}</h2><div class="hint">共 ${items.length} 篇</div></div><div class="list">${items.map(x => `<div class="item"><div class="meta">${x.date || x.section}</div><h2><a href="${x.url}">${escapeHtml(x.title)}</a></h2><div>${escapeHtml(x.summary || '')}</div><div>${x.tags.map(t => `<a class="tag" href="${tagSlug(t)}">${escapeHtml(t)}</a>`).join('')}</div></div>`).join('')}</div></div>`);
  fs.writeFileSync(path.join(distRoot, `${name}.html`), html);
}

renderSectionPage('daily', '每日调研', latestDaily);
renderSectionPage('papers', '论文精读', latestPapers);
renderSectionPage('topics', '主题归档', latestTopics);
renderSectionPage('experiments', '实验分析', latestExperiments);

for (const n of [5,4,3,2,1]) {
  const picked = allItems.filter(x => x.importance === n);
  const html = layout(`重要收藏 ${stars(n)}`, `<section class="hero"><div class="eyebrow">Importance Archive</div><h1>${stars(n)} 收藏</h1><p>这里整理所有被标记为 ${n} 星的重要内容。</p><nav class="nav"><a href="index.html">首页</a><a href="daily.html">每日调研</a><a href="papers.html">论文精读</a><a href="topics.html">主题归档</a><a href="experiments.html">实验分析</a></nav></section><div class="card"><div class="section-head"><h2>${stars(n)}</h2><div class="hint">共 ${picked.length} 篇</div></div><div class="list">${picked.map(x => `<div class="item"><div class="meta">${sectionLabels[x.section]}${x.date ? ' · ' + x.date : ''}</div><h2><a href="${x.url}">${escapeHtml(x.title)}</a></h2><div>${escapeHtml(x.summary || '')}</div><div>${x.tags.map(t => `<a class="tag" href="${tagSlug(t)}">${escapeHtml(t)}</a>`).join('')}</div></div>`).join('')}</div></div>`);
  fs.writeFileSync(path.join(distRoot, importanceSlug(n)), html);
}

const allTags = [...new Set(allItems.flatMap(x => x.tags))].sort((a,b) => a.localeCompare(b));
for (const tag of allTags) {
  const tagged = allItems.filter(x => x.tags.includes(tag));
  const html = layout(`标签:${tag}`, `<section class="hero"><div class="eyebrow">Tag Archive</div><h1>标签:${escapeHtml(tag)}</h1><p>这里整理所有带有「${escapeHtml(tag)}」标签的文章。</p><nav class="nav"><a href="index.html">首页</a><a href="daily.html">每日调研</a><a href="papers.html">论文精读</a><a href="topics.html">主题归档</a><a href="experiments.html">实验分析</a></nav></section><div class="card"><div class="section-head"><h2>${escapeHtml(tag)}</h2><div class="hint">共 ${tagged.length} 篇</div></div><div class="list">${tagged.map(x => `<div class="item"><div class="meta">${sectionLabels[x.section]}${x.date ? ' · ' + x.date : ''}</div><h2><a href="${x.url}">${escapeHtml(x.title)}</a></h2><div>${escapeHtml(x.summary || '')}</div><div>${x.tags.map(t => `<a class="tag" href="${tagSlug(t)}">${escapeHtml(t)}</a>`).join('')}</div></div>`).join('')}</div></div>`);
  fs.writeFileSync(path.join(distRoot, tagSlug(tag)), html);
}

for (const item of allItems) {
  const page = layout(item.title, `<div class="article-wrap"><article class="article"><header class="article-header"><div class="meta"><span class="meta-badge">${sectionLabels[item.section]}</span>${item.date ? `<span class="meta-badge">${item.date}</span>` : ''}${item.importance ? `<a class="meta-badge" href="${importanceSlug(item.importance)}">${stars(item.importance)}</a>` : ''}${item.tags.map(t => `<a class="meta-badge" href="${tagSlug(t)}">${escapeHtml(t)}</a>`).join('')}</div></header>${item.bodyHtml}<nav class="nav article-nav"><a href="index.html">首页</a><a href="${item.section}.html">返回${sectionLabels[item.section]}</a></nav></article></div>`);
  fs.writeFileSync(path.join(distRoot, item.url), page);
}

const publicRoot = path.join(siteRoot, 'public');
if (fs.existsSync(publicRoot)) fs.cpSync(publicRoot, distRoot, { recursive: true });
console.log(`Built ${allItems.length} pages into ${distRoot}`);
EOF

cat > scripts/publish.py <<'EOF'
#!/usr/bin/env python3
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

ROOT = Path('/root/.openclaw/workspace/research-site')
BUILD_SCRIPT = ROOT / 'scripts' / 'build.mjs'

# 如果还没有配置域名,可以先改成 http://127.0.0.1:4080/ 做本地检查;
# 如果已经配置 Nginx/域名,改成自己的公网地址,例如 https://example.com/
URL = 'http://127.0.0.1/'
PUBLIC_URL = URL

def run(cmd, **kwargs):
    print('+', ' '.join(cmd))
    return subprocess.run(cmd, check=True, **kwargs)

def healthcheck():
    for _ in range(5):
        try:
            with urllib.request.urlopen(URL, timeout=5) as r:
                if r.status == 200:
                    return True
        except Exception:
            time.sleep(1)
    return False

def main():
    run(['node', str(BUILD_SCRIPT)], cwd=str(ROOT))
    if not healthcheck():
        print('站点发布失败:健康检查未通过。若尚未配置 Nginx,可先只运行 npm run build。', file=sys.stderr)
        sys.exit(1)
    print('\n发布完成')
    print('公网地址:', PUBLIC_URL)

if __name__ == '__main__':
    main()
EOF
chmod +x scripts/publish.py

cat > content/topics/hello-research-site.md <<'EOF'
---
title: 我的研究站启动说明
date: 2026-05-04
tags: research, note, website
summary: 这是个人研究站的第一篇示例文章,用来验证 Markdown 构建、标签页和文章页是否正常。
importance: 5
---

# 我的研究站启动说明

这是个人研究站的第一篇文章。

以后可以把每天的调研、论文解读、技术主题和实验分析都沉淀到这里。

## 支持的内容

- Markdown 标题、列表、代码块;
- 表格;
- 数学公式,例如 $E = mc^2$;
- 图片,例如 `public/images/foo.png` 可以在文章里用 `![说明](images/foo.png)` 引用。

| 栏目 | 用途 |
|---|---|
| daily | 每日调研 |
| papers | 论文精读 |
| topics | 主题归档 |
| experiments | 实验分析 |
EOF

npm run build
python3 -m http.server 4080 -d dist

本地预览:

http://服务器IP:4080/

如果在服务器安全组/防火墙里没有开放 4080,可以先用 SSH tunnel 或直接继续配置 Nginx。


#4. 文章格式规范

每篇文章建议都使用 YAML frontmatter:

---
title: 文章标题
date: YYYY-MM-DD
tags: tag1, tag2, tag3
summary: 一句话摘要
importance: 5
---

# 文章标题

正文……

字段说明:

  • title:文章标题;
  • date:日期,用于排序;
  • tags:逗号分隔标签;
  • summary:列表页摘要;
  • importance:1~5,生成重要收藏页。

文章应该放到对应栏目:

content/daily/       每日调研、新闻、晨读
content/papers/      单篇论文精读
content/topics/      长期主题、综述、技术路线
content/experiments/ 实验分析、系统分析、复现记录

生成后的路由是扁平 HTML 路由

content/topics/foo.md       -> topics-foo.html
content/papers/bar.md       -> papers-bar.html
content/daily/baz.md        -> daily-baz.html
content/experiments/qux.md  -> experiments-qux.html

不要预期是 /topics/foo.html 这种目录式路由。


#5. 配置 Nginx 对外服务

如果你的站点目录是:

/root/.openclaw/workspace/research-site/dist

可以创建 Nginx 配置:

sudo tee /etc/nginx/sites-available/research-site >/dev/null <<'EOF'
server {
    listen 80;
    listen [::]:80;

    server_name your-domain.com www.your-domain.com _;

    root /root/.openclaw/workspace/research-site/dist;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
EOF

sudo ln -sf /etc/nginx/sites-available/research-site /etc/nginx/sites-enabled/research-site
sudo nginx -t
sudo systemctl reload nginx

然后访问:

http://服务器IP/
http://your-domain.com/

如果 root /root/... 因权限问题导致 Nginx 403,可以选择更稳妥的方式:把站点放到 /var/www/research-site,或者让构建后同步 dist/var/www/research-site

例如:

sudo mkdir -p /var/www/research-site
sudo rsync -a --delete /root/.openclaw/workspace/research-site/dist/ /var/www/research-site/

并把 Nginx root 改成:

root /var/www/research-site;

#6. 配置 HTTPS

如果有域名,并且域名已经解析到服务器,可以用 Certbot:

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com -d www.your-domain.com --redirect

验证:

curl -I http://your-domain.com/
curl -I https://your-domain.com/
sudo certbot renew --dry-run --no-random-sleep-on-renew

配置 HTTPS 后,记得把 scripts/publish.py 里的:

URL = 'http://127.0.0.1/'
PUBLIC_URL = URL

改成:

URL = 'https://your-domain.com/'
PUBLIC_URL = 'https://your-domain.com/'

以后发布脚本就会构建后检查公网 HTTPS 是否可访问。


#7. 让 OpenClaw 学会“推送网站”

建议在 OpenClaw 的长期记忆、项目说明或 skill 中加入以下规则。

可以直接复制给 OpenClaw:

当用户说“推送网站”“发到研究站”“更新网站”时,默认指个人研究站项目:
/root/.openclaw/workspace/research-site

默认流程:
1. 根据内容类型选择目录:
   - content/daily/:每日调研、短新闻、晨读;
   - content/papers/:单篇论文精读;
   - content/topics/:长期主题、方向综述、技术解释;
   - content/experiments/:实验分析、训练系统、源码/性能复现。
2. 写 Markdown 文件,必须包含 frontmatter:title/date/tags/summary/importance。
3. 文件名使用英文 slug,例如 content/topics/llm-agent-rl-survey-2026.md。
4. 运行:
   python3 /root/.openclaw/workspace/research-site/scripts/publish.py
5. 检查 dist/ 里实际生成的 HTML 文件。
6. 用 curl 验证公网 URL 返回 200。
7. 返回实际链接。注意本站使用扁平路由:content/topics/foo.md -> topics-foo.html,不是 /topics/foo.html。

也可以让 OpenClaw 创建一个专门 skill:

Skill 名称:research-site-publishing
触发条件:用户说“推送网站”“更新研究站”“发到网站”。
核心路径:/root/.openclaw/workspace/research-site
发布命令:python3 scripts/publish.py
路由规则:content/topics/foo.md -> topics-foo.html
验证规则:curl -L --max-time 20 https://your-domain.com/topics-foo.html

#8. Agent 每次发文的推荐工作流

当用户给一段内容并要求发网站时,Agent 应该这样做:

#Step 1:判断栏目

  • 是日常 briefing / 新闻 / paper list:放 daily/
  • 是单篇论文:放 papers/
  • 是一个概念、方向、技术发展脉络:放 topics/
  • 是实验、训练系统、性能、源码核查:放 experiments/

#Step 2:生成 Markdown

示例:

---
title: LLM Agent 的长期记忆机制综述
date: 2026-05-04
tags: LLM Agent, memory, survey
summary: 梳理 LLM Agent 长期记忆机制的主要路线,包括向量检索、情景记忆、知识图谱、反思压缩与跨会话状态管理。
importance: 5
---

# LLM Agent 的长期记忆机制综述

正文……

文件路径:

content/topics/llm-agent-long-term-memory-survey-2026.md

#Step 3:发布

cd /root/.openclaw/workspace/research-site
python3 scripts/publish.py

#Step 4:验证实际链接

python3 - <<'PY'
import os
for f in sorted(os.listdir('/root/.openclaw/workspace/research-site/dist')):
    if 'memory' in f.lower():
        print(f)
PY

然后:

curl -L --max-time 20 -o /tmp/page.html -w 'url=%{url_effective}\ncode=%{http_code}\nsize=%{size_download}\n' https://your-domain.com/topics-llm-agent-long-term-memory-survey-2026.html

确认 code=200 后再回复用户。


#9. 常见问题

#9.1 为什么文章链接不是 /topics/foo.html

因为构建脚本用的是扁平路由:

content/topics/foo.md -> topics-foo.html

这是为了让 Nginx 静态服务和链接生成更简单。

#9.2 修改文章后为什么网页没变?

检查是否重新构建:

cd /root/.openclaw/workspace/research-site
npm run build

如果 Nginx root 是 /var/www/research-site,还需要同步:

sudo rsync -a --delete dist/ /var/www/research-site/

#9.3 Nginx 返回 403?

大概率是 Nginx worker 无法读取 /root/... 目录。建议把 dist 同步到 /var/www/research-site,并把 Nginx root 指向那里。

#9.4 公式不显示?

构建脚本通过 CDN 加载 KaTeX:

https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css
https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js

如果网络环境不能访问 jsDelivr,需要换成本地 KaTeX 文件或其他 CDN。

#9.5 Markdown 表格显示异常?

这个站点使用自定义轻量 Markdown renderer,不是完整 Markdown 库。表格必须用标准 GitHub 风格:

| A | B |
|---|---|
| 1 | 2 |

#10. 进阶改造建议

如果后续想进一步增强,可以让 OpenClaw 做这些事:

  1. 独立主题配置文件:把站点标题、描述、footer、栏目名放到 site.config.json
  2. 全文搜索:构建时生成 search-index.json,前端用 JS 搜索。
  3. RSS Feed:生成 feed.xml,方便订阅。
  4. Git 版本管理:每次发布后自动 git add/commit
  5. 自动备份:定期把 content/ 备份到 GitHub 或对象存储。
  6. 更完整 Markdown 支持:替换自定义 renderer 为 markdown-it / remark
  7. 图片压缩:把 public/images 中大图自动压缩成 webp。

#11. 给 OpenClaw 的最终任务提示词

如果想让同学的 OpenClaw 直接照做,可以把下面这段发给它:

请你在我的服务器上搭建一个个人研究站,效果类似“鼠鼠研究站”:

目标:
- 用 Markdown 写研究笔记;
- 自动构建成静态 HTML;
- 有首页、每日调研、论文精读、主题归档、实验分析四个栏目;
- 自动生成标签页和重要性收藏页;
- 用 Nginx 对外服务;
- 以后我说“推送网站”时,你能自动整理文章、写入 content/、构建、验证公网链接并返回给我。

请按以下要求执行:
1. 项目路径使用 /root/.openclaw/workspace/research-site。
2. 创建 content/daily、content/papers、content/topics、content/experiments、public/images、scripts、dist。
3. 写 package.json,包含 build 和 serve 脚本。
4. 写 scripts/build.mjs:从 content 下读取 Markdown + frontmatter,生成 dist/index.html、daily.html、papers.html、topics.html、experiments.html、tag-*.html、importance-*.html 和文章页。
5. 写 scripts/publish.py:运行 node scripts/build.mjs,然后检查我的公网 URL 是否返回 200。
6. 创建一篇示例文章。
7. 配置 Nginx,把 dist 作为静态站点 root;如果 /root 权限有问题,就同步到 /var/www/research-site。
8. 如果我有域名,请用 certbot 配置 HTTPS。
9. 发布后用 curl 验证首页和示例文章返回 200。
10. 最后把公网首页链接和示例文章链接发给我。

注意:生成路由使用扁平 HTML,例如 content/topics/foo.md 生成 topics-foo.html,不是 /topics/foo.html。

#12. 验收清单

完成后至少要确认:

cd /root/.openclaw/workspace/research-site
npm run build
ls dist/index.html dist/topics-hello-research-site.html
curl -I http://your-domain.com/
curl -I http://your-domain.com/topics-hello-research-site.html

如果配置了 HTTPS:

curl -I https://your-domain.com/
curl -I https://your-domain.com/topics-hello-research-site.html

预期:

HTTP/1.1 200 OK

只要这些都通过,一个同款个人研究站就搭好了。