授权模型切换为个人副本签发;share 脱敏共建;自更新通道
- 公众版不再共享 1000 次授权池:移除 data/.license.json, 改置 data/.public_edition.json 标记,首次排盘本机签发个人副本 (初始 20 次、不限期);推送脱敏档案每盘 +10 次(同盘只计一次) - 新增 share/ 共建:结构化脱敏导出(share_export.py)+ 双远端推送(share_push.py)+ 隐私协议(share/README.md); 推送前必须展示脱敏包全文并获用户授权 - 新增自更新:VERSION + manifest.json(SHA256 清单)+ scripts/self_update.py(24h 缓存检查、先验后换、保留授权与档案); 排盘成功后 stderr 提示新版本 - SKILL.md 增「档案共建(share/)」「版本与自更新」两节; LICENSE.md 增第六条(档案权属与撤回) - 测试 100 项全绿
This commit is contained in:
@@ -3,3 +3,4 @@ __pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
.license.json
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
|
||||
本技能的全部知识产权归授权方所有。本授权书未明示授予的权利,均由授权方保留。对违反本授权书的行为,授权方保留依法追究责任的权利。
|
||||
|
||||
## 六、命主档案与共建数据
|
||||
|
||||
1. **命主档案权属**:本机命主档案(`memory/archives/`)归命主与使用者本机所有,授权方不主张任何权利;档案只存于使用者本机,不随授权副本分发,使用者不得将档案原文外发。
|
||||
2. **脱敏共建数据**:经使用者授权推送的脱敏档案(`share/`,结构化 JSON,不含姓名、出生分钟与钟表时间、省以下地域与自由文本),授权方仅可用于本技能的优化与规则蒸馏,不另作他用,不对外出售或转授。命主或使用者可随时联系授权方,按盘 hash 撤回已推送的脱敏档案。
|
||||
3. 未经使用者明示授权而导出、推送命主数据,或将脱敏档案用于上述用途之外的用途,均构成对本授权的违反。
|
||||
|
||||
---
|
||||
|
||||
四川灵爻妙解文化传播有限公司 · https://lingyaomiaojie.com
|
||||
|
||||
@@ -61,14 +61,23 @@ tests/ 排盘脚本测试(时区、夏令时、日界、流年小
|
||||
|
||||
## 授权说明(重要)
|
||||
|
||||
本仓库为**公众授权版**副本:
|
||||
本仓库为**公众版**:
|
||||
|
||||
- **副本编号**:`LY-20261007-AC63`(水印指纹,嵌入 SKILL.md、rules/ 各文件、cast_chart.py 与授权状态文件,泄露可溯源)
|
||||
- **授权对象**:normal(公众)
|
||||
- **授权额度**:排盘 **1000 次**
|
||||
- **有效期至**:**2026-12-31**
|
||||
- **首启签发**:首次排盘时自动在本机签发个人副本(副本编号 `LY-日期-随机`),**初始 20 次、不限期**;授权状态只存本机 `data/.license.json`(不入库)。
|
||||
- **档案共建激励**:每成功推送一份脱敏档案(见下),授权次数 **+10**;同一盘(四柱+出生地)只计一次。
|
||||
- **水印指纹**:副本编号(池编号 `LY-20261007-AC63` + 个人编号)嵌入 SKILL.md、rules/ 各文件、cast_chart.py 与授权状态文件,泄露可溯源。
|
||||
- 软锁(`scripts/_license_gate.py`):次数用尽后排盘脚本拒绝运行(退出码 4)。完整授权条款见 [LICENSE.md](LICENSE.md)——**禁止商业用途,禁止二次传播**。本技能全部知识产权归 **四川灵爻妙解文化传播有限公司** 所有。
|
||||
|
||||
副本内置软锁(`scripts/_license_gate.py`):次数用尽或到期后排盘脚本拒绝运行(退出码 4)。完整授权条款见 [LICENSE.md](LICENSE.md)——**禁止商业用途,禁止二次传播**。本技能全部知识产权归 **四川灵爻妙解文化传播有限公司** 所有。
|
||||
## 自更新
|
||||
|
||||
技能会持续迭代。排盘后有新版本时 stderr 会提示一行;更新由你决定:
|
||||
|
||||
```bash
|
||||
python scripts/self_update.py --check # 检查更新(24h 缓存)
|
||||
python scripts/self_update.py --apply # 应用更新(先校验 SHA256 清单再换文件,授权与档案保留)
|
||||
```
|
||||
|
||||
更新线路 GitHub 优先、Gitea 镜像兜底。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -66,9 +66,10 @@ python scripts/query_shensha.py --gender 女 \
|
||||
|
||||
本技能归**四川灵爻妙解文化传播有限公司**所有,以**测试授权**方式分发,条款见 `LICENSE.md`:禁止商业用途、禁止个人传播、保留法律追责途径。
|
||||
|
||||
- **水印指纹**:定制副本在 `SKILL.md`、`rules/` 各文件、`scripts/cast_chart.py` 与授权状态文件中嵌入唯一副本编号(形如 `LY-20261007-A3F9`),副本泄露可按编号溯源。
|
||||
- **软锁**:定制副本含 `data/.license.json`,排盘脚本按授权次数与有效期放行;用尽、到期或授权文件被改动时退出码 4。总开关为 `scripts/_license_gate.py` 顶部 `_ENABLED` 一行变量——软锁防君子不防小人,是授权边界的可见表达,真正约束以 `LICENSE.md` 为准。
|
||||
- **母版与副本**:技能开发目录为母版(无授权状态文件,不锁)。分发副本由授权方一侧的 `tools/make_license_copy.py` 生成:`python tools/make_license_copy.py --licensee 张三 --uses 20 --expires 2027-12-31 --out <目标目录>`;该工具不随副本分发。
|
||||
- **公众版(首启签发个人副本)**:公众版不带授权状态文件,只带标记 `data/.public_edition.json`;首次排盘时由 `scripts/_license_gate.py` 就地签发个人副本——**初始 20 次、不限期**,授权对象 `public-personal`,个人副本编号此时生成。之后与定制副本同样校验。**推送脱敏档案每盘 +10 次,同一盘只计一次**(见「档案共建(share/)」节)。
|
||||
- **水印指纹**:副本在 `SKILL.md`、`rules/` 各文件、`scripts/cast_chart.py` 与授权状态文件中嵌入唯一副本编号(定制副本形如 `LY-20261007-A3F9`,公众版为池编号形如 `LY-20261009-PUB0`),副本泄露可按编号溯源。
|
||||
- **软锁**:授权副本含 `data/.license.json`,排盘脚本按授权次数与有效期放行;用尽、到期或授权文件被改动时退出码 4。总开关为 `scripts/_license_gate.py` 顶部 `_ENABLED` 一行变量——软锁防君子不防小人,是授权边界的可见表达,真正约束以 `LICENSE.md` 为准。
|
||||
- **母版与副本**:技能开发目录为母版(无授权状态文件、无公众版标记,不锁)。分发副本由授权方一侧的 `tools/make_license_copy.py` 生成:定制副本 `python tools/make_license_copy.py --licensee 张三 --uses 20 --expires 2027-12-31 --out <目标目录>`;公众版 `python tools/make_license_copy.py --public-edition --out <目标目录>`。该工具不随副本分发。
|
||||
|
||||
## 输出契约(v1.2,章节标题冻结)
|
||||
|
||||
@@ -181,7 +182,21 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
|
||||
`python scripts/memory_lookup.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男`
|
||||
(亦可按 `--name` / `--pillars "丁亥 乙巳 庚子 辛巳"` 任意组合检索)。有档则读档延续——此前的归纳、已核实/已证伪反馈在分析推理时直接参用,已证伪的断法不得再断;无档则正常排盘解释,解释完成后用 `python scripts/memory_save.py --name ... --gender ... --birth ... --place ...` 建档(建档即一次排盘,授权副本照常扣次)。
|
||||
- **回写纪律**:用户核对或澄清了任何断语后,当轮把结果写回该档案的 `## 反馈与澄清` 小节,每条以状态标签开头——推测性归纳标【未核实】,命主确认改【已核实】,被否改【已证伪】并补一句实际情况;结构性结论同步更新 `## 归纳` 各行。
|
||||
- **隐私**:档案属命主个人信息,只存本机、不随授权副本分发、不进 share;不得把档案原文外发(不粘贴到外部对话、不上传网络、不写入其他文件)。
|
||||
- **隐私**:档案属命主个人信息,只存本机、不随授权副本分发、档案原文不进 share;不得把档案原文外发(不粘贴到外部对话、不上传网络、不写入其他文件)。回传授权方的只有按下节规格生成的结构化脱敏包,且必须经用户授权。
|
||||
|
||||
## 档案共建(share/)
|
||||
|
||||
用户可自愿把**脱敏后**的命盘数据回传授权方,用于技能优化与规则蒸馏(脱敏规格、隐私协议与撤回方式见 `share/README.md`)。每成功推送一盘,本副本授权次数 **+10**;同一盘(按盘 hash)只计一次,不重复加次。
|
||||
|
||||
- **导出**:`python scripts/share_export.py --archive <档案路径>`(或按 `--birth/--place/--gender` 检索定位),生成结构化 JSON 脱敏包到 `share/pending/<盘hash>.json`。完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态),不满足则拒绝导出(退出码 2)。**未核实推测不得入包**:归纳小节的推测性结论一律不导出;反馈事件(带核实状态标签)可入包,自由文本(反馈原文、盘面细节、归纳文字)一律不入。
|
||||
- **授权询问(强制)**:推送前必须先把脱敏包**全文展示**给用户,并询问是否授权推送——用户同意才执行推送;不同意则取消,脱敏包保留在 `share/pending/`(用户可随时删除),不影响技能正常使用。
|
||||
- **推送**:`python scripts/share_push.py`,把 `share/pending/` 的包推送到 `share/config.json` 配置的共建远端(GitHub / Gitea 逐个尝试,至少一个成功即算成功;远端地址由授权方填写)。推送成功后包移入 `share/pushed/`,并告知用户新增授权次数(+10;该盘已计励过则不重复加)。推送失败(无 git / 无网络 / 远端不可达)保留 pending,不丢数据。
|
||||
|
||||
## 版本与自更新
|
||||
|
||||
- **版本基准**:仓库根 `VERSION` 文件(如 `3.5.0`)+ `manifest.json`(全部文件 SHA256 清单,发布时由 `tools/build_manifest.py` 生成)。
|
||||
- **更新提示(缓存制、不打扰排盘)**:每次排盘成功后,若本地缓存的检查结果已过期(24 小时)则后台线程拉一次远端 VERSION 刷新缓存(超时 3 秒、失败静默,主流程不等);缓存显示有新版本时,stderr 提示一行「技能有新版本 X.Y.Z,运行 `python scripts/self_update.py --apply` 可更新」。
|
||||
- **应用更新(须用户同意)**:用户说「更新技能」时再运行 `python scripts/self_update.py --apply`——从 GitHub 拉取(失败转 Gitea),**先按 manifest.json 逐文件校验 SHA256、任一不符即中止不动本地**,通过后覆盖更新;`data/.license.json`、`memory/archives/`、`share/pending/`、`share/pushed/`、`.git` 保留不动。不得替用户自动执行 --apply。
|
||||
|
||||
## 文风与反 AI 腔(强制)
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"copy_id": "LY-20261007-AC63",
|
||||
"licensee": "normal",
|
||||
"issued_at": "2026-10-07",
|
||||
"expires_at": "2026-12-31",
|
||||
"max_uses": 1000,
|
||||
"remaining_uses": 1000,
|
||||
"sig": "be2d5467c8bc1d78"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"edition": "public",
|
||||
"pack_version": "3.5.0",
|
||||
"pool_copy_id": "LY-20261007-AC63",
|
||||
"issued_at": "2026-10-09",
|
||||
"note": "公众版:首次排盘时自动签发个人副本(初始 20 次、不限期);推送脱敏档案每盘 +10 次(同一盘只计一次)。"
|
||||
}
|
||||
+1269
File diff suppressed because it is too large
Load Diff
@@ -4,16 +4,21 @@
|
||||
本锁只是授权边界的可见表达,真正的约束在 LICENSE.md 的法律条款——
|
||||
绕过、篡改、移除软锁或水印,即构成对授权的违反。
|
||||
|
||||
母版(技能开发目录)无 `data/.license.json`,本模块全部放行;
|
||||
母版(技能开发目录)无 `data/.license.json` 且无公众版标记,本模块全部放行;
|
||||
由 `tools/make_license_copy.py` 生成的定制副本带该状态文件,
|
||||
按剩余次数与到期日限制使用,并以签名防止手工篡改状态文件。
|
||||
|
||||
公众版副本(`tools/make_license_copy.py --public-edition`)不带授权状态文件,
|
||||
只带标记 `data/.public_edition.json`;首次 check 时就地签发个人副本
|
||||
(初始 20 次、不限期、授权对象 public-personal),之后按定制副本同样校验。
|
||||
|
||||
退出码 4 = 授权不可用(次数用尽 / 已过期 / 状态文件被篡改)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,6 +27,11 @@ _ENABLED = True # 软锁总开关:False = 完全放行
|
||||
_SALT = "lingyao-miaojie-ziping-eightchar"
|
||||
_COMPANY = "四川灵爻妙解文化传播有限公司"
|
||||
STATE_REL = Path("data") / ".license.json"
|
||||
PUBLIC_MARKER_REL = Path("data") / ".public_edition.json"
|
||||
|
||||
# 公众版首启签发的个人副本参数
|
||||
PUBLIC_LICENSEE = "public-personal"
|
||||
PUBLIC_INITIAL_USES = 20
|
||||
|
||||
|
||||
def _state_path(root: Path) -> Path:
|
||||
@@ -55,13 +65,28 @@ def _deny(reason: str, st: dict) -> str:
|
||||
f"如需续期或正式授权,请联系{_COMPANY}。")
|
||||
|
||||
|
||||
def _issue_public_personal(root: Path) -> None:
|
||||
"""公众版首启:就地签发个人副本(初始 20 次、不限期)。"""
|
||||
write_state(root,
|
||||
copy_id=f"LY-{date.today():%Y%m%d}-{secrets.token_hex(2).upper()}",
|
||||
licensee=PUBLIC_LICENSEE,
|
||||
max_uses=PUBLIC_INITIAL_USES,
|
||||
expires_at=None)
|
||||
|
||||
|
||||
def check(root: Path) -> str | None:
|
||||
"""返回 None 表示放行;返回字符串表示拒跑原因(由调用方打印到 stderr,退出码 4)。"""
|
||||
if not _ENABLED:
|
||||
return None
|
||||
st = _load(root)
|
||||
if st is None:
|
||||
return None # 母版无状态文件,不锁
|
||||
if (root / PUBLIC_MARKER_REL).is_file():
|
||||
_issue_public_personal(root) # 公众版首启:就地签发个人副本
|
||||
st = _load(root)
|
||||
else:
|
||||
return None # 母版无状态文件,不锁
|
||||
if st is None: # 签发失败(如磁盘只读)按母版放行,不阻断使用
|
||||
return None
|
||||
if st.get("sig") != _sig(st):
|
||||
return _deny("授权状态校验失败(授权文件被改动过)", st)
|
||||
exp = st.get("expires_at")
|
||||
@@ -95,9 +120,35 @@ def write_state(root: Path, *, copy_id: str, licensee: str,
|
||||
"expires_at": expires_at,
|
||||
"max_uses": max_uses,
|
||||
"remaining_uses": max_uses,
|
||||
"granted_archives": [],
|
||||
}
|
||||
st["sig"] = _sig(st)
|
||||
p = _state_path(root)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return st
|
||||
|
||||
|
||||
def grant_uses(root: Path, n: int, archive_hash: str) -> bool:
|
||||
"""脱敏档案共建的计励:推送成功后给本副本加 n 次。
|
||||
|
||||
同一盘(archive_hash)只计一次:已在已计励列表中则返回 False 且不加次。
|
||||
母版(无授权状态文件)不计励,直接返回 True。
|
||||
状态文件签名校验失败(被篡改)时拒绝计励,返回 False。
|
||||
"""
|
||||
if not _ENABLED:
|
||||
return True
|
||||
st = _load(root)
|
||||
if st is None:
|
||||
return True # 母版无锁,不计
|
||||
if st.get("sig") != _sig(st):
|
||||
return False
|
||||
granted = st.setdefault("granted_archives", [])
|
||||
if archive_hash in granted:
|
||||
return False
|
||||
st["remaining_uses"] = int(st.get("remaining_uses", 0)) + int(n)
|
||||
granted.append(archive_hash)
|
||||
st["sig"] = _sig(st)
|
||||
_state_path(root).write_text(
|
||||
json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return True
|
||||
|
||||
@@ -129,6 +129,18 @@ def main() -> int:
|
||||
# L2 渲染:stdout 只出 Markdown
|
||||
print(render(chart))
|
||||
_gate.consume(Path(__file__).resolve().parents[1])
|
||||
|
||||
# 自更新提示:只读本地缓存往 stderr 打一行;缓存过期则后台线程刷新,
|
||||
# 主流程不等它(用当次缓存)。任何异常静默,绝不影响 stdout 命盘契约。
|
||||
try:
|
||||
import self_update as _su
|
||||
_root = Path(__file__).resolve().parents[1]
|
||||
_msg = _su.notify_message(_root)
|
||||
if _msg:
|
||||
print(_msg, file=sys.stderr)
|
||||
_su.refresh_cache_async(_root)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
except Exception:
|
||||
print("ERROR: 排盘内部错误。请把以下信息原样报告给用户,"
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
"""技能自更新:版本检查、异步提示、应用更新。
|
||||
|
||||
用法:
|
||||
python scripts/self_update.py --check 检查更新(24 小时缓存,重复检查不拉网络)
|
||||
python scripts/self_update.py --notify 只读缓存打印提示,绝不碰网络(供 cast_chart 调用)
|
||||
python scripts/self_update.py --apply 下载 zipball → 按 manifest 逐文件校验 → 同步本地
|
||||
|
||||
退出码(--apply):0 成功;2 无需更新或配置缺失;3 失败(本地原样不动)。
|
||||
|
||||
远端双线路 failover:GitHub 优先,Gitea 兜底。HTTPS_PROXY/HTTP_PROXY 等
|
||||
代理环境变量由 urllib 默认读取、自动生效。测试或自建场景可用环境变量
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS(逗号分隔)整体覆盖远端地址,
|
||||
支持 file:// 形式。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
# ---- 远端配置(GitHub 优先,Gitea 兜底)----
|
||||
GITHUB_VERSION_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/VERSION"
|
||||
GITHUB_ZIPBALL_URL = "https://codeload.github.com/mxiaonian/ZiPingEightChar/zip/refs/heads/main"
|
||||
# Gitea 占位地址:由授权方填写实际自建 Gitea 地址后启用
|
||||
GITEA_VERSION_URL = "https://gitea.example.com/owner/ZiPingEightChar/raw/branch/main/VERSION"
|
||||
GITEA_ZIPBALL_URL = "https://gitea.example.com/owner/ZiPingEightChar/archive/main.zip"
|
||||
|
||||
CACHE_REL = "data/.update_check.json"
|
||||
CACHE_TTL_SECONDS = 24 * 3600
|
||||
NETWORK_TIMEOUT = 3
|
||||
|
||||
# 更新管辖区之外:同步时绝不覆盖、绝不删除(与 tools/build_manifest.py 排除表一致)
|
||||
PRESERVE_FILES = {"data/.license.json", "data/.update_check.json"}
|
||||
PRESERVE_DIRS = {".git", "memory/archives", "share/pending", "share/pushed"}
|
||||
PRESERVE_DIR_NAMES = {".git", "__pycache__", ".pytest_cache"}
|
||||
PRESERVE_FILE_NAMES = {".DS_Store"}
|
||||
PRESERVE_SUFFIXES = {".pyc"}
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _version_urls() -> list:
|
||||
env = os.environ.get("ZIPEC_VERSION_URLS")
|
||||
if env:
|
||||
return [u.strip() for u in env.split(",") if u.strip()]
|
||||
return [GITHUB_VERSION_URL, GITEA_VERSION_URL]
|
||||
|
||||
|
||||
def _zipball_urls() -> list:
|
||||
env = os.environ.get("ZIPEC_ZIPBALL_URLS")
|
||||
if env:
|
||||
return [u.strip() for u in env.split(",") if u.strip()]
|
||||
return [GITHUB_ZIPBALL_URL, GITEA_ZIPBALL_URL]
|
||||
|
||||
|
||||
def _fetch_text(url: str, timeout: int = NETWORK_TIMEOUT) -> str:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.read().decode("utf-8")
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, timeout: int = NETWORK_TIMEOUT) -> None:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
with open(dest, "wb") as fh:
|
||||
shutil.copyfileobj(r, fh)
|
||||
|
||||
|
||||
def parse_version(text: str) -> tuple:
|
||||
parts = text.strip().split(".")
|
||||
if not parts or any(not p.isdigit() for p in parts):
|
||||
raise ValueError(f"无法解析版本号: {text!r}")
|
||||
return tuple(int(p) for p in parts)
|
||||
|
||||
|
||||
def read_local_version(root: Path):
|
||||
try:
|
||||
return (root / "VERSION").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# ---- 缓存(data/.update_check.json)----
|
||||
|
||||
def load_cache(root: Path):
|
||||
try:
|
||||
return json.loads((root / CACHE_REL).read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(root: Path, remote_version, local_version) -> dict:
|
||||
update_available = False
|
||||
if remote_version and local_version:
|
||||
try:
|
||||
update_available = parse_version(remote_version) > parse_version(local_version)
|
||||
except ValueError:
|
||||
update_available = False
|
||||
cache = {
|
||||
"checked_at": time.time(),
|
||||
"checked_at_iso": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"local_version": local_version,
|
||||
"remote_version": remote_version,
|
||||
"update_available": update_available,
|
||||
}
|
||||
p = root / CACHE_REL
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return cache
|
||||
|
||||
|
||||
def _cache_fresh(cache) -> bool:
|
||||
try:
|
||||
return time.time() - float(cache.get("checked_at", 0)) < CACHE_TTL_SECONDS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cache_stale(root: Path) -> bool:
|
||||
cache = load_cache(root)
|
||||
return not _cache_fresh(cache)
|
||||
|
||||
|
||||
def _fetch_remote_version():
|
||||
"""双线 failover:按序尝试各源,返回第一个成功的 VERSION 文本;全失败返回 None。"""
|
||||
for url in _version_urls():
|
||||
try:
|
||||
text = _fetch_text(url).strip()
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
# ---- --check / --notify ----
|
||||
|
||||
def _print_check_result(cache, note: str = "") -> None:
|
||||
if cache.get("update_available") and cache.get("remote_version"):
|
||||
print(f"有新版本 {cache['remote_version']}(当前 {cache.get('local_version') or '?'}){note}")
|
||||
else:
|
||||
print(f"已是最新({cache.get('local_version') or cache.get('remote_version') or '?'}){note}")
|
||||
|
||||
|
||||
def cmd_check(root: Path, force: bool = False) -> int:
|
||||
cache = load_cache(root)
|
||||
if not force and _cache_fresh(cache):
|
||||
_print_check_result(cache, note="(24 小时内已检查过)")
|
||||
return 0
|
||||
remote = _fetch_remote_version()
|
||||
if remote is None:
|
||||
if cache:
|
||||
_print_check_result(cache, note="(网络不可达,沿用上次结果)")
|
||||
return 0
|
||||
print("检查失败:远端 VERSION 不可达", file=sys.stderr)
|
||||
return 3
|
||||
_print_check_result(_write_cache(root, remote, read_local_version(root)))
|
||||
return 0
|
||||
|
||||
|
||||
def notify_message(root: Path):
|
||||
"""只读缓存、绝不碰网络:有可更新版本时返回一行提示,否则返回 None。"""
|
||||
cache = load_cache(root)
|
||||
if not cache or not cache.get("update_available"):
|
||||
return None
|
||||
ver = cache.get("remote_version") or "?"
|
||||
return f"技能有新版本 {ver},运行 `python scripts/self_update.py --apply` 可更新"
|
||||
|
||||
|
||||
def cmd_notify(root: Path) -> int:
|
||||
msg = notify_message(root)
|
||||
if msg:
|
||||
print(msg, file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def refresh_cache(root: Path) -> None:
|
||||
"""同步刷新缓存;任何失败都静默(供后台线程调用)。"""
|
||||
try:
|
||||
remote = _fetch_remote_version()
|
||||
if remote is not None:
|
||||
_write_cache(root, remote, read_local_version(root))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def refresh_cache_async(root: Path) -> None:
|
||||
"""缓存过期(>24h)才起后台线程拉一次远端 VERSION;主流程不等它。"""
|
||||
try:
|
||||
if not cache_stale(root):
|
||||
return
|
||||
threading.Thread(target=refresh_cache, args=(root,), daemon=True).start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- --apply ----
|
||||
|
||||
def _rel(root: Path, p: Path) -> str:
|
||||
return p.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def _preserved_dir(root: Path, d: Path) -> bool:
|
||||
if d.name in PRESERVE_DIR_NAMES:
|
||||
return True
|
||||
rel = _rel(root, d)
|
||||
return any(rel == ex or rel.startswith(ex + "/") for ex in PRESERVE_DIRS)
|
||||
|
||||
|
||||
def _preserved_file(root: Path, f: Path) -> bool:
|
||||
rel = _rel(root, f)
|
||||
if rel in PRESERVE_FILES or f.name in PRESERVE_FILE_NAMES:
|
||||
return True
|
||||
if f.suffix in PRESERVE_SUFFIXES:
|
||||
return True
|
||||
# 位于保留目录(或其子目录)内的文件一律保留
|
||||
parts = rel.split("/")[:-1]
|
||||
for i, name in enumerate(parts):
|
||||
if name in PRESERVE_DIR_NAMES or "/".join(parts[: i + 1]) in PRESERVE_DIRS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> None:
|
||||
dest_real = os.path.realpath(dest)
|
||||
for info in zf.infolist():
|
||||
target = os.path.realpath(dest / info.filename)
|
||||
if target != dest_real and not target.startswith(dest_real + os.sep):
|
||||
raise ValueError(f"zip 内含越界路径: {info.filename!r}")
|
||||
zf.extractall(dest)
|
||||
|
||||
|
||||
def _find_source_root(extract_dir: Path):
|
||||
"""zipball 通常带一层顶层目录(GitHub: <repo>-main/;Gitea: <repo>/)。"""
|
||||
if (extract_dir / "manifest.json").is_file():
|
||||
return extract_dir
|
||||
hits = [d for d in sorted(extract_dir.iterdir())
|
||||
if d.is_dir() and (d / "manifest.json").is_file()]
|
||||
return hits[0] if len(hits) == 1 else None
|
||||
|
||||
|
||||
def _verify_manifest(src: Path, files: dict):
|
||||
"""逐文件校验 sha256;返回错误信息列表(空 = 全部通过)。只校验,不动本地。"""
|
||||
errors = []
|
||||
for rel, want in files.items():
|
||||
f = src / rel
|
||||
if not f.is_file():
|
||||
errors.append(f"缺失 {rel}")
|
||||
elif _sha256(f) != want:
|
||||
errors.append(f"校验不符 {rel}")
|
||||
return errors
|
||||
|
||||
|
||||
def _sync(root: Path, src: Path, files: dict) -> None:
|
||||
managed = set(files) | {"manifest.json", "VERSION"}
|
||||
copied = deleted = 0
|
||||
for rel in sorted(managed - {"VERSION"}): # VERSION 最后写
|
||||
s = src / rel
|
||||
if not s.is_file():
|
||||
continue
|
||||
d = root / rel
|
||||
d.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(s, d)
|
||||
copied += 1
|
||||
# 删除本地多余但清单不含的文件(仅清单管辖区;保留区一律不动)
|
||||
for dirpath, dirnames, filenames in os.walk(root, topdown=False):
|
||||
d = Path(dirpath)
|
||||
for name in filenames:
|
||||
f = d / name
|
||||
if _preserved_file(root, f) or _rel(root, f) in managed:
|
||||
continue
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
if d != root and not _preserved_dir(root, d) and not any(d.iterdir()):
|
||||
d.rmdir()
|
||||
# 最后写 VERSION
|
||||
if (src / "VERSION").is_file():
|
||||
shutil.copy2(src / "VERSION", root / "VERSION")
|
||||
print(f"同步完成:写入 {copied} 个文件,清理 {deleted} 个多余文件")
|
||||
|
||||
|
||||
def cmd_apply(root: Path) -> int:
|
||||
local = read_local_version(root)
|
||||
if local is None:
|
||||
print("配置缺失:本地没有 VERSION 文件", file=sys.stderr)
|
||||
return 2
|
||||
print(f"本地版本 {local},正在获取远端版本…")
|
||||
remote = _fetch_remote_version()
|
||||
if remote is None:
|
||||
print("失败:远端 VERSION 不可达(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
print(f"远端版本 {remote}")
|
||||
try:
|
||||
if parse_version(remote) <= parse_version(local):
|
||||
print("已是最新,无需更新")
|
||||
return 2
|
||||
except ValueError as e:
|
||||
print(f"失败:{e}(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="zipec-update-") as td:
|
||||
zip_path = Path(td) / "remote.zip"
|
||||
ok = False
|
||||
for url in _zipball_urls():
|
||||
try:
|
||||
print(f"下载 {url} …")
|
||||
_download(url, zip_path)
|
||||
ok = True
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" 下载失败({e}),尝试下一源")
|
||||
if not ok:
|
||||
print("失败:所有更新源均不可达(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
try:
|
||||
extract_dir = Path(td) / "src"
|
||||
extract_dir.mkdir()
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
_safe_extract(zf, extract_dir)
|
||||
except Exception as e:
|
||||
print(f"失败:zip 解压异常({e})(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
src = _find_source_root(extract_dir)
|
||||
if src is None:
|
||||
print("失败:远端包内找不到 manifest.json(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
try:
|
||||
manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8"))
|
||||
files = manifest["files"]
|
||||
except Exception as e:
|
||||
print(f"失败:manifest.json 无法解析({e})(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
errors = _verify_manifest(src, files)
|
||||
if errors:
|
||||
print(f"失败:{len(errors)} 个文件与清单不符,已中止(本地未改动)",
|
||||
file=sys.stderr)
|
||||
for e in errors[:5]:
|
||||
print(f" {e}", file=sys.stderr)
|
||||
return 3
|
||||
print(f"清单校验通过({len(files)} 个文件),开始同步…")
|
||||
_sync(root, src, files)
|
||||
|
||||
try:
|
||||
_write_cache(root, remote, remote)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"更新完成:{local} → {remote}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(prog="self_update.py", description="技能自更新")
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--check", action="store_true", help="检查更新(24 小时缓存)")
|
||||
g.add_argument("--notify", action="store_true", help="只读缓存打印提示,不碰网络")
|
||||
g.add_argument("--apply", action="store_true", help="校验并应用更新")
|
||||
a = ap.parse_args()
|
||||
root = repo_root()
|
||||
if a.check:
|
||||
return cmd_check(root)
|
||||
if a.notify:
|
||||
return cmd_notify(root)
|
||||
return cmd_apply(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""脱敏档案导出 —— 从 memory 档案生成结构化脱敏包(JSON)到 share/pending/。
|
||||
|
||||
用法:
|
||||
python share_export.py --archive memory/archives/庚午辛巳己卯戊辰-男-a1b2c3.md
|
||||
python share_export.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男
|
||||
|
||||
脱敏规格(见 share/README.md):不含姓名;出生信息只保留年月日与时辰(不留分钟
|
||||
与钟表时间);地域只保留到省级;事件一律类目化;正文自由文本(归纳、盘面细节、
|
||||
反馈原文)不入包。盘 hash = sha1(四柱+出生地) 前 8 位,用于去重与撤回。
|
||||
|
||||
完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态);
|
||||
不满足拒绝导出,退出码 2 并说明缺什么。退出码:0 成功;2 不可用/不完整;3 内部错误。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _memory_store as store
|
||||
|
||||
SCHEMA = "share/v1"
|
||||
PLACEHOLDERS = {"(无)", "(未记录)", "无", "未记录"}
|
||||
|
||||
# 事件/关注方向类目化:首个命中的类目生效,都不命中归「其他」
|
||||
CATEGORIES: list[tuple[str, tuple[str, ...]]] = [
|
||||
("婚姻", ("婚姻", "结婚", "离婚", "配偶", "夫妻", "婚变", "再婚")),
|
||||
("感情", ("感情", "恋爱", "桃花", "对象", "分手", "复合")),
|
||||
("事业", ("事业", "工作", "升职", "跳槽", "调动", "失业", "创业", "辞职", "职务", "官非")),
|
||||
("财运", ("财运", "破财", "投资", "收入", "赚钱", "欠债", "债务", "置业", "买房")),
|
||||
("健康", ("健康", "生病", "手术", "伤病", "身体", "住院", "疾病", "意外")),
|
||||
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书")),
|
||||
("六亲", ("父亲", "母亲", "父母", "子女", "孩子", "兄弟", "姐妹", "六亲", "亲人", "丧")),
|
||||
("流年运势", ("流年", "运势", "大运", "运程")),
|
||||
]
|
||||
|
||||
_PROVINCES = (
|
||||
"北京市", "天津市", "上海市", "重庆市",
|
||||
"河北省", "山西省", "辽宁省", "吉林省", "黑龙江省", "江苏省", "浙江省",
|
||||
"安徽省", "福建省", "江西省", "山东省", "河南省", "湖北省", "湖南省",
|
||||
"广东省", "海南省", "四川省", "贵州省", "云南省", "陕西省", "甘肃省",
|
||||
"青海省", "台湾省",
|
||||
"内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区",
|
||||
"新疆维吾尔自治区", "香港特别行政区", "澳门特别行政区",
|
||||
)
|
||||
_PROVINCE_SHORT = {
|
||||
"北京": "北京市", "天津": "天津市", "上海": "上海市", "重庆": "重庆市",
|
||||
"内蒙古": "内蒙古自治区", "广西": "广西壮族自治区", "西藏": "西藏自治区",
|
||||
"宁夏": "宁夏回族自治区", "新疆": "新疆维吾尔自治区",
|
||||
"香港": "香港特别行政区", "澳门": "澳门特别行政区",
|
||||
}
|
||||
|
||||
_STATUS_TAGS = {"【已核实】": "已核实", "【已证伪】": "已证伪", "【未核实】": "未核实"}
|
||||
|
||||
|
||||
def _categorize_all(text: str) -> list[str]:
|
||||
return [cat for cat, keywords in CATEGORIES if any(k in text for k in keywords)]
|
||||
|
||||
|
||||
def _categorize(text: str) -> str:
|
||||
cats = _categorize_all(text)
|
||||
return cats[0] if cats else "其他"
|
||||
|
||||
|
||||
def _province_of(birthplace: str) -> str | None:
|
||||
"""地域脱敏:只保留到省级;识别不出省级则舍弃(宁缺毋滥)。"""
|
||||
for p in _PROVINCES:
|
||||
if p in birthplace:
|
||||
return p
|
||||
for short, full in _PROVINCE_SHORT.items():
|
||||
if birthplace.startswith(short):
|
||||
return full
|
||||
m = re.match(r"^(.{2,6}?省)", birthplace)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _section_lines(body: str, heading: str) -> list[str]:
|
||||
"""取档案正文某固定小节(SECTIONS 内)的行,到下一个固定小节或文末。"""
|
||||
lines = body.splitlines()
|
||||
bounds = {f"## {s}" for s in store.SECTIONS} - {f"## {heading}"}
|
||||
start = next((i for i, ln in enumerate(lines)
|
||||
if ln.strip() == f"## {heading}"), None)
|
||||
if start is None:
|
||||
return []
|
||||
end = next((i for i in range(start + 1, len(lines))
|
||||
if lines[i].strip() in bounds), len(lines))
|
||||
return lines[start + 1:end]
|
||||
|
||||
|
||||
def _chart_section_lines(body: str, heading: str, nxt: str) -> list[str]:
|
||||
"""取「盘面」内嵌命盘 Markdown 的小节(如 ## 大运 到 ## 小运)。"""
|
||||
lines = body.splitlines()
|
||||
start = next((i for i, ln in enumerate(lines)
|
||||
if ln.strip() == f"## {heading}"), None)
|
||||
if start is None:
|
||||
return []
|
||||
end = next((i for i in range(start + 1, len(lines))
|
||||
if lines[i].strip() == f"## {nxt}"), len(lines))
|
||||
return lines[start + 1:end]
|
||||
|
||||
|
||||
def _parse_luck(body: str) -> list[dict]:
|
||||
luck = []
|
||||
for ln in _chart_section_lines(body, "大运", "小运"):
|
||||
m = re.match(r"^-\s*第\s*(\d+)\s*步:(\S{2})([^)]*,(\d{4})\s*年)", ln.strip())
|
||||
if m:
|
||||
luck.append({"seq": int(m.group(1)), "pillar": m.group(2),
|
||||
"start_year": int(m.group(3))})
|
||||
return luck
|
||||
|
||||
|
||||
def _parse_focus(body: str) -> list[str]:
|
||||
cats: list[str] = []
|
||||
for ln in _section_lines(body, "主要关注方向"):
|
||||
t = ln.strip().lstrip("-•· ").strip()
|
||||
if not t or t in PLACEHOLDERS:
|
||||
continue
|
||||
for cat in _categorize_all(t):
|
||||
if cat not in cats:
|
||||
cats.append(cat)
|
||||
return cats
|
||||
|
||||
|
||||
def _parse_feedback(body: str) -> list[dict]:
|
||||
"""反馈事件:只取年份、类目、核实状态——反馈原文不入包。"""
|
||||
events = []
|
||||
for ln in _section_lines(body, "反馈与澄清"):
|
||||
t = ln.strip().lstrip("-•· ").strip()
|
||||
if not t or t in PLACEHOLDERS:
|
||||
continue
|
||||
status = "未核实"
|
||||
for tag, name in _STATUS_TAGS.items():
|
||||
if tag in t:
|
||||
status = name
|
||||
break
|
||||
m = re.search(r"(?:19|20)\d{2}", t)
|
||||
events.append({"year": int(m.group(0)) if m else None,
|
||||
"category": _categorize(t),
|
||||
"status": status})
|
||||
return events
|
||||
|
||||
|
||||
def chart_hash(pillars: list[str], birthplace: str) -> str:
|
||||
raw = f"{''.join(pillars)}|{birthplace.strip()}"
|
||||
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
|
||||
|
||||
|
||||
def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
|
||||
"""组装脱敏包;返回 (package, 缺失项列表)。package 保证不含姓名/分钟/省以下地域。"""
|
||||
pillars = meta.get("pillars", [])
|
||||
if isinstance(pillars, str):
|
||||
pillars = pillars.split()
|
||||
pillars = [str(p) for p in pillars]
|
||||
birthplace = str(meta.get("birthplace", ""))
|
||||
gender = str(meta.get("gender", ""))
|
||||
|
||||
pkg: dict = {"schema": SCHEMA,
|
||||
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"chart_hash": chart_hash(pillars, birthplace),
|
||||
"gender": gender or None,
|
||||
"pillars": pillars,
|
||||
"luck_pillars": _parse_luck(body),
|
||||
"focus_categories": _parse_focus(body),
|
||||
"feedback": _parse_feedback(body)}
|
||||
|
||||
# 出生信息脱敏:只保留年月日与时辰(时支),不留分钟与钟表时间
|
||||
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", str(meta.get("birth_clock", "")).strip())
|
||||
if m:
|
||||
pkg["birth_date"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
if len(pillars) == 4 and len(pillars[3]) == 2:
|
||||
pkg["birth_shichen"] = pillars[3][1]
|
||||
province = _province_of(birthplace)
|
||||
if province:
|
||||
pkg["region_province"] = province
|
||||
|
||||
missing = []
|
||||
if not gender:
|
||||
missing.append("性别")
|
||||
if len(pillars) != 4:
|
||||
missing.append("四柱")
|
||||
if not pkg["luck_pillars"]:
|
||||
missing.append("大运序列")
|
||||
if not pkg["feedback"]:
|
||||
missing.append("反馈事件(至少一条,任意核实状态)")
|
||||
return pkg, missing
|
||||
|
||||
|
||||
def _find_archive(args) -> Path | None:
|
||||
if args.archive:
|
||||
p = Path(args.archive).expanduser().resolve()
|
||||
return p if p.is_file() else None
|
||||
root = store.resolve_root(args.memory_root, __file__)
|
||||
for path in store.iter_archives(root):
|
||||
meta, _ = store.parse_frontmatter(path.read_text(encoding="utf-8"))
|
||||
if meta and store.matches(meta, name=args.name, birth=args.birth,
|
||||
place=args.place, gender=args.gender,
|
||||
pillars=args.pillars):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="share_export.py",
|
||||
description="从 memory 档案导出结构化脱敏包(JSON)到 share/pending/;不含姓名、分钟、省以下地域")
|
||||
ap.add_argument("--archive", default=None, help="档案文件路径(直接指定)")
|
||||
ap.add_argument("--name", default=None, help="姓名(检索条件)")
|
||||
ap.add_argument("--gender", default=None, choices=["男", "女"], help="性别(检索条件)")
|
||||
ap.add_argument("--birth", default=None,
|
||||
help='出生时刻,格式 "YYYY-MM-DD HH:MM"(检索条件)')
|
||||
ap.add_argument("--place", default=None, help="出生地(检索条件)")
|
||||
ap.add_argument("--pillars", default=None,
|
||||
help='四柱干支,如 "丁亥 乙巳 庚子 辛巳"(检索条件)')
|
||||
ap.add_argument("--memory-root", default=None,
|
||||
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
|
||||
ap.add_argument("--share-root", default=None,
|
||||
help="share/ 目录(默认技能根 share/,测试覆盖用)")
|
||||
a = ap.parse_args()
|
||||
|
||||
if not a.archive and all(v is None for v in (a.name, a.gender, a.birth,
|
||||
a.place, a.pillars)):
|
||||
print("ERROR: 给 --archive <档案路径>,或至少一个检索条件"
|
||||
"(--name/--birth/--place/--gender/--pillars)。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
arch = _find_archive(a)
|
||||
if arch is None:
|
||||
print("ERROR: 未找到匹配档案(先用 scripts/memory_save.py 建档,"
|
||||
"并确认检索条件与档案一致)。", file=sys.stderr)
|
||||
return 2
|
||||
meta, body = store.parse_frontmatter(arch.read_text(encoding="utf-8"))
|
||||
pkg, missing = build_package(meta, body)
|
||||
if missing:
|
||||
print(f"ERROR: 档案完整度不足,拒绝导出。缺:{'、'.join(missing)}。"
|
||||
f"(性别/四柱/大运齐全 + 至少一条反馈事件才可导出)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
share_root = (Path(a.share_root).expanduser().resolve() if a.share_root
|
||||
else Path(__file__).resolve().parents[1] / "share")
|
||||
pending = share_root / "pending"
|
||||
pending.mkdir(parents=True, exist_ok=True)
|
||||
out = pending / f"{pkg['chart_hash']}.json"
|
||||
out.write_text(json.dumps(pkg, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print(out)
|
||||
return 0
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
print("ERROR: 导出内部错误。", file=sys.stderr)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""脱敏包推送 —— 把 share/pending/ 的包 git commit+push 到共建远端。
|
||||
|
||||
用法:python share_push.py [--root 技能根目录]
|
||||
|
||||
远端取自 share/config.json 的 github_remote / gitea_remote(占位为空则跳过,
|
||||
由授权方填写;亦可用环境变量 ZIPING_SHARE_GITHUB_REMOTE /
|
||||
ZIPING_SHARE_GITEA_REMOTE 覆盖)。两个远端逐个尝试,至少一个成功即算成功。
|
||||
|
||||
推送成功后:调 _license_gate.grant_uses(root, 10, 盘hash) 计励(同一盘只计一次),
|
||||
并把该包移入 share/pushed/。推送失败保留 pending,退出码 3。
|
||||
无 git 或无网络安静失败(不打栈)。
|
||||
|
||||
退出码:0 至少一包推送成功或无包可推;2 输入不可用;3 推送失败。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _license_gate as gate
|
||||
|
||||
GRANT_USES = 10 # 每盘推送成功的计励次数
|
||||
ENV_GITHUB = "ZIPING_SHARE_GITHUB_REMOTE"
|
||||
ENV_GITEA = "ZIPING_SHARE_GITEA_REMOTE"
|
||||
_GIT_ENV = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "true"}
|
||||
|
||||
|
||||
def _git(*args: str, cwd: str | None = None) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", *args], cwd=cwd, env=_GIT_ENV,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
def _push_to_remote(remote: str, pkg: Path, workdir: str) -> bool:
|
||||
"""克隆远端 → 放入脱敏包 → commit+push。任一步失败返回 False(安静)。"""
|
||||
try:
|
||||
if _git("clone", "--depth", "50", remote, workdir).returncode != 0:
|
||||
return False
|
||||
dst = Path(workdir) / pkg.name
|
||||
shutil.copy2(pkg, dst)
|
||||
if _git("add", pkg.name, cwd=workdir).returncode != 0:
|
||||
return False
|
||||
commit = _git("-c", "user.name=ziping-share",
|
||||
"-c", "user.email=ziping-share@localhost",
|
||||
"commit", "--allow-empty", "-m", f"share: {pkg.stem}",
|
||||
cwd=workdir)
|
||||
if commit.returncode != 0:
|
||||
return False
|
||||
return _git("push", "origin", "HEAD:main", cwd=workdir).returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def _remotes(root: Path) -> list[tuple[str, str]]:
|
||||
cfg = {}
|
||||
cfg_path = root / "share" / "config.json"
|
||||
if cfg_path.is_file():
|
||||
try:
|
||||
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
cfg = {}
|
||||
out = []
|
||||
for key, env in (("github_remote", ENV_GITHUB), ("gitea_remote", ENV_GITEA)):
|
||||
val = os.environ.get(env) or str(cfg.get(key) or "").strip()
|
||||
if val:
|
||||
out.append((key, val))
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(prog="share_push.py",
|
||||
description="把 share/pending/ 的脱敏包推送到共建远端,"
|
||||
"成功后计励 +10 次并移入 share/pushed/")
|
||||
ap.add_argument("--root", default=None,
|
||||
help="技能根目录(默认取脚本所在技能根,测试覆盖用)")
|
||||
a = ap.parse_args()
|
||||
root = (Path(a.root).expanduser().resolve() if a.root
|
||||
else Path(__file__).resolve().parents[1])
|
||||
|
||||
if shutil.which("git") is None:
|
||||
print("ERROR: 未找到 git,无法推送;pending 保留。", file=sys.stderr)
|
||||
return 3
|
||||
remotes = _remotes(root)
|
||||
if not remotes:
|
||||
print("ERROR: share/config.json 未配置远端(github_remote/gitea_remote "
|
||||
"由授权方填写);pending 保留。", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
pending = root / "share" / "pending"
|
||||
pushed = root / "share" / "pushed"
|
||||
packages = sorted(pending.glob("*.json")) if pending.is_dir() else []
|
||||
if not packages:
|
||||
print("share/pending/ 无待推送脱敏包。")
|
||||
return 0
|
||||
|
||||
pushed.mkdir(parents=True, exist_ok=True)
|
||||
n_ok = 0
|
||||
for pkg in packages:
|
||||
chart_hash = pkg.stem
|
||||
ok_remote = None
|
||||
for name, remote in remotes:
|
||||
with tempfile.TemporaryDirectory(prefix="ziping-share-") as td:
|
||||
if _push_to_remote(remote, pkg, td):
|
||||
ok_remote = name
|
||||
break
|
||||
if ok_remote is None:
|
||||
print(f"FAIL {pkg.name}:所有远端推送失败(无网络或远端不可达),"
|
||||
f"pending 保留。", file=sys.stderr)
|
||||
continue
|
||||
granted = gate.grant_uses(root, GRANT_USES, chart_hash)
|
||||
shutil.move(str(pkg), str(pushed / pkg.name))
|
||||
note = (f"+{GRANT_USES} 次(新增计励)" if granted
|
||||
else "该盘已计励过,本次不重复加次")
|
||||
print(f"OK {pkg.name} → {ok_remote};{note}")
|
||||
n_ok += 1
|
||||
|
||||
return 0 if n_ok else 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,34 @@
|
||||
# 脱敏档案共建(share/)
|
||||
|
||||
本目录是「命主档案共建」的唯一出口:使用者自愿把**脱敏后的**命盘数据回传给授权方,
|
||||
用于技能优化与规则蒸馏。每成功推送一盘,本副本授权次数 +10(同一盘只计一次)。
|
||||
|
||||
- `pending/` —— 已导出、待推送的脱敏包(`scripts/share_export.py` 生成);
|
||||
- `pushed/` —— 已推送成功的脱敏包(`scripts/share_push.py` 移入);
|
||||
- `config.json` —— 接收远端配置,**远端地址由授权方填写**(占位为空时不推送)。
|
||||
|
||||
`pending/` 与 `pushed/` 的数据不随授权副本分发(`tools/` 已排除),骨架照常分发。
|
||||
|
||||
## 隐私协议与脱敏规格
|
||||
|
||||
脱敏包为结构化 JSON(`schema: share/v1`),不是档案原文。导出时强制:
|
||||
|
||||
| 项目 | 处理 |
|
||||
|---|---|
|
||||
| 姓名 | 一律删除,不入包 |
|
||||
| 出生信息 | 只保留年月日与时辰(时辰以时支表示),不留分钟与钟表时间 |
|
||||
| 地域 | 只保留到省级,省以下(市、县、区县)一律删除 |
|
||||
| 事件 | 一律类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他),不写细节 |
|
||||
| 正文自由文本 | 不入包(归纳、盘面细节、反馈原文均不导出) |
|
||||
| 标识 | 仅以盘 hash(四柱+出生地 sha1 前 8 位)标识,用于去重与撤回 |
|
||||
|
||||
## 用途与许可
|
||||
|
||||
脱敏档案数据**仅用于本技能的优化与规则蒸馏**(校验取格、断运规则的命中率),
|
||||
不另作他用,不对外出售或转授。推送即视为同意上述用途;不同意则不推送,
|
||||
`pending/` 中的包可随时删除,不影响技能正常使用。
|
||||
|
||||
## 撤回
|
||||
|
||||
如需撤回已推送的脱敏档案,联系授权方(见 `LICENSE.md`),提供盘 hash
|
||||
(脱敏包文件名即盘 hash),授权方按 hash 定位并删除对应数据。
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"github_remote": "",
|
||||
"gitea_remote": "",
|
||||
"_note": "脱敏包接收远端,由授权方填写(GitHub / Gitea 仓库地址)。占位为空时 share_push.py 不推送。亦可用环境变量 ZIPING_SHARE_GITHUB_REMOTE / ZIPING_SHARE_GITEA_REMOTE 覆盖。"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""软授权锁:母版放行、副本扣次、用尽/过期/篡改/总开关。"""
|
||||
"""软授权锁:母版放行、副本扣次、用尽/过期/篡改/总开关、公众版首启签发、计励幂等。"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -71,3 +72,92 @@ class TestMasterSwitch:
|
||||
gate.consume(root) # 不扣次
|
||||
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 0
|
||||
|
||||
|
||||
def make_public_marker(root):
|
||||
(root / "data").mkdir(exist_ok=True)
|
||||
(root / "data" / ".public_edition.json").write_text(json.dumps(
|
||||
{"edition": "public", "pack_version": "0.1.0",
|
||||
"pool_copy_id": "LY-20261009-PUB0"}, ensure_ascii=False), "utf-8")
|
||||
|
||||
|
||||
class TestPublicEdition:
|
||||
def test_first_check_issues_personal_license(self, root):
|
||||
make_public_marker(root)
|
||||
assert gate.check(root) is None
|
||||
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert re.fullmatch(r"LY-\d{8}-[0-9A-F]{4}", st["copy_id"])
|
||||
assert st["licensee"] == "public-personal"
|
||||
assert st["max_uses"] == 20
|
||||
assert st["remaining_uses"] == 20
|
||||
assert st["expires_at"] is None
|
||||
assert st["granted_archives"] == []
|
||||
|
||||
def test_second_check_keeps_same_copy_id(self, root):
|
||||
make_public_marker(root)
|
||||
gate.check(root)
|
||||
first = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
gate.check(root)
|
||||
second = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert first["copy_id"] == second["copy_id"]
|
||||
assert first["sig"] == second["sig"]
|
||||
|
||||
def test_issued_copy_consumes_then_denies(self, root):
|
||||
make_public_marker(root)
|
||||
assert gate.check(root) is None
|
||||
for _ in range(20):
|
||||
gate.consume(root)
|
||||
assert "次数已用尽" in gate.check(root)
|
||||
|
||||
def test_master_without_marker_stays_unlocked(self, root):
|
||||
assert gate.check(root) is None # 母版:无状态文件也无标记
|
||||
assert not (root / "data" / ".license.json").exists()
|
||||
|
||||
|
||||
class TestGrantUses:
|
||||
def test_grant_adds_uses_and_is_idempotent(self, root):
|
||||
make_state(root, max_uses=5)
|
||||
assert gate.grant_uses(root, 10, "hashA") is True
|
||||
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 15
|
||||
# 同一盘只计一次:第二次返回 False 且不加次
|
||||
assert gate.grant_uses(root, 10, "hashA") is False
|
||||
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 15
|
||||
assert st["granted_archives"] == ["hashA"]
|
||||
# 另一盘可再计
|
||||
assert gate.grant_uses(root, 10, "hashB") is True
|
||||
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 25
|
||||
assert st["granted_archives"] == ["hashA", "hashB"]
|
||||
|
||||
def test_grant_on_master_returns_true_without_state(self, root):
|
||||
assert gate.grant_uses(root, 10, "hashA") is True
|
||||
assert not (root / "data" / ".license.json").exists()
|
||||
|
||||
def test_grant_on_tampered_state_refused(self, root):
|
||||
make_state(root, max_uses=5)
|
||||
p = root / "data" / ".license.json"
|
||||
st = json.loads(p.read_text("utf-8"))
|
||||
st["remaining_uses"] = 999 # 手改次数但不重算签名
|
||||
p.write_text(json.dumps(st, ensure_ascii=False), "utf-8")
|
||||
assert gate.grant_uses(root, 10, "hashA") is False
|
||||
st = json.loads(p.read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 999 # 未被计励写回
|
||||
|
||||
def test_state_written_by_old_schema_still_verifiable(self, root):
|
||||
# 旧格式状态文件(无 granted_archives 字段)签名校验不受影响
|
||||
st = {
|
||||
"copy_id": "LY-20261007-A3F9", "licensee": "张三",
|
||||
"issued_at": "2026-10-07", "expires_at": None,
|
||||
"max_uses": 2, "remaining_uses": 2,
|
||||
}
|
||||
st["sig"] = gate._sig(st)
|
||||
(root / "data").mkdir()
|
||||
(root / "data" / ".license.json").write_text(
|
||||
json.dumps(st, ensure_ascii=False), "utf-8")
|
||||
assert gate.check(root) is None
|
||||
assert gate.grant_uses(root, 10, "hashA") is True
|
||||
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 12
|
||||
assert gate.check(root) is None
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""自更新:版本比较、24h 缓存、manifest 校验拦截、apply 同步与保留区、退出码。
|
||||
|
||||
远端用本地临时目录伪造:VERSION / zipball 均以 file:// 地址经
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS 注入,不碰真实网络。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
import self_update as su
|
||||
|
||||
|
||||
# ---- 造本地副本与伪造远端 ----
|
||||
|
||||
def make_local(tmp_path, version="3.5.0"):
|
||||
root = tmp_path / "local"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
(root / "data").mkdir()
|
||||
(root / "VERSION").write_text(version + "\n", encoding="utf-8")
|
||||
(root / "scripts" / "cast_chart.py").write_text("# old\n", encoding="utf-8")
|
||||
(root / "data" / "cities.json").write_text("{}", encoding="utf-8")
|
||||
(root / "stale_legacy.py").write_text("# to be removed\n", encoding="utf-8")
|
||||
(root / "legacy").mkdir()
|
||||
(root / "legacy" / "gone.py").write_text("# to be removed\n", encoding="utf-8")
|
||||
# 保留区:更新后必须原样存活
|
||||
(root / "data" / ".license.json").write_text('{"copy_id": "x"}', encoding="utf-8")
|
||||
(root / "memory" / "archives").mkdir(parents=True)
|
||||
(root / "memory" / "archives" / "keep.md").write_text("keep", encoding="utf-8")
|
||||
(root / "share" / "pending").mkdir(parents=True)
|
||||
(root / "share" / "pending" / "keep.md").write_text("keep", encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def make_remote(tmp_path, version="3.6.0", files=None, tamper=None):
|
||||
"""造远端 zipball(带一层顶层目录,模拟 GitHub codeload 结构)+ 远端 VERSION。
|
||||
|
||||
tamper=<相对路径>:先按原内容算清单哈希,再篡改该文件,用于触发校验拦截。
|
||||
"""
|
||||
src = tmp_path / "zipcontent" / "ZiPingEightChar-main"
|
||||
src.mkdir(parents=True)
|
||||
all_files = {"VERSION": version + "\n", **(files or {})}
|
||||
for rel, content in all_files.items():
|
||||
p = src / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content, encoding="utf-8")
|
||||
hashes = {
|
||||
rel: hashlib.sha256((src / rel).read_bytes()).hexdigest()
|
||||
for rel in all_files
|
||||
}
|
||||
(src / "manifest.json").write_text(
|
||||
json.dumps({"version": version, "files": hashes}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if tamper:
|
||||
(src / tamper).write_text("# tampered\n", encoding="utf-8")
|
||||
zip_path = tmp_path / "remote.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for f in sorted(src.parent.rglob("*")):
|
||||
zf.write(f, f.relative_to(src.parent))
|
||||
version_file = tmp_path / "REMOTE_VERSION"
|
||||
version_file.write_text(version + "\n", encoding="utf-8")
|
||||
return version_file, zip_path
|
||||
|
||||
|
||||
def point_at_remote(monkeypatch, version_file, zip_path=None):
|
||||
monkeypatch.setenv("ZIPEC_VERSION_URLS", version_file.as_uri())
|
||||
if zip_path is not None:
|
||||
monkeypatch.setenv("ZIPEC_ZIPBALL_URLS", zip_path.as_uri())
|
||||
|
||||
|
||||
REMOTE_FILES = {
|
||||
"scripts/cast_chart.py": "# new\n",
|
||||
"scripts/new_feature.py": "# new file\n",
|
||||
"data/cities.json": '{"new": 1}',
|
||||
}
|
||||
|
||||
|
||||
class TestVersionCompare:
|
||||
def test_parse(self):
|
||||
assert su.parse_version("3.5.0") == (3, 5, 0)
|
||||
assert su.parse_version(" 3.10.1\n") == (3, 10, 1)
|
||||
with pytest.raises(ValueError):
|
||||
su.parse_version("3.x.0")
|
||||
|
||||
def test_newer_remote_marks_update(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
vf, _ = make_remote(tmp_path)
|
||||
point_at_remote(monkeypatch, vf)
|
||||
assert su.cmd_check(root) == 0
|
||||
cache = json.loads((root / "data" / ".update_check.json").read_text())
|
||||
assert cache["remote_version"] == "3.6.0"
|
||||
assert cache["update_available"] is True
|
||||
|
||||
def test_same_version_no_update(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
vf, _ = make_remote(tmp_path, version="3.5.0")
|
||||
point_at_remote(monkeypatch, vf)
|
||||
assert su.cmd_check(root) == 0
|
||||
cache = json.loads((root / "data" / ".update_check.json").read_text())
|
||||
assert cache["update_available"] is False
|
||||
|
||||
|
||||
class TestCacheFreshness:
|
||||
def test_fresh_cache_skips_network(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
calls = []
|
||||
|
||||
def fake_fetch(url, timeout=3):
|
||||
calls.append(url)
|
||||
return "3.6.0"
|
||||
|
||||
monkeypatch.setattr(su, "_fetch_text", fake_fetch)
|
||||
assert su.cmd_check(root) == 0
|
||||
assert su.cmd_check(root) == 0
|
||||
assert len(calls) == 1 # 24h 内第二次 --check 直接读缓存
|
||||
assert "已检查过" in capsys.readouterr().out
|
||||
|
||||
def test_stale_cache_refetches(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
su, "_fetch_text",
|
||||
lambda url, timeout=3: calls.append(url) or "3.6.0",
|
||||
)
|
||||
su.cmd_check(root)
|
||||
p = root / "data" / ".update_check.json"
|
||||
cache = json.loads(p.read_text())
|
||||
cache["checked_at"] = time.time() - 25 * 3600 # 人为过期
|
||||
p.write_text(json.dumps(cache), encoding="utf-8")
|
||||
assert su.cmd_check(root) == 0
|
||||
assert len(calls) == 2
|
||||
|
||||
def test_notify_reads_cache_only(self, tmp_path, capsys):
|
||||
root = tmp_path / "local"
|
||||
root.mkdir()
|
||||
assert su.notify_message(root) is None # 无缓存 → 静默
|
||||
assert su.cmd_notify(root) == 0
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
def test_notify_prints_hint(self, tmp_path, capsys):
|
||||
root = make_local(tmp_path)
|
||||
(root / "data" / ".update_check.json").write_text(json.dumps({
|
||||
"checked_at": time.time(), "local_version": "3.5.0",
|
||||
"remote_version": "3.6.0", "update_available": True,
|
||||
}), encoding="utf-8")
|
||||
msg = su.notify_message(root)
|
||||
assert "3.6.0" in msg and "--apply" in msg
|
||||
assert su.cmd_notify(root) == 0
|
||||
assert "3.6.0" in capsys.readouterr().err
|
||||
|
||||
def test_refresh_cache_failure_silent(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
def boom(url, timeout=3):
|
||||
raise OSError("network down")
|
||||
monkeypatch.setattr(su, "_fetch_text", boom)
|
||||
su.refresh_cache(root) # 不抛
|
||||
assert not (root / "data" / ".update_check.json").exists()
|
||||
|
||||
|
||||
class TestApply:
|
||||
def test_success_syncs_and_preserves(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
assert su.cmd_apply(root) == 0
|
||||
assert "更新完成" in capsys.readouterr().out
|
||||
# 清单内文件被覆盖/新增
|
||||
assert (root / "VERSION").read_text().strip() == "3.6.0"
|
||||
assert (root / "scripts" / "cast_chart.py").read_text() == "# new\n"
|
||||
assert (root / "scripts" / "new_feature.py").exists()
|
||||
assert (root / "manifest.json").exists()
|
||||
# 清单不含的本地多余文件被清理
|
||||
assert not (root / "stale_legacy.py").exists()
|
||||
assert not (root / "legacy").exists() # 清空后的空目录一并移除
|
||||
# 保留区原样存活
|
||||
assert (root / "data" / ".license.json").read_text() == '{"copy_id": "x"}'
|
||||
assert (root / "data" / ".update_check.json").exists()
|
||||
assert (root / "memory" / "archives" / "keep.md").read_text() == "keep"
|
||||
assert (root / "share" / "pending" / "keep.md").read_text() == "keep"
|
||||
|
||||
def test_tampered_file_aborts_untouched(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
tamper="scripts/cast_chart.py")
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "不符" in capsys.readouterr().err
|
||||
# 本地原样不动
|
||||
assert (root / "VERSION").read_text().strip() == "3.5.0"
|
||||
assert (root / "scripts" / "cast_chart.py").read_text() == "# old\n"
|
||||
assert (root / "stale_legacy.py").exists()
|
||||
assert not (root / "scripts" / "new_feature.py").exists()
|
||||
|
||||
def test_already_latest_exit_2(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, version="3.5.0")
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
assert su.cmd_apply(root) == 2
|
||||
assert "无需更新" in capsys.readouterr().out
|
||||
|
||||
def test_missing_local_version_exit_2(self, tmp_path):
|
||||
root = tmp_path / "local"
|
||||
root.mkdir()
|
||||
assert su.cmd_apply(root) == 2
|
||||
|
||||
def test_remote_unreachable_exit_3(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
monkeypatch.setenv("ZIPEC_VERSION_URLS", "file:///nonexistent/VERSION")
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert su.cmd_check(root) == 3 # 无缓存且远端不可达
|
||||
assert (root / "VERSION").read_text().strip() == "3.5.0"
|
||||
|
||||
|
||||
class TestCastChartIntegration:
|
||||
def test_notice_on_stderr_stdout_clean(self):
|
||||
"""排盘成功后 stderr 出一行提示;stdout 命盘契约不受影响。"""
|
||||
root = su.repo_root()
|
||||
cache_path = root / "data" / ".update_check.json"
|
||||
old_bytes = cache_path.read_bytes() if cache_path.exists() else None
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_text(json.dumps({
|
||||
"checked_at": time.time(), "local_version": "3.5.0",
|
||||
"remote_version": "9.9.9", "update_available": True,
|
||||
}), encoding="utf-8")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(root / "scripts" / "cast_chart.py"),
|
||||
"--name", "测试", "--gender", "男",
|
||||
"--birth", "1990-05-14 08:30", "--place", "四川省成都市"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert r.stdout.startswith("# 命盘:测试")
|
||||
assert "技能有新版本 9.9.9" in r.stderr
|
||||
assert "--apply" in r.stderr
|
||||
finally:
|
||||
if old_bytes is None:
|
||||
cache_path.unlink(missing_ok=True)
|
||||
else:
|
||||
cache_path.write_bytes(old_bytes)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""share/ 脱敏共建:导出脱敏、完整度门槛、推送计励幂等(本地 bare git 仓库当远端,无网络)。"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = SKILL_ROOT / "scripts"
|
||||
EXPORT = SCRIPTS_DIR / "share_export.py"
|
||||
PUSH = SCRIPTS_DIR / "share_push.py"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
import _license_gate as gate
|
||||
import _memory_store as store
|
||||
|
||||
PILLARS = ["庚午", "辛巳", "己卯", "戊辰"]
|
||||
BIRTH = "1990-05-14 08:30"
|
||||
PLACE = "四川省成都市"
|
||||
|
||||
git = pytest.mark.skipif(shutil.which("git") is None, reason="无 git")
|
||||
|
||||
|
||||
def run(script, *args):
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(SCRIPTS_DIR) + os.pathsep + env.get("PYTHONPATH", "")
|
||||
return subprocess.run([sys.executable, str(script), *args],
|
||||
capture_output=True, text=True, timeout=120, env=env)
|
||||
|
||||
|
||||
def make_archive(mem_root: Path, feedback: str | None = None) -> Path:
|
||||
"""造一份手工档案:四柱/大运/关注方向齐全,反馈小节可定制。"""
|
||||
meta = {"name": "张三", "gender": "男", "birth_clock": BIRTH,
|
||||
"birthplace": PLACE, "true_solar_time": "1990-05-14 08:23",
|
||||
"pillars": PILLARS, "day_master": "己",
|
||||
"created_at": "2026-10-01 10:00", "updated_at": "2026-10-08 15:00"}
|
||||
body = (
|
||||
"# 档案:张三(男)\n\n"
|
||||
"## 盘面\n\n"
|
||||
"# 命盘:张三\n\n"
|
||||
"## 大运\n\n"
|
||||
"- 排法:顺行;起运:7 岁 8 个月 1 天(7.67 岁)\n"
|
||||
"- 第 1 步:壬午(7.67 岁起,1998 年)\n"
|
||||
"- 第 2 步:癸未(17.67 岁起,2008 年)\n"
|
||||
"- 第 3 步:甲申(27.67 岁起,2018 年)\n\n"
|
||||
"## 小运\n\n(无)\n\n"
|
||||
"## 主要关注方向\n\n- 婚姻与事业走向\n\n"
|
||||
"## 归纳\n\n- 性格:未断\n- 身体:未断\n- 事业:未断\n- 感情:未断\n- 婚姻:未断\n\n"
|
||||
"## 流年大事\n\n(未记录)\n\n"
|
||||
"## 反馈与澄清\n\n"
|
||||
+ (feedback if feedback is not None else
|
||||
"- 【已核实】2018 年升职,跳槽到外企\n- 【未核实】2020 年感情波动\n")
|
||||
)
|
||||
arch_dir = mem_root / "archives"
|
||||
arch_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = arch_dir / store.archive_name(
|
||||
PILLARS, "男", store.key_hash(BIRTH, PLACE, "男"))
|
||||
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def share_root(tmp_path):
|
||||
return tmp_path / "share"
|
||||
|
||||
|
||||
class TestExport:
|
||||
def test_package_is_sanitized(self, tmp_path, share_root):
|
||||
arch = make_archive(tmp_path / "memory")
|
||||
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
|
||||
assert r.returncode == 0, r.stderr
|
||||
out = Path(r.stdout.strip())
|
||||
assert out.parent == share_root / "pending"
|
||||
text = out.read_text("utf-8")
|
||||
pkg = json.loads(text)
|
||||
|
||||
# 脱敏:不含姓名、出生分钟/钟表时间、省以下地域
|
||||
assert "name" not in pkg
|
||||
assert "张三" not in text
|
||||
assert "08:30" not in text and "08:23" not in text
|
||||
assert "成都市" not in text
|
||||
assert "birth_clock" not in pkg and "birthplace" not in pkg
|
||||
|
||||
# 结构字段
|
||||
assert pkg["schema"] == "share/v1"
|
||||
assert pkg["gender"] == "男"
|
||||
assert pkg["pillars"] == PILLARS
|
||||
assert pkg["birth_date"] == "1990-05-14"
|
||||
assert pkg["birth_shichen"] == "辰"
|
||||
assert pkg["region_province"] == "四川省"
|
||||
assert [l["pillar"] for l in pkg["luck_pillars"]] == ["壬午", "癸未", "甲申"]
|
||||
assert pkg["luck_pillars"][0]["start_year"] == 1998
|
||||
assert pkg["focus_categories"] == ["婚姻", "事业"]
|
||||
assert {f["year"] for f in pkg["feedback"]} == {2018, 2020}
|
||||
assert {f["status"] for f in pkg["feedback"]} == {"已核实", "未核实"}
|
||||
assert all(f["category"] for f in pkg["feedback"])
|
||||
# 盘 hash = sha1(四柱+出生地) 前 8,文件名即 hash
|
||||
assert out.stem == pkg["chart_hash"]
|
||||
assert len(pkg["chart_hash"]) == 8
|
||||
|
||||
def test_lookup_by_birth_place_gender(self, tmp_path, share_root):
|
||||
make_archive(tmp_path / "memory")
|
||||
r = run(EXPORT, "--birth", BIRTH, "--place", PLACE, "--gender", "男",
|
||||
"--memory-root", str(tmp_path / "memory"),
|
||||
"--share-root", str(share_root))
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert (share_root / "pending").glob("*.json")
|
||||
|
||||
def test_completeness_gate_refuses_without_feedback(self, tmp_path, share_root):
|
||||
arch = make_archive(tmp_path / "memory", feedback="(无)\n")
|
||||
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
|
||||
assert r.returncode == 2
|
||||
assert "反馈事件" in r.stderr
|
||||
assert not list((share_root / "pending").glob("*.json"))
|
||||
|
||||
def test_archive_not_found(self, tmp_path, share_root):
|
||||
r = run(EXPORT, "--archive", str(tmp_path / "nope.md"),
|
||||
"--share-root", str(share_root))
|
||||
assert r.returncode == 2
|
||||
|
||||
|
||||
@git
|
||||
class TestPush:
|
||||
@pytest.fixture
|
||||
def skill(self, tmp_path):
|
||||
"""假技能根:share/ 骨架 + 两个 bare 远端 + 授权状态(5 次)。"""
|
||||
root = tmp_path / "skill"
|
||||
(root / "share" / "pending").mkdir(parents=True)
|
||||
(root / "share" / "pushed").mkdir(parents=True)
|
||||
remotes = {}
|
||||
for name in ("github", "gitea"):
|
||||
bare = tmp_path / f"{name}.git"
|
||||
subprocess.run(["git", "init", "--bare", str(bare)],
|
||||
capture_output=True, check=True)
|
||||
remotes[f"{name}_remote"] = str(bare)
|
||||
(root / "share" / "config.json").write_text(
|
||||
json.dumps(remotes, ensure_ascii=False), "utf-8")
|
||||
gate.write_state(root, copy_id="LY-20261009-TEST",
|
||||
licensee="测试", max_uses=5)
|
||||
return root
|
||||
|
||||
def _export_into(self, skill_root, tmp_path):
|
||||
arch = make_archive(tmp_path / "memory")
|
||||
r = run(EXPORT, "--archive", str(arch),
|
||||
"--share-root", str(skill_root / "share"))
|
||||
assert r.returncode == 0, r.stderr
|
||||
return Path(r.stdout.strip())
|
||||
|
||||
def test_push_grants_uses_and_moves_package(self, skill, tmp_path):
|
||||
pkg = self._export_into(skill, tmp_path)
|
||||
r = run(PUSH, "--root", str(skill))
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert not pkg.exists()
|
||||
assert (skill / "share" / "pushed" / pkg.name).is_file()
|
||||
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 15 # 5 + 10
|
||||
assert st["granted_archives"] == [pkg.stem]
|
||||
# 远端确实收到提交
|
||||
log = subprocess.run(
|
||||
["git", "--git-dir", str(tmp_path / "github.git"), "log", "--oneline"],
|
||||
capture_output=True, text=True)
|
||||
assert f"share: {pkg.stem}" in log.stdout
|
||||
|
||||
def test_push_same_chart_twice_grants_once(self, skill, tmp_path):
|
||||
pkg = self._export_into(skill, tmp_path)
|
||||
assert run(PUSH, "--root", str(skill)).returncode == 0
|
||||
# 同一盘再次导出推送:仍成功移走,但不重复计励
|
||||
pkg2 = self._export_into(skill, tmp_path)
|
||||
assert pkg2.name == pkg.name
|
||||
r = run(PUSH, "--root", str(skill))
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert "不重复" in r.stdout
|
||||
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 15
|
||||
assert st["granted_archives"] == [pkg.stem]
|
||||
|
||||
def test_push_failure_keeps_pending(self, skill):
|
||||
bad = {"github_remote": str(skill / "no-such-repo.git"),
|
||||
"gitea_remote": ""}
|
||||
(skill / "share" / "config.json").write_text(
|
||||
json.dumps(bad, ensure_ascii=False), "utf-8")
|
||||
pkg = skill / "share" / "pending" / "deadbeef.json"
|
||||
pkg.write_text(json.dumps({"schema": "share/v1",
|
||||
"chart_hash": "deadbeef"}), "utf-8")
|
||||
r = run(PUSH, "--root", str(skill))
|
||||
assert r.returncode == 3
|
||||
assert pkg.is_file() # 失败保留 pending
|
||||
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 5 # 未计励
|
||||
|
||||
def test_no_remote_configured(self, tmp_path):
|
||||
root = tmp_path / "skill"
|
||||
(root / "share" / "pending").mkdir(parents=True)
|
||||
(root / "share" / "config.json").write_text(
|
||||
json.dumps({"github_remote": "", "gitea_remote": ""}), "utf-8")
|
||||
(root / "share" / "pending" / "abc12345.json").write_text("{}", "utf-8")
|
||||
r = run(PUSH, "--root", str(root))
|
||||
assert r.returncode == 3
|
||||
assert (root / "share" / "pending" / "abc12345.json").is_file()
|
||||
|
||||
def test_env_overrides_config(self, skill, tmp_path):
|
||||
# config 远端写死为坏地址,环境变量指向好远端
|
||||
(skill / "share" / "config.json").write_text(json.dumps(
|
||||
{"github_remote": str(skill / "bad.git"), "gitea_remote": ""}), "utf-8")
|
||||
good = tmp_path / "env.git"
|
||||
subprocess.run(["git", "init", "--bare", str(good)],
|
||||
capture_output=True, check=True)
|
||||
pkg = skill / "share" / "pending" / "c0ffee11.json"
|
||||
pkg.write_text(json.dumps({"schema": "share/v1",
|
||||
"chart_hash": "c0ffee11"}), "utf-8")
|
||||
env = dict(os.environ)
|
||||
env["PYTHONPATH"] = str(SCRIPTS_DIR) + os.pathsep + env.get("PYTHONPATH", "")
|
||||
env["ZIPING_SHARE_GITHUB_REMOTE"] = str(good)
|
||||
r = subprocess.run([sys.executable, str(PUSH), "--root", str(skill)],
|
||||
capture_output=True, text=True, timeout=120, env=env)
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert (skill / "share" / "pushed" / "c0ffee11.json").is_file()
|
||||
Reference in New Issue
Block a user