授权模型切换为个人副本签发;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:
2026-10-09 21:24:25 +08:00
parent 5889d7a150
commit 26a83df3c4
20 changed files with 2760 additions and 22 deletions
+53 -2
View File
@@ -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
+12
View File
@@ -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: 排盘内部错误。请把以下信息原样报告给用户,"
+386
View File
@@ -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())
+265
View File
@@ -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())
+129
View File
@@ -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())