样本 - openwork (different-ai/openwork) 23.2k★ 能力分发层,DCO + evals + worlds - orca (stablyai/orca) 58.8k★ 并行编排外壳,5000 issue 实为团队自开任务板 - maka (apache/maka) 4.4k★ 执行记录层,Apache 孵化但无正式 release 档案 - 三份五维档案填完,risk.claim_vs_reality 均已核实到证据 - ipollowork 象限坐标从占位 9/9/9/9 校准为 8/7/7/7(与新样本同一标尺) 文章 - 新增横评文《四个 Agent 工作台的路线分野:把「多引擎中立」拆开看》(emlog id=99) - 总纲文回填四项目矩阵 + 双视图象限图 (emlog id=98) 镜像 - gitinbox/maka 已推送(public,source-only 快照 12 MB) - openwork(291MB) / orca(671MB) 受 50KB/s 带宽约束,档案标 mode: pending 规则 - license-tiers.yaml 新增「目录切分许可」降级流程(OpenWork 判例: 排除 ee/ 后为纯 MIT → public,完整镜像 → private) - registry.yaml openwork 可见性改 public + mirror_excludes: ["ee/"] 脚本修复 - _common.py 新增 upsert_block:手工整块覆盖 ecosystem 删掉 metrics 时能自愈 - mirror_sync.sh:SOURCE_ONLY 未初始化(set -u 下偶发炸); 浅仓推送被服务端拒绝 → 自动 --unshallow,失败降级快照; 量体积前先 repack(此前新 commit 全是 loose object,恒打印 0 MB) - build_matrix.py:成熟度长文本移出表格(新增 maturity_level 短标签); 象限图标签靠右时翻转到左侧防溢出;SVG 加 max-width 防窄屏溢出; 文章索引改为自动生成(原先手抄,横评文发了索引里却没有) - publish_emlog.py:ID 回写按「有无同名档案」分流, 00-* 跨项目文章落到 articles/<id>.meta.yaml(此前 ID 无处可写)
204 lines
7.9 KiB
Python
Executable File
204 lines
7.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""把 articles/ 下的 Markdown 源文发布(或更新)到 emlog 可巴蒂栏目。
|
||
|
||
用法
|
||
----
|
||
python3 scripts/publish_emlog.py --article ipollowork
|
||
python3 scripts/publish_emlog.py --article 00-overview
|
||
python3 scripts/publish_emlog.py --article ipollowork --draft y # 存草稿
|
||
|
||
两个关键设计
|
||
------------
|
||
1. **防空保护点放在"本地正文非空",不是"先 detail 取回线上全文"**。
|
||
emlog 的 article_update 在 content 为空时会把正文清空,所以必须有保护。
|
||
但保护不能建立在 detail 上,原因有二:
|
||
a) 本脚本的 content 永远来自本地 Markdown 源文,不存在"忘了传"的情况;
|
||
b) detail 接口**查不到草稿**(实测:草稿 id 返回 400 Article not found,
|
||
已发布文章正常)。若以 detail 为门槛,草稿将永远无法转正。
|
||
因此 detail 降级为可选的健全性检查:取得到就对比长度,取不到不阻断。
|
||
2. **文章 ID 回写**。
|
||
有同名项目档案(data/projects/<id>.yaml)→ 回写到档案的 article 块;
|
||
没有(总纲文 00-overview、横评文 00-comparison 这类跨项目文章)
|
||
→ 回写到 articles/<id>.meta.yaml。
|
||
有了 ID 才能做"更新"而不是每次都发一篇新的。
|
||
判断顺序不能反:先按"是不是项目文"分流,再决定落哪里,
|
||
否则新增一篇横评类文章时 ID 会无处可写,下次发布就变成重复发一篇。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
sys.path.insert(0, "/Users/figmarlite/.workbuddy/skills/emlog-blog-publisher/scripts")
|
||
|
||
import yaml # noqa: E402
|
||
from _common import ARTICLES, PROJECTS, today, update_block # noqa: E402
|
||
import publish_article as emlog # noqa: E402
|
||
|
||
DEFAULT_SORT_ID = 17 # 可巴蒂
|
||
|
||
|
||
# ---------------------------------------------------------------- 转换
|
||
def md_to_html(md: str) -> str:
|
||
"""Markdown → HTML。emlog 只吃 HTML。"""
|
||
try:
|
||
import markdown
|
||
return markdown.markdown(
|
||
md, extensions=["tables", "fenced_code", "toc", "attr_list", "sane_lists"]
|
||
)
|
||
except ImportError:
|
||
sys.exit("缺少 markdown 库:pip install markdown")
|
||
|
||
|
||
def split_title(md: str) -> tuple[str, str]:
|
||
"""取第一个一级标题作为文章标题,其余作为正文。"""
|
||
lines = md.lstrip().split("\n")
|
||
if lines and lines[0].startswith("# "):
|
||
return lines[0][2:].strip(), "\n".join(lines[1:]).lstrip("\n")
|
||
return "未命名", md
|
||
|
||
|
||
def to_excerpt(md: str, limit: int = 110) -> str:
|
||
"""摘一段纯文本作为摘要:跳过标题与表格,取第一段实质内容。"""
|
||
for line in md.split("\n"):
|
||
s = line.strip()
|
||
if not s or s.startswith(("#", "|", ">", "-", "*", "!")) or s.startswith("<"):
|
||
continue
|
||
s = re.sub(r"[*`_]", "", s)
|
||
return s[:limit]
|
||
return ""
|
||
|
||
|
||
# ---------------------------------------------------------------- ID 读写
|
||
def meta_path(aid: str) -> Path:
|
||
return ARTICLES / f"{aid}.meta.yaml"
|
||
|
||
|
||
def read_emlog_id(aid: str) -> int | None:
|
||
p = PROJECTS / f"{aid}.yaml"
|
||
if p.exists():
|
||
d = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||
v = (d.get("article") or {}).get("emlog_id")
|
||
return int(v) if v else None
|
||
mp = meta_path(aid)
|
||
if mp.exists():
|
||
return (yaml.safe_load(mp.read_text(encoding="utf-8")) or {}).get("emlog_id")
|
||
return None
|
||
|
||
|
||
def write_emlog_id(aid: str, emlog_id: int) -> None:
|
||
url = f"{emlog.BLOG_URL}/?post={emlog_id}"
|
||
p = PROJECTS / f"{aid}.yaml"
|
||
|
||
if not p.exists():
|
||
# 跨项目文章(总纲 / 横评 / 专题):没有档案可挂,单独存 meta。
|
||
mp = meta_path(aid)
|
||
mp.write_text(
|
||
f"# 发布元数据(由 scripts/publish_emlog.py 维护)\n"
|
||
f"# 本文不属于单个项目,ID 不落在 data/projects/ 下\n"
|
||
f"emlog_id: {emlog_id}\nurl: \"{url}\"\nlast_published: \"{today()}\"\n",
|
||
encoding="utf-8",
|
||
)
|
||
print(f" 已写入 {mp.name}")
|
||
return
|
||
|
||
text = p.read_text(encoding="utf-8")
|
||
try:
|
||
text = update_block(text, "article", {
|
||
"emlog_id": emlog_id, "published_at": today(), "url": url,
|
||
})
|
||
p.write_text(text, encoding="utf-8")
|
||
print(f" 已回写 emlog_id 到档案")
|
||
except ValueError as e:
|
||
print(f" [warn] 回写失败:{e}")
|
||
|
||
|
||
# ---------------------------------------------------------------- 发布
|
||
def do_publish(aid: str, title: str, html: str, sort_id: int, tags: str, draft: str, excerpt: str) -> None:
|
||
res = emlog.publish_article(
|
||
title=title, content=html, sort_id=sort_id,
|
||
excerpt=excerpt, tags=tags, draft=draft,
|
||
)
|
||
if res.get("code") != 0:
|
||
print(f" 发布失败:{res.get('msg')}")
|
||
sys.exit(1)
|
||
new_id = int(res.get("data", {}).get("article_id") or 0)
|
||
print(f" 已发布 id={new_id} {emlog.BLOG_URL}/?post={new_id}")
|
||
write_emlog_id(aid, new_id)
|
||
|
||
|
||
def do_update(aid: str, emlog_id: int, title: str, html: str, sort_id: int, tags: str, draft: str, excerpt: str) -> None:
|
||
"""更新既有文章。
|
||
|
||
关于"emlog update 不传 content 会清空正文"这个坑:
|
||
保护点应该是**本地正文非空**,而不是"先 detail 取回线上全文"。
|
||
两个理由——
|
||
1. 本脚本的 content 永远来自本地 Markdown 源文,不存在"忘了传"的情况;
|
||
2. detail 接口查不到**草稿**(实测:草稿 id 返回 400 Article not found,
|
||
已发布文章正常)。若把保护建立在 detail 上,草稿就永远无法转正。
|
||
所以 detail 降级为可选的健全性检查:取得到就对比一下长度,取不到也不阻断。
|
||
"""
|
||
print(f" 发现已有文章 id={emlog_id},走更新流程")
|
||
if not html.strip():
|
||
print(" 本地正文为空,拒绝更新(那会清空线上内容)")
|
||
sys.exit(1)
|
||
|
||
det = emlog.get_article_detail(emlog_id)
|
||
if det.get("code") == 0:
|
||
# 注意返回结构是 data.article.content,不是 data.content
|
||
art = (det.get("data") or {}).get("article") or {}
|
||
existing = art.get("content") or ""
|
||
print(f" 线上现有正文 {len(existing)} 字符 → 将覆盖为 {len(html)} 字符")
|
||
else:
|
||
print(f" 未能取回线上正文({det.get('msg', '未知')});"
|
||
f"本地正文非空,继续更新")
|
||
|
||
res = emlog.update_article(
|
||
article_id=emlog_id, title=title, content=html,
|
||
sort_id=sort_id, excerpt=excerpt, tags=tags, draft=draft,
|
||
)
|
||
if res.get("code") != 0:
|
||
print(f" 更新失败:{res.get('msg')}")
|
||
sys.exit(1)
|
||
print(f" 已更新 {emlog.BLOG_URL}/?post={emlog_id}")
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--article", required=True, help="articles/ 下的文件名(不含 .md)")
|
||
ap.add_argument("--sort-id", type=int, default=DEFAULT_SORT_ID)
|
||
ap.add_argument("--tags", default="")
|
||
ap.add_argument("--draft", default="n", choices=["y", "n"])
|
||
args = ap.parse_args()
|
||
|
||
md_path = ARTICLES / f"{args.article}.md"
|
||
if not md_path.exists():
|
||
print(f"文章不存在:{md_path}")
|
||
return 1
|
||
|
||
md = md_path.read_text(encoding="utf-8")
|
||
title, body = split_title(md)
|
||
html = md_to_html(body)
|
||
|
||
print(f"==> {args.article}")
|
||
print(f" 标题:{title}")
|
||
print(f" HTML {len(html)} 字符")
|
||
|
||
# 摘要要从正文里摘,不是从标题 —— 传标题进去等于没写摘要
|
||
excerpt = to_excerpt(body)
|
||
|
||
emlog_id = read_emlog_id(args.article)
|
||
if emlog_id:
|
||
do_update(args.article, emlog_id, title, html, args.sort_id, args.tags, args.draft, excerpt)
|
||
else:
|
||
do_publish(args.article, title, html, args.sort_id, args.tags, args.draft, excerpt)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|