track: 新增 OpenWork / Orca / Maka 三项目,样本达到 4 个
样本 - 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 无处可写)
This commit is contained in:
@@ -167,6 +167,52 @@ def update_block(text: str, block_key: str, values: dict, indent: int | None = N
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def upsert_block(
|
||||
text: str, block_key: str, values: dict, parent: str, indent: int | None = None
|
||||
) -> str:
|
||||
"""update_block 的容错版:块不存在时,在 parent 块末尾**新建**它。
|
||||
|
||||
为什么需要这个:手工补完档案时习惯整块替换(ecosystem、business 等),
|
||||
一不留神就会把块内的子块(如 ecosystem.metrics)一起覆盖掉。
|
||||
update_block 遇到块缺失只会抛 ValueError,被上层 catch 成一条警告,
|
||||
表现是「指标栏静默变空」——很难察觉,等发现时原始数据已经没了。
|
||||
有了 upsert,这类误删能自动长回来。
|
||||
"""
|
||||
try:
|
||||
return update_block(text, block_key, values, indent)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
lines = text.split("\n")
|
||||
pat = re.compile(rf"^(\s*){re.escape(parent)}:\s*$")
|
||||
pstart = None
|
||||
pbase = 0
|
||||
for i, ln in enumerate(lines):
|
||||
m = pat.match(ln)
|
||||
if m:
|
||||
pstart, pbase = i, len(m.group(1))
|
||||
break
|
||||
if pstart is None:
|
||||
raise ValueError(f"未找到父块 `{parent}:`,无法新建 `{block_key}:`")
|
||||
|
||||
pend = len(lines)
|
||||
for j in range(pstart + 1, len(lines)):
|
||||
ln = lines[j]
|
||||
if not ln.strip() or ln.lstrip().startswith("#"):
|
||||
continue
|
||||
if len(ln) - len(ln.lstrip()) <= pbase:
|
||||
pend = j
|
||||
break
|
||||
|
||||
child = pbase + 2
|
||||
grand = child + 2
|
||||
new = [f"{' ' * child}{block_key}:"] + [
|
||||
f"{' ' * grand}{k}: {_fmt(v)}" for k, v in values.items()
|
||||
]
|
||||
lines[pend:pend] = new
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 快照
|
||||
def snapshot_path(pid: str) -> Path:
|
||||
return SNAPSHOTS / f"{pid}.jsonl"
|
||||
|
||||
+138
-4
@@ -28,6 +28,28 @@ from _common import ARTICLES, PROJECTS, load_yaml # noqa: E402
|
||||
|
||||
QUAD_THRESHOLD = 4
|
||||
BEGIN, END = "<!-- MATRIX:BEGIN -->", "<!-- MATRIX:END -->"
|
||||
IDX_BEGIN, IDX_END = "<!-- INDEX:BEGIN -->", "<!-- INDEX:END -->"
|
||||
OVERVIEW_AID = "00-overview"
|
||||
|
||||
|
||||
def short_maturity(risk: dict) -> str:
|
||||
"""成熟度在档案里是一整段判断(几十字),直接塞进表格会把列撑爆。
|
||||
|
||||
优先用档案给的 `maturity_level` 短标签(人工判断最准);
|
||||
没有则从长文本截取第一个分句兜底。
|
||||
"""
|
||||
r = risk or {}
|
||||
lv = r.get("maturity_level")
|
||||
if lv:
|
||||
return str(lv)
|
||||
m = (r.get("maturity") or "").strip()
|
||||
if not m:
|
||||
return "-"
|
||||
for sep in ("。", ";", "\n"):
|
||||
if sep in m:
|
||||
m = m.split(sep)[0]
|
||||
break
|
||||
return m[:14] + ("…" if len(m) > 14 else "")
|
||||
|
||||
|
||||
def cell(v, empty="-") -> str:
|
||||
@@ -93,9 +115,18 @@ def build_tables(items: list[dict]) -> str:
|
||||
stale = " ⚠" if m.get("stale") else ""
|
||||
add(f"| **{d.get('name','')}** | {cell(m.get('stars'))} | {cell(m.get('forks'))} | "
|
||||
f"{cell(m.get('open_issues'))} | {cell(m.get('latest_release'))} | "
|
||||
f"{cell(m.get('release_cadence'))} | {cell((d.get('risk') or {}).get('maturity'))} | "
|
||||
f"{cell(m.get('release_cadence'))} | {short_maturity(d.get('risk') or {})} | "
|
||||
f"{cell(m.get('data_date'))}{stale} |")
|
||||
|
||||
# 成熟度是长文本,塞进表格会把列撑爆。表格里只放短标签,
|
||||
# 完整的成熟度判断单独列在表后,读者想看细节再往下读。
|
||||
mats = [(d.get("name", ""), (d.get("risk") or {}).get("maturity")) for d in items]
|
||||
if any(v for _, v in mats):
|
||||
add("\n**成熟度详解**\n")
|
||||
for name, v in mats:
|
||||
if v:
|
||||
add(f"- **{name}**:{v}")
|
||||
|
||||
# 宣称 vs 实测:全套矩阵里信息密度最高的一张表
|
||||
rows = []
|
||||
for d in items:
|
||||
@@ -121,6 +152,87 @@ def build_tables(items: list[dict]) -> str:
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 文章索引
|
||||
def read_h1(aid: str) -> str:
|
||||
"""取 articles/<aid>.md 的第一个一级标题作为文章名。"""
|
||||
p = ARTICLES / f"{aid}.md"
|
||||
if not p.exists():
|
||||
return aid
|
||||
for line in p.read_text(encoding="utf-8").lstrip().split("\n"):
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return aid
|
||||
|
||||
|
||||
def collect_cross_articles() -> list[dict]:
|
||||
"""跨项目文章(总纲 / 横评 / 专题)的发布元数据。
|
||||
|
||||
ID 落在 articles/<aid>.meta.yaml 里,由 publish_emlog.py 维护。
|
||||
总纲文自身要排除——索引出现在总纲内部,自己链自己没有意义。
|
||||
"""
|
||||
out = []
|
||||
for mp in sorted(ARTICLES.glob("*.meta.yaml")):
|
||||
aid = mp.name[: -len(".meta.yaml")]
|
||||
if aid == OVERVIEW_AID:
|
||||
continue
|
||||
d = load_yaml(mp) or {}
|
||||
if not d.get("emlog_id"):
|
||||
continue
|
||||
out.append({
|
||||
"aid": aid,
|
||||
"title": read_h1(aid),
|
||||
"emlog_id": d["emlog_id"],
|
||||
"url": d.get("url") or f"https://blog.apescale.com/?post={d['emlog_id']}",
|
||||
"last_published": d.get("last_published", ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def article_index_section(items: list[dict]) -> str:
|
||||
"""文章索引。
|
||||
|
||||
这段原来是手抄的,结果新发了横评文、索引里却没有它——手写的索引
|
||||
每发一篇文章就会漂移一次。改成从两处事实源生成:
|
||||
项目深度文 ← data/projects/*.yaml 的 article 块
|
||||
跨项目文章 ← articles/*.meta.yaml
|
||||
"""
|
||||
L = ["<!-- INDEX:BEGIN -->"]
|
||||
cross = collect_cross_articles()
|
||||
|
||||
L.append("| 项目 | 一句话结论 | 深度文 |")
|
||||
L.append("| --- | --- | --- |")
|
||||
fallback = cross[0]["url"] if cross else ""
|
||||
for d in items:
|
||||
art = d.get("article") or {}
|
||||
url = art.get("url") or ""
|
||||
if url:
|
||||
link = f"[已发布]({url})"
|
||||
elif fallback:
|
||||
link = f"待发布(见下方横评)"
|
||||
else:
|
||||
link = "待发布"
|
||||
one = (d.get("verdict") or {}).get("one_liner") or "-"
|
||||
# 一句话结论本来就长,索引表里截断到第一个分句,避免一行几十字
|
||||
for sep in ("——", "。", ";"):
|
||||
if sep in one:
|
||||
one = one.split(sep)[0]
|
||||
break
|
||||
L.append(f"| **{d.get('name','')}** | {one} | {link} |")
|
||||
|
||||
if cross:
|
||||
L.append("\n**跨项目文章**\n")
|
||||
for a in cross:
|
||||
L.append(f"- [{a['title']}]({a['url']}) — {a['last_published']}")
|
||||
|
||||
pending = [d.get("name", "") for d in items if not (d.get("article") or {}).get("url")]
|
||||
if pending:
|
||||
L.append(f"\n> 待补深度文:{'、'.join(pending)}。"
|
||||
f"当前可先读上方跨项目文章,其中已包含这些项目的完整分维度对比。")
|
||||
|
||||
L.append("<!-- INDEX:END -->")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 图
|
||||
def stack_diagram(items: list[dict]) -> str:
|
||||
"""项目 <4 个时的赛道分层图:按交互形态把项目归到三层栈。"""
|
||||
@@ -161,8 +273,12 @@ def quadrant_svg(items: list[dict]) -> str:
|
||||
svg = []
|
||||
for i, (title, xk, yk, xlo, xhi, ylo, yhi) in enumerate(plots):
|
||||
ox = i * (W + 30)
|
||||
# max-width:100% + height:auto 不能省:SVG 带固定 width/height 属性,
|
||||
# 在窄屏(手机、窄主题)里会撑破容器横向溢出。有 viewBox 时浏览器
|
||||
# 才能按这两个声明等比缩放。
|
||||
svg.append(f'<svg viewBox="0 0 {W} {H}" width="{W}" height="{H}" '
|
||||
f'xmlns="http://www.w3.org/2000/svg" style="font-family:sans-serif">')
|
||||
f'xmlns="http://www.w3.org/2000/svg" '
|
||||
f'style="font-family:sans-serif;max-width:100%;height:auto">')
|
||||
svg.append(f'<text x="{W//2}" y="26" text-anchor="middle" font-size="15" '
|
||||
f'font-weight="600" fill="#1f2328">{title}</text>')
|
||||
px, py, pw, ph = PAD, 55, W - PAD * 2, H - PAD - 55
|
||||
@@ -190,7 +306,16 @@ def quadrant_svg(items: list[dict]) -> str:
|
||||
name = d.get("name", "")
|
||||
svg.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="7" fill="#0969da" '
|
||||
f'fill-opacity="0.75" stroke="#fff" stroke-width="1.5"/>')
|
||||
svg.append(f'<text x="{cx+11:.1f}" y="{cy+4:.1f}" font-size="12" fill="#1f2328">{name}</text>')
|
||||
# 标签默认画在点右侧,但满分点会顶到绘图区右边界(cx == px+pw),
|
||||
# 文字就会溢出画布被裁掉。靠右时翻到点的左侧。
|
||||
# 估算文字宽度:中文按 12px、其余按 7px,够用且不依赖字体度量。
|
||||
tw = sum(12 if ord(ch) > 0x2E80 else 7 for ch in name)
|
||||
if cx + 11 + tw > W - 4:
|
||||
svg.append(f'<text x="{cx-11:.1f}" y="{cy+4:.1f}" text-anchor="end" '
|
||||
f'font-size="12" fill="#1f2328">{name}</text>')
|
||||
else:
|
||||
svg.append(f'<text x="{cx+11:.1f}" y="{cy+4:.1f}" font-size="12" '
|
||||
f'fill="#1f2328">{name}</text>')
|
||||
svg.append("</svg>")
|
||||
return "\n".join(svg)
|
||||
|
||||
@@ -216,7 +341,7 @@ def main() -> int:
|
||||
print(block)
|
||||
return 0
|
||||
|
||||
path = ARTICLES / f"{'00-overview'}.md"
|
||||
path = ARTICLES / f"{OVERVIEW_AID}.md"
|
||||
if not path.exists():
|
||||
print(f"总纲文不存在:{path}")
|
||||
return 1
|
||||
@@ -225,6 +350,15 @@ def main() -> int:
|
||||
print(f"总纲文缺少 {BEGIN} / {END} 占位标记,未回填。")
|
||||
return 1
|
||||
text = re.sub(rf"{re.escape(BEGIN)}.*?{re.escape(END)}", lambda _: block, text, flags=re.S)
|
||||
|
||||
# 文章索引同样回填,两个块各管一段,互不干扰
|
||||
index = article_index_section(items)
|
||||
if IDX_BEGIN in text and IDX_END in text:
|
||||
text = re.sub(rf"{re.escape(IDX_BEGIN)}.*?{re.escape(IDX_END)}",
|
||||
lambda _: index, text, flags=re.S)
|
||||
else:
|
||||
print(f" [warn] 总纲文缺少 {IDX_BEGIN} / {IDX_END},文章索引未回填")
|
||||
|
||||
path.write_text(text, encoding="utf-8")
|
||||
print(f"==> 已回填总纲文:{path}")
|
||||
return 0
|
||||
|
||||
@@ -34,6 +34,7 @@ from _common import ( # noqa: E402
|
||||
load_yaml,
|
||||
today,
|
||||
update_block,
|
||||
upsert_block,
|
||||
)
|
||||
|
||||
|
||||
@@ -125,7 +126,9 @@ def fetch_one(pid: str, repo: str, force: bool) -> bool:
|
||||
if ypath.exists():
|
||||
text = ypath.read_text(encoding="utf-8")
|
||||
try:
|
||||
text = update_block(text, "metrics", metrics)
|
||||
# 用 upsert 而非 update:手工补完档案时若整块覆盖了 ecosystem,
|
||||
# metrics 子块会消失,upsert 能把它重建回来。
|
||||
text = upsert_block(text, "metrics", metrics, parent="ecosystem")
|
||||
except ValueError as e:
|
||||
print(f" [warn] 回写 metrics 失败:{e}")
|
||||
# 许可证若尚未定级,顺手补上
|
||||
|
||||
+210
-51
@@ -16,29 +16,35 @@ DISPLAY_URL="${GITEA_DISPLAY_URL:-https://gitea.apescale.com}"
|
||||
ORG="${GITEA_ORG:-gitinbox}"
|
||||
ID=""
|
||||
DEPTH=""
|
||||
# auto = 体积超限时自动降级为源码快照;no = 始终尝试完整镜像(超限会 413 失败)。
|
||||
# 这两个变量必须在 set -u 之前就有值:下面体积闸门的条件里会引用它们,
|
||||
# 而这个分支只在特定体积下才走到——不初始化就成了「偶尔才炸」的雷。
|
||||
SOURCE_ONLY="auto"
|
||||
SOURCE_ONLY_FORCE="no"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--id) ID="$2"; shift 2;;
|
||||
--org) ORG="$2"; shift 2;;
|
||||
--url) GITEA_URL="$2"; shift 2;;
|
||||
--depth) DEPTH="$2"; shift 2;;
|
||||
--id) ID="$2"; shift 2;;
|
||||
--org) ORG="$2"; shift 2;;
|
||||
--url) GITEA_URL="$2"; shift 2;;
|
||||
--depth) DEPTH="$2"; shift 2;;
|
||||
--no-source-only) SOURCE_ONLY="no"; shift;;
|
||||
*) echo "未知参数: $1" >&2; exit 1;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$ID" ]] && { echo "缺少 --id" >&2; exit 1; }
|
||||
YAML="$ROOT/data/projects/$ID.yaml"
|
||||
[[ -f "$YAML" ]] || { echo "档案不存在: $YAML" >&2; exit 1; }
|
||||
[[ -z "${ID}" ]] && { echo "缺少 --id" >&2; exit 1; }
|
||||
YAML="${ROOT}/data/projects/${ID}.yaml"
|
||||
[[ -f "${YAML}" ]] || { echo "档案不存在: ${YAML}" >&2; exit 1; }
|
||||
|
||||
# ---- 从档案读取镜像配置(bash 不适合解析 YAML,借用 python 输出 shell 赋值) ----
|
||||
# 用"写临时文件再 source"而不是 eval "$(...)":
|
||||
# eval 会二次解析内容,YAML 里的括号/引号一旦漏转义就会变成难定位的
|
||||
# syntax error,而且报错行号会偏移到完全无关的位置。
|
||||
CONF_FILE="$(mktemp)"
|
||||
trap 'rm -f "$CONF_FILE"' EXIT
|
||||
trap 'rm -f "${CONF_FILE}"' EXIT
|
||||
|
||||
/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python - "$YAML" > "$CONF_FILE" <<'PY'
|
||||
/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python - "${YAML}" > "${CONF_FILE}" <<'PY'
|
||||
import sys, shlex
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(sys.argv[1]).resolve().parent.parent.parent / "scripts"))
|
||||
@@ -56,81 +62,233 @@ print(f"LIC_NAME={shlex.quote(str(lic.get('name') or ''))}")
|
||||
PY
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONF_FILE"
|
||||
source "${CONF_FILE}"
|
||||
|
||||
[[ -z "$UPSTREAM" ]] && { echo "档案缺少 mirror.upstream" >&2; exit 1; }
|
||||
[[ -z "${UPSTREAM}" ]] && { echo "档案缺少 mirror.upstream" >&2; exit 1; }
|
||||
|
||||
echo "==> 镜像 $ID"
|
||||
echo " 上游 : $UPSTREAM"
|
||||
echo " 目标 : $ORG/$REPO_NAME"
|
||||
echo " 许可证 : $LIC_LEVEL ($LIC_NAME)"
|
||||
echo " 可见性 : $VISIBILITY"
|
||||
if [[ "$VISIBILITY" == "private" ]]; then
|
||||
echo "==> 镜像 ${ID}"
|
||||
echo " 上游 : ${UPSTREAM}"
|
||||
echo " 目标 : ${ORG}/${REPO_NAME}"
|
||||
echo " 许可证 : ${LIC_LEVEL} (${LIC_NAME})"
|
||||
echo " 可见性 : ${VISIBILITY}"
|
||||
if [[ "${VISIBILITY}" == "private" ]]; then
|
||||
echo " ⚠ L3 级 source-available,已强制私有。公开镜像可能构成未经授权的分发。"
|
||||
fi
|
||||
|
||||
# ---- 凭据:优先环境变量,回退到 macOS 钥匙串 ----
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
CRED="$(printf "host=%s\nprotocol=https\n" "${DISPLAY_URL#*://}" | git credential-osxkeychain get 2>/dev/null || true)"
|
||||
GITEA_USER="${GITEA_USER:-$(printf '%s' "$CRED" | sed -n 's/^username=//p')}"
|
||||
GITEA_TOKEN="$(printf '%s' "$CRED" | sed -n 's/^password=//p')"
|
||||
GITEA_USER="${GITEA_USER:-$(printf '%s' "${CRED}" | sed -n 's/^username=//p')}"
|
||||
GITEA_TOKEN="$(printf '%s' "${CRED}" | sed -n 's/^password=//p')"
|
||||
fi
|
||||
[[ -z "${GITEA_TOKEN:-}" || -z "${GITEA_USER:-}" ]] && {
|
||||
echo "未拿到 Gitea 凭据(设 GITEA_TOKEN/GITEA_USER,或存入钥匙串)" >&2; exit 1; }
|
||||
|
||||
# ---- 确保目标仓库存在(组织端点,不是 /user/repos) ----
|
||||
API="$GITEA_URL/api/v1"
|
||||
HTTP="$(curl -sS -o /dev/null -w '%{http_code}' -u "$GITEA_USER:$GITEA_TOKEN" "$API/repos/$ORG/$REPO_NAME" || echo 000)"
|
||||
if [[ "$HTTP" == "200" ]]; then
|
||||
API="${GITEA_URL}/api/v1"
|
||||
HTTP="$(curl -sS -o /dev/null -w '%{http_code}' -u "${GITEA_USER}:${GITEA_TOKEN}" "${API}/repos/${ORG}/${REPO_NAME}" || echo 000)"
|
||||
if [[ "${HTTP}" == "200" ]]; then
|
||||
echo " 仓库已存在"
|
||||
elif [[ "$HTTP" == "404" ]]; then
|
||||
echo " 创建仓库 $ORG/$REPO_NAME (private=$([[ $VISIBILITY == private ]] && echo true || echo false)) ..."
|
||||
CREATE="$(curl -sS -o /tmp/gitea_mirror_create.json -w '%{http_code}' -u "$GITEA_USER:$GITEA_TOKEN" \
|
||||
-H 'Content-Type: application/json' -X POST "$API/org/$ORG/repos" \
|
||||
-d "{\"name\":\"$REPO_NAME\",\"private\":$([[ $VISIBILITY == private ]] && echo true || echo false),\"auto_init\":false,\"description\":\"Mirror of $UPSTREAM (license: $LIC_NAME)\"}")"
|
||||
[[ "$CREATE" =~ ^(200|201)$ ]] || {
|
||||
echo "创建失败 (HTTP $CREATE):" >&2; cat /tmp/gitea_mirror_create.json >&2; exit 1; }
|
||||
elif [[ "${HTTP}" == "404" ]]; then
|
||||
echo " 创建仓库 ${ORG}/${REPO_NAME} (private=$([[ ${VISIBILITY} == private ]] && echo true || echo false)) ..."
|
||||
CREATE="$(curl -sS -o /tmp/gitea_mirror_create.json -w '%{http_code}' -u "${GITEA_USER}:${GITEA_TOKEN}" \
|
||||
-H 'Content-Type: application/json' -X POST "${API}/org/${ORG}/repos" \
|
||||
-d "{\"name\":\"${REPO_NAME}\",\"private\":$([[ ${VISIBILITY} == private ]] && echo true || echo false),\"auto_init\":false,\"description\":\"Mirror of ${UPSTREAM} (license: ${LIC_NAME})\"}")"
|
||||
[[ "${CREATE}" =~ ^(200|201)$ ]] || {
|
||||
echo "创建失败 (HTTP ${CREATE}):" >&2; cat /tmp/gitea_mirror_create.json >&2; exit 1; }
|
||||
echo " 已创建"
|
||||
else
|
||||
echo "查询仓库失败 (HTTP $HTTP)" >&2; exit 1
|
||||
echo "查询仓库失败 (HTTP ${HTTP})" >&2; exit 1
|
||||
fi
|
||||
|
||||
# 已存在的仓库若应为私有,显式修正(创建时的 private 对已存在仓库不生效)
|
||||
if [[ "$VISIBILITY" == "private" ]]; then
|
||||
curl -sS -o /dev/null -u "$GITEA_USER:$GITEA_TOKEN" -X PATCH \
|
||||
-H 'Content-Type: application/json' -d '{"private":true}' "$API/repos/$ORG/$REPO_NAME" || true
|
||||
if [[ "${VISIBILITY}" == "private" ]]; then
|
||||
curl -sS -o /dev/null -u "${GITEA_USER}:${GITEA_TOKEN}" -X PATCH \
|
||||
-H 'Content-Type: application/json' -d '{"private":true}' "${API}/repos/${ORG}/${REPO_NAME}" || true
|
||||
fi
|
||||
|
||||
# ---- 同步 ----
|
||||
MIRROR_DIR="$ROOT/.mirrors/$REPO_NAME.git"
|
||||
mkdir -p "$ROOT/.mirrors"
|
||||
MIRROR_DIR="${ROOT}/.mirrors/${REPO_NAME}.git"
|
||||
mkdir -p "${ROOT}/.mirrors"
|
||||
|
||||
if [[ -d "$MIRROR_DIR" ]]; then
|
||||
echo " 更新已有裸仓 ..."
|
||||
git -C "$MIRROR_DIR" remote update --prune
|
||||
else
|
||||
echo " 首次全量镜像(大仓请耐心)..."
|
||||
if [[ -n "$DEPTH" ]]; then
|
||||
git clone --mirror --depth "$DEPTH" "$UPSTREAM" "$MIRROR_DIR"
|
||||
DEEPEN_STEP="${DEEPEN_STEP:-100}"
|
||||
|
||||
# 注意:不能用 `rev-parse --git-dir` 判断仓库是否有效。git clone 会先建好
|
||||
# 目录骨架再下载 pack,被中断的半成品照样能通过 rev-parse,于是被当成
|
||||
# "已有仓库"走 fetch 分支,报出 "CONNECT tunnel failed" 这类误导性错误。
|
||||
# 真正的判据是"里面有没有提交"。
|
||||
repo_ok() {
|
||||
[[ -d "${MIRROR_DIR}" ]] || return 1
|
||||
git -C "${MIRROR_DIR}" rev-parse --git-dir >/dev/null 2>&1 || return 1
|
||||
local n
|
||||
n="$(git -C "${MIRROR_DIR}" rev-list --count --all 2>/dev/null || echo 0)"
|
||||
[[ "${n}" -gt 0 ]]
|
||||
}
|
||||
is_shallow() { [[ "$(git -C "${MIRROR_DIR}" rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; }
|
||||
|
||||
# clone/fetch 失败时清掉半成品,避免下次误判(脚本有 set -e,失败会直接退出)
|
||||
cleanup_broken() {
|
||||
if [[ -d "${MIRROR_DIR}" ]] && ! repo_ok; then
|
||||
echo " 清理未完成的半成品裸仓 ..."
|
||||
rm -rf "${MIRROR_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup_broken EXIT
|
||||
|
||||
if [[ -d "${MIRROR_DIR}" ]] && ! repo_ok; then
|
||||
echo " 清理上次中断的半成品裸仓 ..."
|
||||
rm -rf "${MIRROR_DIR}"
|
||||
fi
|
||||
|
||||
if repo_ok; then
|
||||
if is_shallow && [[ -z "${DEPTH}" ]]; then
|
||||
# 慢链路续传:分批 --deepen。每批成功即落盘,中断后重跑本脚本可继续,
|
||||
# 不像 `git clone` 那样失败一次全部作废。
|
||||
echo " 浅仓分批续传(每批 --deepen=${DEEPEN_STEP},中断可重跑)..."
|
||||
while is_shallow; do
|
||||
PRE="$(git -C "${MIRROR_DIR}" rev-list --count --all 2>/dev/null || echo 0)"
|
||||
echo " 已有 ${PRE} 条历史,再加深 ${DEEPEN_STEP} ..."
|
||||
if ! git -C "${MIRROR_DIR}" fetch --deepen="${DEEPEN_STEP}" origin; then
|
||||
echo " ⚠ 本批中断;已落盘的 ${PRE} 条历史保留,重跑本脚本继续。"
|
||||
break
|
||||
fi
|
||||
POST="$(git -C "${MIRROR_DIR}" rev-list --count --all 2>/dev/null || echo 0)"
|
||||
[[ "${POST}" == "${PRE}" ]] && { echo " 已补齐完整历史"; break; }
|
||||
done
|
||||
elif is_shallow && [[ -n "${DEPTH}" ]]; then
|
||||
echo " 浅仓加深到 depth=${DEPTH} ..."
|
||||
git -C "${MIRROR_DIR}" fetch --depth="${DEPTH}" origin || \
|
||||
echo " ⚠ 加深中断,已保留现有深度,重跑可继续"
|
||||
else
|
||||
git clone --mirror "$UPSTREAM" "$MIRROR_DIR"
|
||||
echo " 更新已有裸仓 ..."
|
||||
git -C "${MIRROR_DIR}" remote update --prune
|
||||
fi
|
||||
else
|
||||
# 走沙箱代理时偶发 `CONNECT tunnel failed, response 502`,且 git clone
|
||||
# 失败不保留已下载内容,所以首次克隆必须带重试。
|
||||
CLONE_ARGS=(clone --mirror)
|
||||
[[ -n "${DEPTH}" ]] && CLONE_ARGS+=(--depth "${DEPTH}")
|
||||
if [[ -n "${DEPTH}" ]]; then
|
||||
echo " 首次镜像(--depth ${DEPTH},只取近期历史)..."
|
||||
else
|
||||
echo " 首次全量镜像(大仓请耐心)..."
|
||||
fi
|
||||
for attempt in 1 2 3; do
|
||||
if git "${CLONE_ARGS[@]}" "${UPSTREAM}" "${MIRROR_DIR}"; then
|
||||
break
|
||||
fi
|
||||
echo " ⚠ 第 ${attempt} 次克隆失败,8s 后重试 ..."
|
||||
rm -rf "${MIRROR_DIR}"
|
||||
sleep 8
|
||||
[[ ${attempt} -eq 3 ]] && { echo "克隆三次均失败,请检查网络/代理" >&2; exit 1; }
|
||||
done
|
||||
fi
|
||||
|
||||
# ---- 浅仓处理:服务端拒绝 shallow update,必须先补齐历史 ----
|
||||
# Git 服务端默认 `receive.shallowUpdate = deny`,推送浅仓会被拒:
|
||||
# ! [remote rejected] main -> main (shallow update not allowed)
|
||||
# 而 --depth 是本脚本自己提供的选项,推不上去等于自相矛盾,所以在这里兜住:
|
||||
# 先 --unshallow 补齐(增量下载,通常只比浅仓多几十 MB),失败再降级为快照。
|
||||
UNSHALLOWED="no"
|
||||
if is_shallow; then
|
||||
echo " 浅仓无法直接推送(服务端拒绝 shallow update),先补齐完整历史 ..."
|
||||
for attempt in 1 2 3; do
|
||||
if git -C "${MIRROR_DIR}" fetch --unshallow origin 2>/dev/null; then
|
||||
UNSHALLOWED="yes"; break
|
||||
fi
|
||||
echo " ⚠ 第 ${attempt} 次 unshallow 失败,10s 后重试 ..."
|
||||
sleep 10
|
||||
done
|
||||
if [[ "${UNSHALLOWED}" == "yes" ]]; then
|
||||
echo " 已补齐完整历史"
|
||||
else
|
||||
# 网络或体积原因补不齐 —— 退回快照模式,丢历史但至少把代码留下来。
|
||||
echo " ⚠ 补齐失败,将降级为【源码快照】(会丢失提交历史)"
|
||||
SOURCE_ONLY_FORCE="yes"
|
||||
fi
|
||||
fi
|
||||
|
||||
PROTO="${GITEA_URL%%://*}"
|
||||
HOST="${GITEA_URL#*//}"
|
||||
U_ENC="$(/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c \
|
||||
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$GITEA_USER")"
|
||||
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "${GITEA_USER}")"
|
||||
P_ENC="$(/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c \
|
||||
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$GITEA_TOKEN")"
|
||||
'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "${GITEA_TOKEN}")"
|
||||
|
||||
echo " 推送到 $ORG/$REPO_NAME ..."
|
||||
git -C "$MIRROR_DIR" push --mirror "$PROTO://$U_ENC:$P_ENC@$HOST/$ORG/$REPO_NAME.git"
|
||||
# ---- 体积闸门:Gitea 前置 nginx 有 client_max_body_size 上限 ----
|
||||
# 实测 gitea.apescale.com:48 MB 放行、64 MB 拒绝(推算为 50m,宝塔常见默认值)。
|
||||
# 超限推送会在传完全部对象后才收到 HTTP 413,白白浪费几十分钟下载。
|
||||
# 所以推送前先量体积,超限则自动降级为「源码快照」:剔除图片/音视频/字体等
|
||||
# 非源码素材——这些内容通常占一半以上体积,却不是"跟踪源码"要看的东西。
|
||||
MAX_PUSH_MB="${MAX_PUSH_MB:-45}"
|
||||
PACK_KB="$(git -C "${MIRROR_DIR}" count-objects -v | awk '/^size-pack:/{print $2}')"
|
||||
PACK_MB=$(( PACK_KB / 1024 ))
|
||||
MIRROR_MODE="full"
|
||||
|
||||
echo " 本地裸仓打包体积: ${PACK_MB} MB(推送上限 ${MAX_PUSH_MB} MB)"
|
||||
|
||||
if [[ ( "${PACK_MB}" -gt "${MAX_PUSH_MB}" || "${SOURCE_ONLY_FORCE}" == "yes" ) \
|
||||
&& "${SOURCE_ONLY}" != "no" ]]; then
|
||||
MIRROR_MODE="source-only"
|
||||
if [[ "${SOURCE_ONLY_FORCE}" == "yes" ]]; then
|
||||
echo " ⚠ 降级为【源码快照】:提交历史不完整,按快照提交(仍会剔除素材)"
|
||||
else
|
||||
echo " ⚠ 超出上限,降级为【源码快照】:剔除非源码素材后推送"
|
||||
fi
|
||||
SNAP="$(mktemp -d)"
|
||||
# shellcheck disable=SC2064
|
||||
trap "cleanup_broken; rm -rf '${SNAP}'" EXIT
|
||||
|
||||
git init -q "${SNAP}/repo"
|
||||
git --git-dir="${MIRROR_DIR}" --work-tree="${SNAP}/repo" checkout HEAD -- . 2>/dev/null
|
||||
find "${SNAP}/repo" -type f -not -path '*/.git/*' \( \
|
||||
-iname '*.png' -o -iname '*.gif' -o -iname '*.jpg' -o -iname '*.jpeg' \
|
||||
-o -iname '*.webp' -o -iname '*.ico' -o -iname '*.mp4' -o -iname '*.mov' \
|
||||
-o -iname '*.webm' -o -iname '*.wav' -o -iname '*.mp3' -o -iname '*.glb' \
|
||||
-o -iname '*.ttf' -o -iname '*.woff' -o -iname '*.woff2' -o -iname '*.eot' \
|
||||
-o -iname '*.pdf' -o -iname '*.zip' \) -delete
|
||||
|
||||
cat > "${SNAP}/repo/MIRROR-NOTE.md" <<NOTE
|
||||
# 镜像说明
|
||||
|
||||
- 上游:${UPSTREAM}
|
||||
- 许可证:${LIC_NAME}(${LIC_LEVEL})
|
||||
- 抓取日期:$(date +%F)
|
||||
|
||||
## 这不是完整保真镜像
|
||||
|
||||
本仓库只含**源码与文档**,已剔除图片、音视频、字体等非源码素材。
|
||||
原因:Gitea 前置 nginx 的 \`client_max_body_size\` 上限约 ${MAX_PUSH_MB} MB,
|
||||
完整裸仓打包 ${PACK_MB} MB 会被 HTTP 413 拒绝。
|
||||
|
||||
完整保真快照保留在追踪体系的本地裸仓 \`.mirrors/${REPO_NAME}.git\` 中。
|
||||
把服务器 nginx 放开到 \`client_max_body_size 512m;\` 后即可推送完整版。
|
||||
NOTE
|
||||
|
||||
PUSH_DIR="${SNAP}/repo"
|
||||
( cd "${PUSH_DIR}" && git add -A \
|
||||
&& git -c user.name='agent-radar' -c user.email='radar@local' \
|
||||
commit -q -m "源码快照 @ $(date +%F) (source-only, 剔除非源码素材)" )
|
||||
# 必须先 repack 再量:新 commit 出来的都是 loose object,
|
||||
# size-pack 为 0,会打印出「源码快照体积: 0 MB」这种误导性输出。
|
||||
# repack 顺带也把推送体积压下去(实测 iPolloWork:散装 57 MB → 打包 14 MB)。
|
||||
git -C "${PUSH_DIR}" repack -a -d -q --window=50 --depth=50 2>/dev/null || true
|
||||
SNAP_MB=$(( $(git -C "${PUSH_DIR}" count-objects -v | awk '/^size-pack:/{print $2}') / 1024 ))
|
||||
echo " 源码快照体积: ${SNAP_MB} MB"
|
||||
else
|
||||
PUSH_DIR="${MIRROR_DIR}"
|
||||
fi
|
||||
|
||||
echo " 推送到 ${ORG}/${REPO_NAME} ..."
|
||||
if [[ "${MIRROR_MODE}" == "full" ]]; then
|
||||
git -C "${PUSH_DIR}" push --mirror "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git"
|
||||
else
|
||||
git -C "${PUSH_DIR}" push "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git" HEAD:refs/heads/main
|
||||
fi
|
||||
|
||||
# ---- 回写档案 ----
|
||||
/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python - "$YAML" "$VISIBILITY" "$REPO_NAME" <<'PY'
|
||||
/Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python - "${YAML}" "${VISIBILITY}" "${REPO_NAME}" "${MIRROR_MODE}" <<'PY'
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(sys.argv[1]).resolve().parent.parent.parent / "scripts"))
|
||||
from _common import today, update_block
|
||||
@@ -140,9 +298,10 @@ text = update_block(text, "mirror", {
|
||||
"visibility": sys.argv[2],
|
||||
"gitea_repo": f"{'gitinbox'}/{sys.argv[3]}",
|
||||
"last_sync": today(),
|
||||
"mode": sys.argv[4],
|
||||
})
|
||||
p.write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
SIZE="$(du -sh "$MIRROR_DIR" 2>/dev/null | cut -f1 || echo '?')"
|
||||
echo "==> 完成。$DISPLAY_URL/$ORG/$REPO_NAME (本地裸仓 $SIZE)"
|
||||
SIZE="$(du -sh "${MIRROR_DIR}" 2>/dev/null | cut -f1 || echo '?')"
|
||||
echo "==> 完成。${DISPLAY_URL}/${ORG}/${REPO_NAME} (模式 ${MIRROR_MODE},本地裸仓 ${SIZE})"
|
||||
|
||||
+33
-24
@@ -9,13 +9,20 @@
|
||||
|
||||
两个关键设计
|
||||
------------
|
||||
1. **update 前必须先 detail 取回全文**。
|
||||
emlog 的 article_update 接口在 content 为空时会把正文清空 —— 这是接口行为,
|
||||
不是 bug。总纲文每次回填都要更新,所以这条保护是刚需。
|
||||
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 块;
|
||||
总纲文回写到 articles/00-overview.meta.yaml。
|
||||
有同名项目档案(data/projects/<id>.yaml)→ 回写到档案的 article 块;
|
||||
没有(总纲文 00-overview、横评文 00-comparison 这类跨项目文章)
|
||||
→ 回写到 articles/<id>.meta.yaml。
|
||||
有了 ID 才能做"更新"而不是每次都发一篇新的。
|
||||
判断顺序不能反:先按"是不是项目文"分流,再决定落哪里,
|
||||
否则新增一篇横评类文章时 ID 会无处可写,下次发布就变成重复发一篇。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,8 +40,6 @@ from _common import ARTICLES, PROJECTS, today, update_block # noqa: E402
|
||||
import publish_article as emlog # noqa: E402
|
||||
|
||||
DEFAULT_SORT_ID = 17 # 可巴蒂
|
||||
OVERVIEW_ID = "00-overview"
|
||||
META_PATH = ARTICLES / f"{OVERVIEW_ID}.meta.yaml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 转换
|
||||
@@ -69,34 +74,38 @@ def to_excerpt(md: str, limit: int = 110) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ID 读写
|
||||
def meta_path(aid: str) -> Path:
|
||||
return ARTICLES / f"{aid}.meta.yaml"
|
||||
|
||||
|
||||
def read_emlog_id(aid: str) -> int | None:
|
||||
if aid == OVERVIEW_ID:
|
||||
if not META_PATH.exists():
|
||||
return None
|
||||
return (yaml.safe_load(META_PATH.read_text(encoding="utf-8")) or {}).get("emlog_id")
|
||||
p = PROJECTS / f"{aid}.yaml"
|
||||
if not p.exists():
|
||||
return None
|
||||
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
|
||||
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}"
|
||||
if aid == OVERVIEW_ID:
|
||||
META_PATH.write_text(
|
||||
f"# 总纲文发布元数据(由 scripts/publish_emlog.py 维护)\n"
|
||||
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" 已写入 {META_PATH.name}")
|
||||
print(f" 已写入 {mp.name}")
|
||||
return
|
||||
|
||||
p = PROJECTS / f"{aid}.yaml"
|
||||
if not p.exists():
|
||||
print(f" [warn] 档案不存在,未回写 ID: {p}")
|
||||
return
|
||||
text = p.read_text(encoding="utf-8")
|
||||
try:
|
||||
text = update_block(text, "article", {
|
||||
|
||||
Reference in New Issue
Block a user