initial: blog-app snapshot

This commit is contained in:
omo
2026-08-17 17:11:27 +08:00
commit 65cb9d5ead
125 changed files with 14720 additions and 0 deletions

917
scripts/daily_watchdog.py Executable file
View File

@@ -0,0 +1,917 @@
#!/usr/bin/python3
"""AI Agent 升级迭代日报 — 抓取 + 存档 + 报告管道 (B1 阶段)."""
# LLM-INTEGRATION-POINT: B2 将替换 summarize_with_llm 函数体
# (litellm analysis, POST http://127.0.0.1:4000/v1/chat/completions,
# model="analysis", 无 auth, timeout=LLM_TIMEOUT)
import json
import logging
import os
import re
import sqlite3
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from concurrent import futures
from datetime import datetime
from pathlib import Path
import requests
# blog-app root on sys.path so the analysis service (Stage 1 refactor) is
# importable from cron/systemd contexts where CWD is not the app root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import config # noqa: E402 (needs the sys.path insert above)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
log = logging.getLogger("daily-watchdog")
BASE_DIR = Path.home() / "opencode-blog-showcase"
DATA_DIR = BASE_DIR / "data"
LOGS_DIR = BASE_DIR / "logs"
DB_PATH = DATA_DIR / "watchdog.db"
UA = "daily-watchdog/1.0"
HTTP_TIMEOUT = 30
GITHUB_TIMEOUT = 30
EXA_TIMEOUT = 60
ATOM_TIMEOUT = 45
LLM_URL = "http://127.0.0.1:4000/v1/chat/completions"
LLM_MODEL = "analysis"
LLM_TIMEOUT = 300
# Prompt trimming budgets shared with services/analysis.py (OPT-10).
LLM_BODY_LIMIT = config.LLM_BODY_LIMIT
LLM_FIELD_LIMIT = config.LLM_FIELD_LIMIT
LLM_MESSAGE_LIMIT = config.LLM_MESSAGE_LIMIT
LLM_TOTAL_LIMIT = config.LLM_TOTAL_LIMIT
# Parallel collection (OPT-9): 6 projects x 5 sources + 1 LLM call each are
# I/O bound, so threads beat the GIL. Capped at 3 to stay under GitHub's
# unauthenticated rate limit (60 req/hr) and to be polite to npm/atom.
COLLECT_MAX_WORKERS = int(os.environ.get("BLOG_APP_COLLECT_WORKERS", "3"))
# Global cap so one hanging project cannot stall the whole run:
# slowest data source (Exa, 60s + one retry) + LLM timeout + slack.
COLLECT_PROJECT_TIMEOUT = LLM_TIMEOUT + 120
CANONICAL_SOURCES = [
"GitHub Releases API",
"GitHub commits API",
"npm registry",
"GitHub releases.atom",
"Exa web search",
]
PROJECTS = [
{
"key": "opencode",
"label": "sst/opencode",
"github": "sst/opencode",
"npm": "opencode-ai",
"installed": "1.18.14",
"star": True,
},
{
"key": "claude-code",
"label": "anthropics/claude-code",
"github": "anthropics/claude-code",
"npm": "@anthropic-ai/claude-code",
"installed": None,
"star": True,
},
{
"key": "codex",
"label": "openai/codex",
"github": "openai/codex",
"npm": "@openai/codex",
"installed": None,
"star": False,
},
{
"key": "hermes-agent",
"label": "NousResearch/hermes-agent",
"github": "NousResearch/hermes-agent",
"npm": "hermes-agent",
"installed": None,
"star": True,
},
{
"key": "oh-my-openagent",
"label": "code-yeongyu/oh-my-openagent",
"github": "code-yeongyu/oh-my-openagent",
"npm": "oh-my-openagent",
"installed": None,
"star": True,
},
{
"key": "hindsight",
"label": "vectorize-io/hindsight",
"github": "vectorize-io/hindsight",
"npm": "hindsight",
"installed": None,
"star": True,
"npm_relevance_check": True,
},
]
ATOM_NS = "{http://www.w3.org/2005/Atom}"
def _llm_failure(reason: str) -> dict:
return {
"summary_zh": f"(LLM 分析失败: {reason})",
"breaking": None,
"breaking_reason": "LLM 分析失败",
"relevance_note": "",
}
def _truncate_field(value, limit: int):
"""Recursively truncate every string in a nested structure."""
if isinstance(value, str):
return value[:limit]
if isinstance(value, dict):
return {k: _truncate_field(v, limit) for k, v in value.items()}
if isinstance(value, list):
return [_truncate_field(v, limit) for v in value]
return value
def _extract_json_object(text: str) -> dict | None:
"""Extract the first {...} JSON object from text (tolerates fences/prose)."""
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
return None
try:
obj = json.loads(text[start:end + 1])
except json.JSONDecodeError:
return None
return obj if isinstance(obj, dict) else None
def _short_reason(e: Exception, default: str = "请求失败") -> str:
"""Short single-line failure reason (≤80 chars, no newlines)."""
msg = str(e).strip().replace("\n", " ").replace("\r", " ")
if not msg:
msg = default
return msg[:80]
def summarize_with_llm(project_key: str, collected_data: dict) -> dict:
# 数据截断: release body ≤LLM_BODY_LIMIT, 其余字段各 ≤LLM_FIELD_LIMIT,
# 总数据部分 ≤LLM_TOTAL_LIMIT (12000 chars ≈ 3000 tokens, 见 config.py)
trimmed: dict = {}
for key, value in collected_data.items():
if value is None:
continue
if key == "github_release" and isinstance(value, dict):
rel = dict(value)
rel["body"] = (rel.get("body") or "")[:LLM_BODY_LIMIT]
trimmed[key] = _truncate_field(rel, LLM_FIELD_LIMIT)
trimmed[key]["body"] = (value.get("body") or "")[:LLM_BODY_LIMIT]
else:
trimmed[key] = _truncate_field(value, LLM_FIELD_LIMIT)
data_json = json.dumps(trimmed, ensure_ascii=False, default=str)
if len(data_json) > LLM_TOTAL_LIMIT:
# 极端情况下继续收缩: 砍掉 exa_results 再砍 commits
trimmed.pop("exa_results", None)
data_json = json.dumps(trimmed, ensure_ascii=False, default=str)
if len(data_json) > LLM_TOTAL_LIMIT and isinstance(trimmed.get("github_commits"), list):
trimmed["github_commits"] = trimmed["github_commits"][:3]
data_json = json.dumps(trimmed, ensure_ascii=False, default=str)
prompt = (
"你是 AI 编程助手项目的升级监控分析师。下面是项目 "
f"{project_key} 的最新抓取数据 (JSON):\n\n{data_json}\n\n"
"请分析这些变更, 严格只返回一个 JSON 对象, 不要输出其他内容:\n"
"{\n"
' "summary_zh": "不少于50字的中文摘要, 概括本次更新的核心内容, 2-3句话",\n'
' "breaking": true 或 false, 表示是否存在不向后兼容的破坏性变更,\n'
' "breaking_reason": "若有 breaking 说明具体内容; 若无则写 \'未发现破坏性变更\'",\n'
' "relevance_note": "对本地已安装该工具用户的实际意义, 1-2句话"\n'
"}"
)
messages = [
{"role": "system", "content": "你是严谨的 AI 工具升级监控分析师, 只输出 JSON。"},
{"role": "user", "content": prompt},
]
try:
resp = requests.post(
LLM_URL,
json={"model": LLM_MODEL, "messages": messages},
timeout=LLM_TIMEOUT,
)
resp.raise_for_status()
content = (
resp.json()
.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
if not content:
log.error("LLM empty content for %s", project_key)
return _llm_failure("空响应")
obj = _extract_json_object(content)
if obj is None:
log.error("LLM JSON parse failed for %s: %.200s", project_key, content)
return _llm_failure("JSON 解析失败")
summary = str(obj.get("summary_zh", "")).strip()
if not summary:
log.error("LLM empty summary_zh for %s", project_key)
return _llm_failure("summary_zh 为空")
breaking = obj.get("breaking")
if not isinstance(breaking, bool):
breaking = None
return {
"summary_zh": summary,
"breaking": breaking,
"breaking_reason": str(obj.get("breaking_reason", "")).strip() or "未发现破坏性变更",
"relevance_note": str(obj.get("relevance_note", "")).strip(),
}
except Exception as e:
log.error("LLM call failed for %s: %s", project_key, e)
return _llm_failure(str(e))
def gh_headers():
return {
"User-Agent": UA,
"Accept": "application/vnd.github+json",
}
def fetch_github_release(github_repo: str) -> tuple[dict | None, str | None]:
url = f"https://api.github.com/repos/{github_repo}/releases/latest"
try:
r = requests.get(url, headers=gh_headers(), timeout=GITHUB_TIMEOUT)
r.raise_for_status()
d = r.json()
return {
"tag_name": d.get("tag_name", ""),
"published_at": d.get("published_at", ""),
"name": d.get("name", ""),
"body": (d.get("body") or "")[:LLM_BODY_LIMIT],
}, None
except Exception as e:
log.error("GitHub release failed for %s: %s", github_repo, e)
return None, _short_reason(e)
def fetch_github_commits(github_repo: str) -> tuple[list[dict] | None, str | None]:
url = f"https://api.github.com/repos/{github_repo}/commits?per_page=5"
try:
r = requests.get(url, headers=gh_headers(), timeout=GITHUB_TIMEOUT)
r.raise_for_status()
items = []
for c in r.json():
msg = c.get("commit", {}).get("message", "")
first_line = msg.split("\n")[0][:LLM_MESSAGE_LIMIT]
date = c.get("commit", {}).get("author", {}).get("date", "")
items.append({"message": first_line, "date": date})
return items, None
except Exception as e:
log.error("GitHub commits failed for %s: %s", github_repo, e)
return None, _short_reason(e)
def fetch_npm(pkg: str) -> tuple[dict | None, str | None]:
if pkg.startswith("@"):
scope, name = pkg.split("/", 1)
encoded = f"{scope}%2F{name}"
else:
encoded = pkg
url = f"https://registry.npmjs.org/{encoded}/latest"
try:
r = requests.get(url, headers={"User-Agent": UA}, timeout=HTTP_TIMEOUT)
r.raise_for_status()
d = r.json()
return {
"version": d.get("version", ""),
"description": d.get("description", ""),
}, None
except Exception as e:
log.error("npm fetch failed for %s: %s", pkg, e)
return None, _short_reason(e)
def fetch_releases_atom(github_repo: str) -> tuple[dict | None, str | None]:
url = f"https://github.com/{github_repo}/releases.atom"
last_err: str | None = None
for attempt in range(2): # 首次 + 1 次重试 (仅 Timeout)
try:
r = requests.get(
url, headers={"User-Agent": UA}, timeout=ATOM_TIMEOUT
)
r.raise_for_status()
root = ET.fromstring(r.text)
entries = root.findall(f"{ATOM_NS}entry")
if not entries:
return None, "feed 无 entry"
entry = entries[0]
title = entry.findtext(f"{ATOM_NS}title", default="")
updated = entry.findtext(f"{ATOM_NS}updated", default="")
return {"title": title, "updated": updated}, None
except requests.exceptions.Timeout:
last_err = "超时 (github.com 限流/网络)"
log.warning(
"Atom feed timeout for %s (attempt %d/2)", github_repo, attempt + 1
)
if attempt == 0:
time.sleep(5)
except Exception as e:
last_err = _short_reason(e)
log.error("Atom feed failed for %s: %s", github_repo, e)
break
return None, last_err
def _parse_exa_text_block(text: str) -> list[dict]:
results = []
blocks = re.split(r"\n---\n", text)
for block in blocks:
block = block.strip()
if not block:
continue
title = ""
url = ""
highlights = []
in_highlights = False
for line in block.split("\n"):
if line.startswith("Title:"):
title = line[6:].strip()
in_highlights = False
elif line.startswith("URL:"):
url = line[4:].strip()
in_highlights = False
elif line.startswith("Published:") or line.startswith("Author:"):
in_highlights = False
elif line.startswith("Highlights:"):
in_highlights = True
elif in_highlights and line.strip() and not line.startswith("..."):
highlights.append(line.strip())
if title and url:
summary = " ".join(highlights)[:LLM_FIELD_LIMIT]
results.append({"title": title, "url": url, "text": summary})
return results
class ExaClient:
def __init__(self):
self.url = "https://mcp.exa.ai/mcp?tools=web_search_exa"
self.headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
self.session_id: str | None = None
self._initialized = False
def initialize(self) -> bool:
try:
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "daily-watchdog", "version": "1.0"},
},
}
r = requests.post(
self.url, json=body, headers=self.headers, timeout=EXA_TIMEOUT
)
r.raise_for_status()
self.session_id = r.headers.get("Mcp-Session-Id", "")
notify = {
"jsonrpc": "2.0",
"method": "notifications/initialized",
}
h = {**self.headers}
if self.session_id:
h["Mcp-Session-Id"] = self.session_id
requests.post(
self.url, json=notify, headers=h, timeout=HTTP_TIMEOUT
)
self._initialized = True
log.info("Exa MCP initialized, session=%s", self.session_id)
return True
except Exception as e:
log.error("Exa init failed: %s", e)
return False
def search(self, query: str, num_results: int = 3) -> list[dict] | None:
if not self._initialized:
return None
try:
body = {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "web_search_exa",
"arguments": {"query": query, "numResults": num_results},
},
}
h = {**self.headers}
if self.session_id:
h["Mcp-Session-Id"] = self.session_id
r = requests.post(
self.url, json=body, headers=h, timeout=EXA_TIMEOUT
)
r.raise_for_status()
text = r.text
results = []
for line in text.split("\n"):
line = line.strip()
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if not payload:
continue
try:
obj = json.loads(payload)
except json.JSONDecodeError:
continue
if "result" not in obj:
continue
content = obj["result"].get("content", [])
for c in content:
raw = c.get("text", "")
parsed = _parse_exa_text_block(raw)
results.extend(parsed)
return results if results else None
except Exception as e:
log.error("Exa search failed for '%s': %s", query, e)
return None
def check_npm_relevance(pkg: str, npm_data: dict) -> bool:
desc = (npm_data.get("description") or "").lower()
keywords = [
"vectorize",
"hindsight",
"replay",
"debugging",
"browser",
"record",
"session",
]
return any(kw in desc for kw in keywords)
def _collect_one_project(proj: dict, exa: ExaClient | None) -> dict:
"""Fetch all sources for one project and summarize with the LLM.
Runs inside a worker thread. Every fetcher already catches its own
exceptions, so one failing source (or a failing LLM call) never raises
out of here and never affects other projects. `exa` is None when the
shared MCP session failed to initialize.
"""
log.info("Collecting data for %s", proj["label"])
data: dict = {
"key": proj["key"],
"label": proj["label"],
"star": proj["star"],
"installed": proj["installed"],
"github_release": None,
"github_commits": None,
"npm": None,
"atom": None,
"exa_results": None,
"source_errors": [],
"sources_used": [],
"llm_summary": None,
}
rel, rel_err = fetch_github_release(proj["github"])
if rel:
data["github_release"] = rel
data["sources_used"].append("GitHub Releases API")
else:
data["source_errors"].append(
f"GitHub Releases API: {rel_err or '请求失败'}"
)
commits, commits_err = fetch_github_commits(proj["github"])
if commits:
data["github_commits"] = commits
data["sources_used"].append("GitHub commits API")
else:
data["source_errors"].append(
f"GitHub commits API: {commits_err or '请求失败'}"
)
npm_data, npm_err = fetch_npm(proj["npm"])
if npm_data:
if proj.get("npm_relevance_check"):
if check_npm_relevance(proj["npm"], npm_data):
data["npm"] = npm_data
data["sources_used"].append("npm registry")
else:
log.info(
"npm package '%s' not relevant, skipping", proj["npm"]
)
data["source_errors"].append(
"npm registry: 包存在但与本 repo 不相关, 已跳过"
)
else:
data["npm"] = npm_data
data["sources_used"].append("npm registry")
else:
data["source_errors"].append(f"npm registry: {npm_err or '请求失败'}")
atom, atom_err = fetch_releases_atom(proj["github"])
if atom:
data["atom"] = atom
data["sources_used"].append("GitHub releases.atom")
else:
data["source_errors"].append(
f"GitHub releases.atom: {atom_err or '请求失败'}"
)
if exa is not None:
query = f"{proj['label']} latest release changelog"
exa_res = exa.search(query)
if exa_res:
data["exa_results"] = exa_res
data["sources_used"].append("Exa web search")
else:
data["source_errors"].append("Exa web search: 无结果或查询失败")
else:
data["source_errors"].append("Exa web search: MCP 初始化失败")
collected_for_llm = {
"github_release": data["github_release"],
"github_commits": data["github_commits"],
"npm": data["npm"],
"atom": data["atom"],
"exa_results": data["exa_results"],
}
data["llm_summary"] = summarize_with_llm(proj["key"], collected_for_llm)
return data
def collect_all() -> tuple[list[dict], dict[str, int], list[str]]:
exa = ExaClient()
exa_ok = exa.initialize()
if not exa_ok:
log.warning("Exa MCP init failed — all projects will note it")
# I/O bound (HTTP + LLM), so threads sidestep the GIL. One hanging
# project cannot stall the run: its future is abandoned after
# COLLECT_PROJECT_TIMEOUT and the thread is left to die on its own.
with futures.ThreadPoolExecutor(max_workers=COLLECT_MAX_WORKERS) as pool:
future_by_index = {
i: pool.submit(_collect_one_project, proj, exa if exa_ok else None)
for i, proj in enumerate(PROJECTS)
}
results_by_index: dict[int, dict] = {}
for i, fut in future_by_index.items():
proj = PROJECTS[i]
try:
results_by_index[i] = fut.result(timeout=COLLECT_PROJECT_TIMEOUT)
except Exception as e:
log.error(
"collect timed out or crashed for %s: %s", proj["label"], e
)
results_by_index[i] = {
"key": proj["key"],
"label": proj["label"],
"star": proj["star"],
"installed": proj["installed"],
"github_release": None,
"github_commits": None,
"npm": None,
"atom": None,
"exa_results": None,
"source_errors": [f"并行抓取超时或异常: {_short_reason(e)}"],
"sources_used": [],
"llm_summary": None,
}
# Preserve PROJECTS order; aggregate per-source stats from the results
# produced by the worker threads (counts are merged here, single-threaded).
project_results = [results_by_index[i] for i in range(len(PROJECTS))]
source_counts: dict[str, int] = {s: 0 for s in CANONICAL_SOURCES}
sources_failed: set[str] = set()
if not exa_ok:
sources_failed.add("Exa web search")
for data in project_results:
for src in data["sources_used"]:
if src in source_counts:
source_counts[src] += 1
for err in data["source_errors"]:
src, _sep, _reason = err.partition(": ")
if src in source_counts:
sources_failed.add(src)
return project_results, source_counts, sorted(sources_failed)
def determine_project_status(data: dict) -> str:
has_any = any(
[
data["github_release"],
data["github_commits"],
data["npm"],
data["atom"],
]
)
if has_any and not data["source_errors"]:
return "ok"
if has_any:
return "partial"
return "failed"
def _split_source_error(err: str) -> tuple[str, str]:
src, sep, reason = err.partition(": ")
if not sep:
return err, "未知原因"
return src, reason
def render_report(
date_str: str,
project_results: list[dict],
source_counts: dict[str, int],
) -> str:
lines = []
lines.append(f"# AI Agent 升级迭代日报 - {date_str}")
lines.append("")
lines.append("## 数据源")
lines.append("")
total = len(PROJECTS)
for src in CANONICAL_SOURCES:
n = source_counts.get(src, 0)
lines.append(f"- {src} ({n}/{total})")
lines.append("")
all_failed = all(determine_project_status(d) == "failed" for d in project_results)
if all_failed:
lines.append("> ⚠️ 所有项目全部数据源抓取失败")
lines.append("")
for data in project_results:
status = determine_project_status(data)
star_mark = "" if data["star"] else ""
install_mark = f" (已装 {data['installed']})" if data["installed"] else " (未装)"
lines.append(f"## {data['label']}{star_mark}{install_mark}")
lines.append("")
if status == "failed":
lines.append("⚠️ 全部数据源抓取失败:")
lines.append("")
for err in data["source_errors"]:
src, reason = _split_source_error(err)
lines.append(f"⚠️ 抓取失败: {src}{reason}")
lines.append("")
continue
if data["github_release"]:
rel = data["github_release"]
lines.append(f"**Latest Release:** `{rel['tag_name']}`")
lines.append(f"**发布日期:** {rel['published_at']}")
if rel["name"]:
lines.append(f"**Release 名称:** {rel['name']}")
lines.append("")
if rel["body"]:
lines.append("**Changelog 摘要:**")
lines.append(f"> {rel['body'][:LLM_FIELD_LIMIT]}")
lines.append("")
elif data["github_commits"]:
lines.append("**最近 Commits (无 release):**")
for c in data["github_commits"]:
lines.append(f"- [{c['date'][:10]}] {c['message']}")
lines.append("")
else:
lines.append("⚠️ GitHub 数据不可用")
for err in data["source_errors"]:
if err.startswith("GitHub Releases API:") or err.startswith(
"GitHub commits API:"
):
src, reason = _split_source_error(err)
lines.append(f"⚠️ 抓取失败: {src}{reason}")
lines.append("")
llm = data["llm_summary"] or {}
if llm.get("summary_zh"):
lines.append(f"**LLM 总结:** {llm['summary_zh']}")
lines.append("")
lines.append(
f"**Breaking Changes:** {llm.get('breaking_reason', 'LLM 总结失败')}"
)
lines.append("")
if data["npm"]:
npm = data["npm"]
lines.append(f"**npm latest:** `{npm['version']}` — {npm['description']}")
lines.append("")
elif any(
e.startswith("npm registry: 包存在但与本 repo 不相关")
for e in data["source_errors"]
):
lines.append("**npm:** ⚠️ 包存在但与本 repo 不相关, 已跳过")
lines.append("")
elif any(e.startswith("npm registry:") for e in data["source_errors"]):
err = next(
e for e in data["source_errors"] if e.startswith("npm registry:")
)
src, reason = _split_source_error(err)
lines.append(f"**npm:** ⚠️ 抓取失败: {src}{reason}")
lines.append("")
if data["atom"]:
atom = data["atom"]
lines.append(f"**releases.atom 最新:** {atom['title']} ({atom['updated']})")
lines.append("")
elif any(
e.startswith("GitHub releases.atom:") for e in data["source_errors"]
):
err = next(
e
for e in data["source_errors"]
if e.startswith("GitHub releases.atom:")
)
src, reason = _split_source_error(err)
lines.append(f"**releases.atom:** ⚠️ 抓取失败: {src}{reason}")
lines.append("")
llm = data["llm_summary"] or {}
relevance = llm.get("relevance_note", "")
if relevance:
lines.append(f"★ **相关性:** {relevance}")
lines.append("")
else:
lines.append("★ **相关性:** LLM 总结失败, 相关性待评估")
lines.append("")
if data["exa_results"]:
lines.append("**Exa 搜索结果:**")
lines.append("")
for item in data["exa_results"][:3]:
title = item.get("title", "无标题")
url = item.get("url", "")
text = item.get("text", "")[:LLM_MESSAGE_LIMIT]
lines.append(f"> [{title}]({url}): {text}")
lines.append("")
elif any(e.startswith("Exa web search:") for e in data["source_errors"]):
err = next(
e for e in data["source_errors"] if e.startswith("Exa web search:")
)
src, reason = _split_source_error(err)
lines.append(f"**Exa 搜索:** ⚠️ 抓取失败: {src}{reason}")
lines.append("")
lines.append("---")
lines.append("")
return "\n".join(lines)
def save_to_sqlite(date_str: str, markdown: str, meta: dict) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH))
try:
conn.execute(
"""CREATE TABLE IF NOT EXISTS reports (
date TEXT PRIMARY KEY,
markdown TEXT NOT NULL,
meta_json TEXT NOT NULL,
created_at TEXT NOT NULL
)"""
)
now = datetime.now().isoformat()
conn.execute(
"INSERT OR REPLACE INTO reports (date, markdown, meta_json, created_at) VALUES (?, ?, ?, ?)",
(date_str, markdown, json.dumps(meta, ensure_ascii=False), now),
)
conn.commit()
finally:
conn.close()
def save_markdown(date_str: str, markdown: str) -> Path:
LOGS_DIR.mkdir(parents=True, exist_ok=True)
path = LOGS_DIR / f"daily-agent-watchdog-{date_str}.md"
path.write_text(markdown, encoding="utf-8")
return path
def _watchdog_already_running() -> bool:
try:
out = subprocess.check_output(
["pgrep", "-f", "daily_watchdog.py"], text=True, timeout=10
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
return False
own = str(os.getpid())
for pid in out.split():
pid = pid.strip()
if not pid or pid == own:
continue
# 只认真正的 python 脚本进程, 排除命令行里恰好提到该文件名的进程
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
argv = f.read().split(b"\x00")
except OSError:
continue
if not argv or not argv[0]:
continue
exe = os.path.basename(argv[0].decode(errors="replace"))
if exe.startswith("python"):
return True
return False
def _today_report_ok(date_str: str) -> bool:
if not DB_PATH.exists():
return False
try:
conn = sqlite3.connect(str(DB_PATH))
try:
row = conn.execute(
"SELECT meta_json FROM reports WHERE date = ?", (date_str,)
).fetchone()
finally:
conn.close()
except Exception as e:
log.warning("fix-if-missing check failed: %s", e)
return False
if not row:
return False
try:
meta = json.loads(row[0])
except (json.JSONDecodeError, TypeError):
return False
projects = meta.get("projects", {})
ok_count = sum(
1
for st in projects.values()
if isinstance(st, dict) and st.get("status") != "failed"
)
return ok_count >= 2
def main() -> int:
fix_if_missing = "--fix-if-missing" in sys.argv[1:]
date_str = datetime.now().strftime("%Y-%m-%d")
if _watchdog_already_running():
log.warning("Another daily_watchdog instance is already running — skip")
return 0
if fix_if_missing and _today_report_ok(date_str):
log.info("today's report already OK — skip full run (--fix-if-missing)")
return 0
log.info("Starting daily watchdog for %s", date_str)
project_results, source_counts, sources_failed = collect_all()
all_failed = all(determine_project_status(d) == "failed" for d in project_results)
markdown = render_report(date_str, project_results, source_counts)
meta = {
"sources_ok": sorted(k for k, v in source_counts.items() if v > 0),
"sources_failed": sources_failed,
"source_counts": source_counts,
"projects": {
d["key"]: {
"status": determine_project_status(d),
"sources_used": d["sources_used"],
}
for d in project_results
},
}
save_to_sqlite(date_str, markdown, meta)
md_path = save_markdown(date_str, markdown)
log.info("Report saved to %s", md_path)
log.info("SQLite record saved to %s", DB_PATH)
if all_failed:
log.error("All projects failed — exit code 1")
return 1
log.info("Daily watchdog completed successfully")
return 0
if __name__ == "__main__":
sys.exit(main())

98
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Restart the blog-app Flask server on 0.0.0.0:8090.
#
# Guarantees:
# - PID file (blog-app/app.pid) tracks the live server.
# - Old server is killed before new one starts (no "already running" ambiguity).
# - Health check loops on /healthz up to 30s (1s interval). 启动失败立即报错.
# - On failure: kill the failed new PID, restore the OLD server if we recorded one,
# print the last 50 lines of /tmp/blog-app.log, exit 1.
# - Does NOT touch daily_watchdog.py / cron (independent lifecycle).
set -u
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PID_FILE="$APP_DIR/app.pid"
LOG_FILE="/tmp/blog-app.log"
HEALTHZ_URL="http://127.0.0.1:8090/healthz"
HEALTHZ_TIMEOUT_S=30
PYTHON_BIN="/usr/bin/python3"
cd "$APP_DIR"
# flask/requests live in the real user's site-packages; ensure python sees them
export HOME=/home/yi
log() { printf '[deploy] %s\n' "$*"; }
fail() {
log "ERROR: $*"
log "----- last 50 lines of $LOG_FILE -----"
tail -n 50 "$LOG_FILE" 2>/dev/null || log "(no log file)"
exit 1
}
# --- 1. Snapshot current PID so we can roll back if the new boot fails ---
OLD_PID=""
if [[ -f "$PID_FILE" ]]; then
OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
fi
# --- 2. Kill old server if PID is alive ---
if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then
log "stopping old server pid=$OLD_PID"
kill "$OLD_PID" 2>/dev/null || true
# wait up to 5s for graceful exit, then SIGKILL
for _ in 1 2 3 4 5; do
if ! kill -0 "$OLD_PID" 2>/dev/null; then break; fi
sleep 1
done
if kill -0 "$OLD_PID" 2>/dev/null; then
log "old pid $OLD_PID did not exit, sending SIGKILL"
kill -9 "$OLD_PID" 2>/dev/null || true
fi
fi
# --- 3. Start new server, record PID ---
log "starting new server: $PYTHON_BIN app.py"
nohup "$PYTHON_BIN" app.py > "$LOG_FILE" 2>&1 &
NEW_PID=$!
echo "$NEW_PID" > "$PID_FILE"
log "new pid=$NEW_PID"
# --- 4. Health check loop ---
healthz_ok=0
for ((i=1; i<=HEALTHZ_TIMEOUT_S; i++)); do
# Is the process still alive?
if ! kill -0 "$NEW_PID" 2>/dev/null; then
fail "server process $NEW_PID died during startup"
fi
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 2 "$HEALTHZ_URL" 2>/dev/null || echo '000')"
if [[ "$code" == "200" ]]; then
healthz_ok=1
log "healthz OK after ${i}s"
break
fi
sleep 1
done
# --- 5. Rollback on failure ---
if [[ "$healthz_ok" -ne 1 ]]; then
log "health check failed after ${HEALTHZ_TIMEOUT_S}s, rolling back"
kill "$NEW_PID" 2>/dev/null || true
sleep 1
kill -9 "$NEW_PID" 2>/dev/null || true
rm -f "$PID_FILE"
if [[ -n "$OLD_PID" ]]; then
log "attempting to restart previous server (old pid=$OLD_PID)"
nohup "$PYTHON_BIN" app.py > "$LOG_FILE" 2>&1 &
ROLLBACK_PID=$!
echo "$ROLLBACK_PID" > "$PID_FILE"
log "rollback pid=$ROLLBACK_PID"
fi
fail "deploy failed; rolled back (see log above)"
fi
log "deploy succeeded pid=$NEW_PID"
curl -sS -o /dev/null -w 'local:%{http_code}\n' "$HEALTHZ_URL"

516
scripts/fetch_data.py Normal file
View File

@@ -0,0 +1,516 @@
#!/usr/bin/env python3
"""Fetch GitHub project data and store it in a local SQLite database.
Fetches repository metadata, the latest 5 releases, and the latest 10
issues for real projects via the GitHub REST API (no token required),
and generates mock data for the fictional "openclaw" project.
Stage 4.3 (schema unfreeze): the script is now incremental-friendly.
- Tables are created with CREATE TABLE IF NOT EXISTS (never DROPped).
- Rows are written with INSERT OR REPLACE keyed on UNIQUE indexes:
projects.full_name, releases(project_id, tag_name),
issues(project_id, html_url).
- releases/issues carry a `fetched_at` TEXT column (ISO-8601 UTC).
- --incremental: skip repos whose latest fetched_at is fresher than
the repo's GitHub `updated_at` (no new upstream activity).
- --project <name>: only refresh a single project (by `name` or
`full_name`).
Usage:
python3 fetch_data.py # full refresh, no drops
python3 fetch_data.py --incremental # skip up-to-date repos
python3 fetch_data.py --project opencode # single project
python3 fetch_data.py --incremental --project opencode
"""
import argparse
import logging
import os
import sqlite3
import sys
from datetime import datetime, timezone
import requests
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)
GITHUB_API = "https://api.github.com"
HEADERS = {
"Accept": "application/vnd.github+json",
"User-Agent": "opencode-blog-showcase-fetcher",
}
# Paths are relative to this script's location so the script is portable.
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.normpath(
os.path.join(SCRIPT_DIR, "..", "..", "data", "projects.db")
)
REAL_REPOS = ["NousResearch/hermes-agent", "sst/opencode"]
RELEASES_LIMIT = 5
ISSUES_LIMIT = 10
# ---------------------------------------------------------------------------
# Mock data for the fictional "openclaw" project.
# ---------------------------------------------------------------------------
MOCK_PROJECT = {
"name": "openclaw",
"full_name": "openclaw/openclaw",
"description": "OpenClaw - a mock open-source CLI agent harness used for demo purposes.",
"stars": 1284,
"forks": 96,
"language": "Python",
"html_url": "https://github.com/openclaw/openclaw",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2026-08-10T08:30:00Z",
"source": "mock",
}
MOCK_RELEASES = [
{
"tag_name": "v0.3.0",
"name": "v0.3.0 - Streaming tool calls",
"body": "Adds streaming support for tool calls, improved retry logic, "
"and a new `--dry-run` flag for the agent runner.",
"published_at": "2026-07-20T12:00:00Z",
"html_url": "https://github.com/openclaw/openclaw/releases/tag/v0.3.0",
},
{
"tag_name": "v0.2.1",
"name": "v0.2.1 - Bugfix release",
"body": "Fixes a race condition in the scheduler and corrects "
"token-count reporting for long sessions.",
"published_at": "2026-06-05T09:00:00Z",
"html_url": "https://github.com/openclaw/openclaw/releases/tag/v0.2.1",
},
{
"tag_name": "v0.2.0",
"name": "v0.2.0 - Plugin system",
"body": "Introduces the plugin system with sandboxed execution and "
"a declarative manifest format.",
"published_at": "2026-05-01T14:30:00Z",
"html_url": "https://github.com/openclaw/openclaw/releases/tag/v0.2.0",
},
{
"tag_name": "v0.1.0",
"name": "v0.1.0 - Initial public release",
"body": "First public release of OpenClaw with basic agent loop, "
"file editing tools, and shell execution.",
"published_at": "2026-03-12T16:00:00Z",
"html_url": "https://github.com/openclaw/openclaw/releases/tag/v0.1.0",
},
]
MOCK_ISSUES = [
{
"title": "Agent loop hangs when tool output exceeds context window",
"state": "open",
"created_at": "2026-08-01T11:20:00Z",
"html_url": "https://github.com/openclaw/openclaw/issues/42",
"user": "mock-user-alice",
},
{
"title": "Add support for custom system prompts per project",
"state": "open",
"created_at": "2026-07-18T08:45:00Z",
"html_url": "https://github.com/openclaw/openclaw/issues/38",
"user": "mock-user-bob",
},
{
"title": "Scheduler race condition under parallel subagents",
"state": "closed",
"created_at": "2026-06-02T15:10:00Z",
"html_url": "https://github.com/openclaw/openclaw/issues/31",
"user": "mock-user-carol",
},
{
"title": "Documentation: missing example for plugin manifest",
"state": "closed",
"created_at": "2026-05-20T10:05:00Z",
"html_url": "https://github.com/openclaw/openclaw/issues/25",
"user": "mock-user-dave",
},
]
# ---------------------------------------------------------------------------
# GitHub API helpers
# ---------------------------------------------------------------------------
def github_get(path, params=None):
"""GET a GitHub API path, returning parsed JSON or None on failure."""
url = f"{GITHUB_API}{path}"
try:
resp = requests.get(url, headers=HEADERS, params=params, timeout=20)
if resp.status_code == 403 and "rate limit" in resp.text.lower():
logger.error("GitHub rate limit exceeded for %s", url)
return None
resp.raise_for_status()
return resp.json()
except requests.RequestException as exc:
logger.error("Request failed for %s: %s", url, exc)
return None
def fetch_repo(full_name):
"""Fetch normalized metadata for one repository."""
data = github_get(f"/repos/{full_name}")
if not data:
return None
return {
"name": data.get("name", full_name.split("/")[-1]),
"full_name": data.get("full_name", full_name),
"description": data.get("description") or "",
"stars": data.get("stargazers_count", 0),
"forks": data.get("forks_count", 0),
"language": data.get("language") or "",
"html_url": data.get("html_url", ""),
"created_at": data.get("created_at", ""),
"updated_at": data.get("updated_at", ""),
"source": "real",
}
def fetch_releases(full_name, limit=RELEASES_LIMIT):
"""Fetch the latest `limit` releases for a repository."""
data = github_get(f"/repos/{full_name}/releases", params={"per_page": limit})
if not data:
return []
releases = []
for rel in data[:limit]:
releases.append(
{
"tag_name": rel.get("tag_name", ""),
"name": rel.get("name") or rel.get("tag_name", ""),
"body": rel.get("body") or "",
"published_at": rel.get("published_at") or rel.get("created_at", ""),
"html_url": rel.get("html_url", ""),
}
)
return releases
def fetch_issues(full_name, limit=ISSUES_LIMIT):
"""Fetch the latest `limit` issues (excluding pull requests)."""
data = github_get(
f"/repos/{full_name}/issues",
params={"state": "all", "per_page": limit * 2, "sort": "created",
"direction": "desc"},
)
if not data:
return []
issues = []
for issue in data:
# The issues endpoint also returns pull requests; skip them.
if "pull_request" in issue:
continue
issues.append(
{
"title": issue.get("title", ""),
"state": issue.get("state", ""),
"created_at": issue.get("created_at", ""),
"html_url": issue.get("html_url", ""),
"user": (issue.get("user") or {}).get("login", ""),
}
)
if len(issues) >= limit:
break
return issues
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
SCHEMA = """
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
full_name TEXT NOT NULL UNIQUE,
description TEXT,
stars INTEGER DEFAULT 0,
forks INTEGER DEFAULT 0,
language TEXT,
html_url TEXT,
created_at TEXT,
updated_at TEXT,
source TEXT NOT NULL DEFAULT 'real',
starred INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS releases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
tag_name TEXT,
name TEXT,
body TEXT,
published_at TEXT,
html_url TEXT,
fetched_at TEXT,
FOREIGN KEY (project_id) REFERENCES projects (id)
);
CREATE TABLE IF NOT EXISTS issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
title TEXT NOT NULL,
state TEXT,
created_at TEXT,
html_url TEXT,
user TEXT,
fetched_at TEXT,
FOREIGN KEY (project_id) REFERENCES projects (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_releases_project_tag
ON releases(project_id, tag_name);
CREATE UNIQUE INDEX IF NOT EXISTS idx_issues_project_url
ON issues(project_id, html_url);
"""
# Columns added in Stage 4.3 via ALTER TABLE when upgrading an existing DB.
_MIGRATIONS = (
("projects", "starred", "ALTER TABLE projects ADD COLUMN starred INTEGER DEFAULT 0"),
("releases", "fetched_at", "ALTER TABLE releases ADD COLUMN fetched_at TEXT"),
("issues", "fetched_at", "ALTER TABLE issues ADD COLUMN fetched_at TEXT"),
)
def _column_exists(conn, table, column):
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(r[1] == column for r in rows)
def init_db(db_path):
"""Open (or create) the database, apply schema + migrations, return conn.
Never drops tables. Existing rows are preserved; missing columns are
added via ALTER TABLE (Stage 4.3 schema unfreeze).
"""
os.makedirs(os.path.dirname(db_path), exist_ok=True)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA foreign_keys = ON;")
conn.executescript(SCHEMA)
for table, column, ddl in _MIGRATIONS:
if not _column_exists(conn, table, column):
logger.info("Migrating: %s", ddl)
conn.execute(ddl)
conn.commit()
logger.info("Initialized database at %s", db_path)
return conn
def _utcnow_iso():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def upsert_project(conn, project):
"""INSERT OR REPLACE a project row keyed on UNIQUE(full_name).
Returns the project id. Preserves the existing id (and any columns not
in the INSERT, like `starred`) by reusing the current row when present.
"""
cur = conn.cursor()
existing = cur.execute(
"SELECT id FROM projects WHERE full_name = ?", (project["full_name"],)
).fetchone()
if existing:
cur.execute(
"""
UPDATE projects
SET name = ?, description = ?, stars = ?, forks = ?,
language = ?, html_url = ?, created_at = ?, updated_at = ?,
source = ?
WHERE full_name = ?
""",
(
project["name"], project["description"], project["stars"],
project["forks"], project["language"], project["html_url"],
project["created_at"], project["updated_at"], project["source"],
project["full_name"],
),
)
return existing[0]
cur.execute(
"""
INSERT OR REPLACE INTO projects
(name, full_name, description, stars, forks, language,
html_url, created_at, updated_at, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
project["name"], project["full_name"], project["description"],
project["stars"], project["forks"], project["language"],
project["html_url"], project["created_at"], project["updated_at"],
project["source"],
),
)
return cur.lastrowid
def upsert_releases(conn, project_id, releases, fetched_at):
"""INSERT OR REPLACE releases keyed on UNIQUE(project_id, tag_name)."""
conn.executemany(
"""
INSERT OR REPLACE INTO releases
(project_id, tag_name, name, body, published_at, html_url, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(project_id, r["tag_name"], r["name"], r["body"],
r["published_at"], r["html_url"], fetched_at)
for r in releases
],
)
def upsert_issues(conn, project_id, issues, fetched_at):
"""INSERT OR REPLACE issues keyed on UNIQUE(project_id, html_url)."""
conn.executemany(
"""
INSERT OR REPLACE INTO issues
(project_id, title, state, created_at, html_url, user, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(project_id, i["title"], i["state"], i["created_at"],
i["html_url"], i["user"], fetched_at)
for i in issues
],
)
def get_last_fetched_at(conn, full_name):
"""Most recent fetched_at across releases+issues for a project, or None."""
row = conn.execute(
"""
SELECT MAX(fetched_at) FROM (
SELECT r.fetched_at AS fetched_at
FROM releases r
JOIN projects p ON p.id = r.project_id
WHERE p.full_name = ?
UNION ALL
SELECT i.fetched_at AS fetched_at
FROM issues i
JOIN projects p ON p.id = i.project_id
WHERE p.full_name = ?
)
""",
(full_name, full_name),
).fetchone()
return row[0] if row else None
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def _resolve_targets(project_filter):
"""Return list of (kind, payload) tuples to process.
kind == "real" -> payload is a GitHub "owner/repo" full_name
kind == "mock" -> payload is the mock project dict
"""
if project_filter is None:
return ([("real", r) for r in REAL_REPOS]
+ [("mock", MOCK_PROJECT)])
needle = project_filter.lower()
targets = []
for full_name in REAL_REPOS:
short = full_name.split("/")[-1].lower()
if needle in (full_name.lower(), short):
targets.append(("real", full_name))
if needle in (MOCK_PROJECT["full_name"].lower(), MOCK_PROJECT["name"].lower()):
targets.append(("mock", MOCK_PROJECT))
if not targets:
logger.error("No project matches --project %r", project_filter)
sys.exit(2)
return targets
def _should_skip_incremental(conn, full_name, project):
"""Incremental mode: skip when upstream hasn't moved since last fetch.
GitHub's `updated_at` on the repo is bumped by pushes/releases, so if
our last fetched_at is newer, there is nothing new to pull.
"""
last = get_last_fetched_at(conn, full_name)
if not last:
return False
upstream = project.get("updated_at") or ""
if upstream and last >= upstream:
logger.info(
" -> skip %s (last fetched %s >= upstream updated_at %s)",
full_name, last, upstream,
)
return True
return False
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--incremental",
action="store_true",
help="Skip repos whose last fetched_at is newer than GitHub updated_at.",
)
parser.add_argument(
"--project",
metavar="NAME",
default=None,
help="Only refresh the named project (short name or owner/repo).",
)
args = parser.parse_args(argv)
conn = init_db(DB_PATH)
fetched_at = _utcnow_iso()
try:
for kind, payload in _resolve_targets(args.project):
if kind == "mock":
logger.info("Upserting mock project: %s", payload["name"])
pid = upsert_project(conn, payload)
upsert_releases(conn, pid, MOCK_RELEASES, fetched_at)
upsert_issues(conn, pid, MOCK_ISSUES, fetched_at)
continue
full_name = payload
logger.info("Fetching repo: %s", full_name)
project = fetch_repo(full_name)
if project is None:
logger.error("Skipping %s (fetch failed)", full_name)
continue
if args.incremental and _should_skip_incremental(
conn, full_name, project
):
continue
releases = fetch_releases(full_name)
issues = fetch_issues(full_name)
logger.info(
" -> %d releases, %d issues", len(releases), len(issues)
)
pid = upsert_project(conn, project)
upsert_releases(conn, pid, releases, fetched_at)
upsert_issues(conn, pid, issues, fetched_at)
conn.commit()
# Summary.
cur = conn.cursor()
for table in ("projects", "releases", "issues"):
count = cur.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
logger.info("Table %-10s: %d rows", table, count)
except sqlite3.Error as exc:
conn.rollback()
logger.error("Database error: %s", exc)
sys.exit(1)
finally:
conn.close()
logger.info("Done. Database written to %s", DB_PATH)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Idempotent: 重复跑不会重复加 cron 条目 (用 crontab -l 检查已存在则跳过)
# 安装 AI Agent 升级日报的每日 cron 任务 (幂等: 已存在则跳过)。
# 每天 09:00 (Asia/Shanghai) 运行 scripts/daily_watchdog.py,
# 输出追加到 ~/opencode-blog-showcase/logs/daily-agent-watchdog.log。
# 绝不覆盖现有 crontab 条目。
set -euo pipefail
ENTRY='0 9 * * * cd /home/yi/opencode-blog-showcase/blog-app && /usr/bin/python3 scripts/daily_watchdog.py >> /home/yi/opencode-blog-showcase/logs/daily-agent-watchdog.log 2>&1'
if crontab -l 2>/dev/null | grep -F "daily_watchdog.py" >/dev/null; then
echo "already installed, skipping"
exit 0
fi
(crontab -l 2>/dev/null; echo "$ENTRY") | crontab -
echo "installed"