initial: blog-app snapshot
This commit is contained in:
917
scripts/daily_watchdog.py
Executable file
917
scripts/daily_watchdog.py
Executable 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
98
scripts/deploy.sh
Executable 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
516
scripts/fetch_data.py
Normal 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()
|
||||
17
scripts/install_watchdog_cron.sh
Executable file
17
scripts/install_watchdog_cron.sh
Executable 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"
|
||||
Reference in New Issue
Block a user