Files
ZiPingEightChar/scripts/share_export.py
T
moxiaonian 433c9b4a41 v3.6.0 同步发版
- 交互契约改对话式询问(禁点选工具、一次1~2问、先铺垫再问),规则库 3.6
- 盘面图默认生成+chart_shot 截图;品牌区、大运/流年小卡信息补全、交流群二维码卡
- day_boundary 子正换日;memory_save --update-chart 换盘留痕;share_export 事件简介+城市级地域
- share_push 失败诊断+代理引导;_runtime_guard;修 STAR_ORDER 三奇贵人 KeyError
- README 规则计数 541→542;manifest 重建(1275 文件);测试 142 项全绿
2026-10-10 13:13:41 +08:00

374 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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):不含姓名;出生信息只保留年月日与时辰(不留分钟
与钟表时间);地域保留到城市级(省+地级市,区县及以下删除);事件带类目与简介
(note),简介中城市级地名保留(规则蒸馏要用方位信息),姓名、详细住址
(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除;归纳、盘面细节等
正文自由文本不入包。盘 hash = sha1(四柱+出生地) 前 8 位,用于去重与撤回。
完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态);
不满足拒绝导出,退出码 2 并说明缺什么。事件简介缺失(档案未写事件内容)
不挡导出,JSON 中如实反映(events 为空或 note 为 null)。
换盘残留提示:导出时若 pending/ 中已有同出生日、同性别但盘 hash 不同的
旧包(多为换盘/信息更正后的孤儿包),stderr 打 WARN 列出路径,提醒人工清理
(只提示,不自动删)。
退出码: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 _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
import _memory_store as store
SCHEMA = "share/v1"
PLACEHOLDERS = {"(无)", "(未记录)", "无", "未记录"}
# 事件/关注方向类目化:首个命中的类目生效,都不命中归「其他」
CATEGORIES: list[tuple[str, tuple[str, ...]]] = [
("婚姻", ("婚姻", "结婚", "离婚", "配偶", "夫妻", "婚变", "再婚")),
("感情", ("感情", "恋爱", "桃花", "对象", "分手", "复合")),
("事业", ("事业", "工作", "升职", "跳槽", "调动", "失业", "创业", "辞职", "职务", "官非")),
("财运", ("财运", "破财", "投资", "收入", "赚钱", "欠债", "债务", "置业", "买房")),
("健康", ("健康", "生病", "手术", "伤病", "身体", "住院", "疾病", "意外")),
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书", "大学")),
("六亲", ("父亲", "母亲", "父母", "子女", "孩子", "兄弟", "姐妹", "六亲", "亲人", "丧")),
("流年运势", ("流年", "运势", "大运", "运程")),
]
_PROVINCES = (
"北京市", "天津市", "上海市", "重庆市",
"河北省", "山西省", "辽宁省", "吉林省", "黑龙江省", "江苏省", "浙江省",
"安徽省", "福建省", "江西省", "山东省", "河南省", "湖北省", "湖南省",
"广东省", "海南省", "四川省", "贵州省", "云南省", "陕西省", "甘肃省",
"青海省", "台湾省",
"内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区",
"新疆维吾尔自治区", "香港特别行政区", "澳门特别行政区",
)
_PROVINCE_SHORT = {
"北京": "北京市", "天津": "天津市", "上海": "上海市", "重庆": "重庆市",
"内蒙古": "内蒙古自治区", "广西": "广西壮族自治区", "西藏": "西藏自治区",
"宁夏": "宁夏回族自治区", "新疆": "新疆维吾尔自治区",
"香港": "香港特别行政区", "澳门": "澳门特别行政区",
}
_STATUS_TAGS = {"【已核实】": "已核实", "【已证伪】": "已证伪", "【未核实】": "未核实"}
_GZ_RE = re.compile(r"[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]")
_YEAR_RE = re.compile(r"(?:19|20)\d{2}")
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 _city_of(birthplace: str) -> str | None:
"""地域脱敏:城市级(地级市/州/盟;直辖市即省级本身)。识别不出则舍弃。"""
province = _province_of(birthplace)
if province in ("北京市", "天津市", "上海市", "重庆市",
"香港特别行政区", "澳门特别行政区"):
return province
rest = birthplace
if province and rest.startswith(province):
rest = rest[len(province):]
m = re.match(r"^(.{2,10}?(?:市|州|盟))", rest)
return m.group(1) if m else None
def _sanitize_note(text: str, name: str) -> str:
"""事件简介脱敏:姓名、身份证/电话、详细住址(小区/门牌/街道号)一律删除;
城市级地名保留(规则蒸馏要用方位信息)。"""
t = text
if name and name not in PLACEHOLDERS:
t = t.replace(name, "命主")
t = re.sub(r"\d{17}[\dXx]", "(已删)", t) # 身份证
t = re.sub(r"1[3-9]\d{9}", "(已删)", t) # 手机号
t = re.sub(r"\d{3,4}-\d{7,8}", "(已删)", t) # 座机号
t = re.sub(r"[^\s,。;:、()]*(?:小区|公寓|花园|山庄|新村|苑|大院)"
r"[^\s,。;:、()]*", "(已删)", t) # 小区/楼盘名
t = re.sub(r"\d+\s*(?:号楼|栋|单元|室|号院|层|户)", "(已删)", t) # 楼栋门牌
t = re.sub(r"(?:街|路|巷|大道|道)\s*\d+\s*号(?:\s*\d+\s*室)?",
"(已删)", t) # 街道号
return t.strip()
def _strip_tags(text: str) -> str:
for tag in _STATUS_TAGS:
text = text.replace(tag, "")
return text.strip()
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 _status_of(t: str) -> str:
for tag, name in _STATUS_TAGS.items():
if tag in t:
return name
return "未核实"
def _parse_feedback(body: str, name: str) -> list[dict]:
"""反馈事件:年份、类目、核实状态 + 事件简介(note,脱敏后;无实质内容则为 None)。"""
events = []
for ln in _section_lines(body, "反馈与澄清"):
t = ln.strip().lstrip("-•· ").strip()
if not t or t in PLACEHOLDERS:
continue
m = _YEAR_RE.search(t)
note = _sanitize_note(_strip_tags(t), name)
events.append({"year": int(m.group(0)) if m else None,
"category": _categorize(t),
"status": _status_of(t),
"note": note or None})
return events
def _parse_events(body: str, name: str) -> list[dict]:
"""流年大事:年份(区间取首年)、干支、标签、核实状态 + 事件简介(脱敏后)。
行形如「- 2016(丙申·交运年,入丁酉运):【已核实】离乡,自四川乐山赴厦门读大学。」;
无括号标签或缺事件内容时对应字段为 None(如实反映,不挡导出)。
"""
events = []
for ln in _section_lines(body, "流年大事"):
t = ln.strip().lstrip("-•· ").strip()
if not t or t in PLACEHOLDERS:
continue
m = re.match(r"^((?:19|20)\d{2})(?:[–—\-]\d{2,4})?"
r"(?:(([^)]*)))?\s*[::]\s*(.+)$", t)
if m:
year = int(m.group(1))
label = m.group(2) or None
note = m.group(3)
else:
ym = _YEAR_RE.search(t)
year = int(ym.group(0)) if ym else None
label = None
note = t
ganzhi = None
if label:
gz = _GZ_RE.search(label)
ganzhi = gz.group(0) if gz else None
note = _sanitize_note(_strip_tags(note), name)
events.append({"year": year, "ganzhi": ganzhi, "label": label,
"note": note or None, "status": _status_of(t),
"category": _categorize(t)})
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, 缺失项列表)。
保证不含姓名、出生分钟/钟表时间、区县及以下地域;事件简介中城市级地名保留,
直接定位信息(详细住址、证件、电话)已删除。
"""
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", ""))
name = str(meta.get("name", ""))
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),
"events": _parse_events(body, name),
"feedback": _parse_feedback(body, name)}
# 出生信息脱敏:只保留年月日与时辰(时支),不留分钟与钟表时间
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
city = _city_of(birthplace)
if city:
pkg["region_city"] = city
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"
# 换盘孤儿包提示:pending 中同出生日、同性别但盘 hash 不同的旧包,
# 多为换盘/信息更正后的残留,提醒人工清理(只提示,不自动删)
for old in sorted(pending.glob("*.json")):
if old.name == out.name:
continue
try:
opkg = json.loads(old.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if (opkg.get("birth_date") == pkg.get("birth_date")
and opkg.get("gender") == pkg.get("gender")
and opkg.get("chart_hash") != pkg["chart_hash"]):
print(f"WARN: pending 中已有同出生日同性别的旧包(盘 hash 不同),"
f"疑似换盘残留:{old};确认后请手动删除,避免误推。",
file=sys.stderr)
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())