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 项全绿
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""运行环境守卫:本技能脚本需要 Python ≥ 3.10。
|
||||
|
||||
各入口脚本在 sys.path 引导后立即 import 本模块(import 即检查,无其他调用):
|
||||
解释器低于 3.10 时在 stderr 打印中文提示并以退出码 3 结束,
|
||||
避免用户看到库深处难以理解的报错(运行期 `X | None` 注解在 3.9 及以下
|
||||
求值即 TypeError,报错位置离真正原因很远)。
|
||||
|
||||
本模块自身只用 Python 3.6 兼容语法,保证在旧解释器上也能正常执行检查。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
MIN_PYTHON = (3, 10)
|
||||
|
||||
|
||||
def version_error(version_info=None) -> str | None:
|
||||
"""版本达标返回 None;不足时返回中文报错文案。可注入 version_info 便于测试。"""
|
||||
vi = sys.version_info if version_info is None else version_info
|
||||
if tuple(vi[:2]) >= MIN_PYTHON:
|
||||
return None
|
||||
return (f"运行环境不可用:本技能脚本需要 Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} "
|
||||
f"或更高版本,当前解释器为 Python {vi[0]}.{vi[1]}.{vi[2]}"
|
||||
f"({sys.executable})。请改用更新版本的 Python 运行"
|
||||
"(macOS 系统自带 python3 为 3.9,不可用;可用 python3.11/3.12/3.13 "
|
||||
"或项目自带虚拟环境)。")
|
||||
|
||||
|
||||
_msg = version_error()
|
||||
if _msg is not None:
|
||||
print(f"ERROR: {_msg}", file=sys.stderr)
|
||||
raise SystemExit(3)
|
||||
@@ -3,9 +3,10 @@ from datetime import datetime, timedelta
|
||||
ZI_HOUR_START = 23 # 真太阳时 23:00 换日
|
||||
|
||||
|
||||
def resolve_calendar_day(tst: datetime) -> tuple[datetime, dict]:
|
||||
def resolve_calendar_day(tst: datetime, rule: str = "子初") -> tuple[datetime, dict]:
|
||||
"""
|
||||
子初换日:真太阳时 23:00–24:00 归入次日。
|
||||
日界归算:rule="子初"(默认)真太阳时 23:00–24:00 归入次日;
|
||||
rule="子正" 以 0:00 为日界,晚子时不换日。
|
||||
|
||||
返回
|
||||
(日历日 00:00 的 datetime, flag)
|
||||
@@ -13,11 +14,12 @@ def resolve_calendar_day(tst: datetime) -> tuple[datetime, dict]:
|
||||
is_late_zi = tst.hour == ZI_HOUR_START
|
||||
|
||||
day = tst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if is_late_zi:
|
||||
if is_late_zi and rule != "子正":
|
||||
day += timedelta(days=1)
|
||||
|
||||
return day, {
|
||||
"rule": "子初换日@真太阳时23:00",
|
||||
"rule": ("子正换日@真太阳时00:00" if rule == "子正"
|
||||
else "子初换日@真太阳时23:00"),
|
||||
"is_late_zi": is_late_zi,
|
||||
"tst_time": tst.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
+18
-3
@@ -18,6 +18,8 @@ from pathlib import Path
|
||||
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
|
||||
# vendor 目录不存在时回退到环境中已安装的包(开发模式)
|
||||
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
|
||||
@@ -39,6 +41,9 @@ def main() -> int:
|
||||
help="出生地,精确到地级市一级,如「四川省成都市」")
|
||||
ap.add_argument("--html", nargs="?", const="", default=None,
|
||||
help="可选:同时生成 HTML 盘面图(给路径参数,或省略则写 ./命盘_<姓名>.html)")
|
||||
ap.add_argument("--shot", action="store_true",
|
||||
help="可选:生成 HTML 盘面图并截图 PNG,路径打在 stdout 末尾;"
|
||||
"截图工具不可用时只出 HTML")
|
||||
a = ap.parse_args()
|
||||
|
||||
# 软授权锁:定制副本(含 data/.license.json)按授权次数/期限放行;母版无状态文件直接放行
|
||||
@@ -132,14 +137,24 @@ def main() -> int:
|
||||
print(render(chart))
|
||||
_gate.consume(Path(__file__).resolve().parents[1])
|
||||
|
||||
# 可选:同步生成 HTML 盘面图(渲染失败不影响 stdout 契约)
|
||||
if a.html is not None:
|
||||
# 可选:同步生成 HTML 盘面图(渲染失败不影响 stdout 契约);--shot 再截图 PNG
|
||||
if a.html is not None or a.shot:
|
||||
try:
|
||||
from render.html import build_html
|
||||
html_path = (Path(a.html) if a.html
|
||||
else Path(f"命盘_{a.name}.html"))
|
||||
html_path.write_text(build_html(chart), encoding="utf-8")
|
||||
print(f"HTML 盘面:{html_path.resolve()}", file=sys.stderr)
|
||||
if a.shot:
|
||||
import chart_shot
|
||||
png = chart_shot.shoot(html_path)
|
||||
print(f"\nHTML 盘面:{html_path.resolve()}")
|
||||
if png:
|
||||
print(f"盘面截图:{png}")
|
||||
else:
|
||||
print("盘面截图:不可用(未检测到 playwright 或 Chrome,"
|
||||
"可直接打开上面的 HTML 查看盘面)")
|
||||
else:
|
||||
print(f"HTML 盘面:{html_path.resolve()}", file=sys.stderr)
|
||||
except Exception:
|
||||
print("WARN: HTML 盘面生成失败(命盘不受影响)", file=sys.stderr)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""干支派生纯查表:五行、十神、十二长生(星运)、旬空、六十甲子纳音。
|
||||
|
||||
本命四柱的同名信息由 chart/tyme_adapter.py 经 tyme4py 求得;本模块为同口径的
|
||||
纯 Python 实现,供岁运干支(大运/流年/流月)与渲染层复用,保证 HTML 盘面
|
||||
与排盘正文口径一致。纯查表,零命理判断(P1)。
|
||||
"""
|
||||
|
||||
GAN = "甲乙丙丁戊己庚辛壬癸"
|
||||
ZHI = "子丑寅卯辰巳午未申酉戌亥"
|
||||
|
||||
WX_GAN = {"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土",
|
||||
"己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"}
|
||||
WX_ZHI = {"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土",
|
||||
"巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金",
|
||||
"戌": "土", "亥": "水"}
|
||||
|
||||
_YY = {"甲": 1, "丙": 1, "戊": 1, "庚": 1, "壬": 1,
|
||||
"乙": -1, "丁": -1, "己": -1, "辛": -1, "癸": -1}
|
||||
_SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
||||
_KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
|
||||
|
||||
|
||||
def ten_god(me: str, other: str) -> str:
|
||||
"""other 天干相对日干 me 的十神(命名与 tyme4py get_ten_star 一致)。"""
|
||||
wx_m, wx_o = WX_GAN[me], WX_GAN[other]
|
||||
same = _YY[me] == _YY[other]
|
||||
if wx_m == wx_o:
|
||||
return "比肩" if same else "劫财"
|
||||
if _SHENG[wx_m] == wx_o:
|
||||
return "食神" if same else "伤官"
|
||||
if _KE[wx_m] == wx_o:
|
||||
return "偏财" if same else "正财"
|
||||
if _KE[wx_o] == wx_m:
|
||||
return "七杀" if same else "正官"
|
||||
return "偏印" if same else "正印"
|
||||
|
||||
|
||||
_TERRAIN_ORDER = ["长生", "沐浴", "冠带", "临官", "帝旺", "衰",
|
||||
"病", "死", "墓", "绝", "胎", "养"]
|
||||
_TERRAIN_START = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳",
|
||||
"壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"}
|
||||
|
||||
|
||||
def terrain(me: str, zhi: str) -> str:
|
||||
"""日干 me 对地支 zhi 的十二长生(阳顺阴逆,命名与 tyme4py get_terrain 一致)。"""
|
||||
start = ZHI.index(_TERRAIN_START[me])
|
||||
zi = ZHI.index(zhi)
|
||||
off = (zi - start) % 12 if _YY[me] == 1 else (start - zi) % 12
|
||||
return _TERRAIN_ORDER[off]
|
||||
|
||||
|
||||
def gz_void(gz: str) -> str:
|
||||
"""一柱干支所在旬的空亡两支(与 tyme4py get_extra_earth_branches 同果)。"""
|
||||
g, z = GAN.index(gz[0]), ZHI.index(gz[1])
|
||||
return ZHI[(z - g + 10) % 12] + ZHI[(z - g + 11) % 12]
|
||||
|
||||
|
||||
# 六十甲子纳音( pairwise ),末字即五行
|
||||
_NAYIN_PAIRS = [
|
||||
"海中金", "炉中火", "大林木", "路旁土", "剑锋金",
|
||||
"山头火", "涧下水", "城头土", "白蜡金", "杨柳木",
|
||||
"泉中水", "屋上土", "霹雳火", "松柏木", "长流水",
|
||||
"沙中金", "山下火", "平地木", "壁上土", "金箔金",
|
||||
"覆灯火", "天河水", "大驿土", "钗钏金", "桑柘木",
|
||||
"大溪水", "沙中土", "天上火", "石榴木", "大海水",
|
||||
]
|
||||
_JIAZI = [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
|
||||
NAYIN = {gz: _NAYIN_PAIRS[i // 2] for i, gz in enumerate(_JIAZI)}
|
||||
@@ -10,21 +10,9 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
GAN = "甲乙丙丁戊己庚辛壬癸"
|
||||
ZHI = "子丑寅卯辰巳午未申酉戌亥"
|
||||
YANG_GAN = set("甲丙戊庚壬")
|
||||
from .ganzhi import GAN, NAYIN, ZHI
|
||||
|
||||
# 六十甲子纳音( pairwise ),末字即五行
|
||||
_NAYIN_PAIRS = [
|
||||
"海中金", "炉中火", "大林木", "路旁土", "剑锋金",
|
||||
"山头火", "涧下水", "城头土", "白蜡金", "杨柳木",
|
||||
"泉中水", "屋上土", "霹雳火", "松柏木", "长流水",
|
||||
"沙中金", "山下火", "平地木", "壁上土", "金箔金",
|
||||
"覆灯火", "天河水", "大驿土", "钗钏金", "桑柘木",
|
||||
"大溪水", "沙中土", "天上火", "石榴木", "大海水",
|
||||
]
|
||||
_JIAZI = [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
|
||||
NAYIN = {gz: _NAYIN_PAIRS[i // 2] for i, gz in enumerate(_JIAZI)}
|
||||
YANG_GAN = set("甲丙戊庚壬")
|
||||
|
||||
# —— 日干查地支 ——
|
||||
TIANYI = {"甲": "丑未", "戊": "丑未", "庚": "丑未",
|
||||
@@ -102,6 +90,7 @@ SANQI = ("乙丙丁", "甲戊庚", "辛壬癸")
|
||||
# 输出排序(每柱内部按此序)
|
||||
STAR_ORDER = [
|
||||
"天德", "天德合", "月德", "月德合", "天乙贵人", "太极贵人", "福星贵人",
|
||||
"三奇贵人",
|
||||
"文昌", "禄神", "羊刃", "金舆", "驿马", "桃花", "红艳", "华盖", "将星",
|
||||
"劫煞", "灾煞", "亡神", "元辰", "勾煞", "绞煞", "孤辰", "寡宿",
|
||||
"天罗", "地网", "童子煞", "天赦", "魁罡", "阴差阳错", "十灵日", "十恶大败",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTML 盘面 → PNG 截图。
|
||||
|
||||
用法:
|
||||
python chart_shot.py <html路径> [-o 输出png路径]
|
||||
|
||||
截图分级回退:环境装有 playwright 则优先(file:// 全页截图,2 倍分辨率);
|
||||
否则用 macOS Chrome 无头截图(固定窗口,高度按 HTML 内容估算避免截断)。
|
||||
两者都不可用:退出码 2,stderr 提示直接打开 HTML。截图失败不影响排盘主流程。
|
||||
"""
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
|
||||
def _shot_playwright(html: Path, out: Path) -> bool:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page(device_scale_factor=2)
|
||||
page.goto(html.resolve().as_uri())
|
||||
page.screenshot(path=str(out), full_page=True)
|
||||
browser.close()
|
||||
except Exception:
|
||||
return False
|
||||
return out.is_file() and out.stat().st_size > 0
|
||||
|
||||
|
||||
def _content_height(html: Path) -> int:
|
||||
"""按 HTML 结构估算整页高度(px):头卡/主表/页脚取固定高,条带按格数折行。"""
|
||||
text = html.read_text(encoding="utf-8", errors="ignore")
|
||||
height = (900 + 40 * text.count('class="warn"')
|
||||
+ 150 * text.count('class="qrcard"'))
|
||||
for strip in text.split('class="strip"')[1:]:
|
||||
cells = strip.count('class="cell"')
|
||||
height += 36 + 132 * -(-cells // 9)
|
||||
return max(1300, min(height + 120, 4200))
|
||||
|
||||
|
||||
def _shot_chrome(html: Path, out: Path) -> bool:
|
||||
if not Path(CHROME).is_file():
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[CHROME, "--headless=new", f"--screenshot={out.resolve()}",
|
||||
f"--window-size=1100,{_content_height(html)}",
|
||||
"--force-device-scale-factor=2", "--hide-scrollbars",
|
||||
html.resolve().as_uri()],
|
||||
capture_output=True, timeout=60)
|
||||
except Exception:
|
||||
return False
|
||||
return r.returncode == 0 and out.is_file() and out.stat().st_size > 0
|
||||
|
||||
|
||||
def shoot(html: Path, out: Path | None = None) -> Path | None:
|
||||
"""对 HTML 盘面截图,成功返回 PNG 绝对路径,无可用工具或失败返回 None。"""
|
||||
out = out or html.with_suffix(".png")
|
||||
if _shot_playwright(html, out) or _shot_chrome(html, out):
|
||||
return out.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="chart_shot.py",
|
||||
description="HTML 盘面 → PNG 截图(playwright 优先,Chrome 无头兜底)")
|
||||
ap.add_argument("html", help="HTML 盘面文件路径")
|
||||
ap.add_argument("-o", "--out", default=None,
|
||||
help="输出 PNG 路径(默认与 HTML 同目录同名 .png)")
|
||||
a = ap.parse_args()
|
||||
html = Path(a.html)
|
||||
if not html.is_file():
|
||||
print(f"ERROR: 找不到 HTML 文件 {html}", file=sys.stderr)
|
||||
return 2
|
||||
png = shoot(html, Path(a.out) if a.out else None)
|
||||
if png:
|
||||
print(png)
|
||||
return 0
|
||||
print("无可用截图工具(未检测到 playwright 或 Chrome),"
|
||||
"请直接打开 HTML 文件查看盘面。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
|
||||
|
||||
|
||||
+222
-83
@@ -4,12 +4,23 @@
|
||||
用法:
|
||||
python memory_save.py --name 张三 --gender 男 \
|
||||
--birth "1990-05-14 08:30" --place "四川省成都市"
|
||||
|
||||
# 换盘(更新既有档案盘面段):按档案重排并替换「## 盘面」,frontmatter 同步
|
||||
python memory_save.py --update-chart memory/archives/丙子庚子庚辰丙子-女-2825d1.md \
|
||||
[--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后出生地"]
|
||||
输出:档案文件路径到 stdout。
|
||||
|
||||
建档即一次排盘:授权副本照常过软锁、扣减一次(与 cast_chart.py 同一 check/consume 位置)。
|
||||
同主键(四柱+性别+hash6)档案已存在时只刷新 frontmatter 与「## 盘面」,
|
||||
正文其余小节(归纳、反馈与澄清等)原样保留。档案规范见 memory/README.md。
|
||||
|
||||
换盘模式(--update-chart):姓名/性别/出生信息默认读档案 frontmatter,
|
||||
--birth/--place 用于时辰/地点校正,--day-boundary 子正 切换日界规则(默认子初)。
|
||||
换盘后「## 盘面」只保留新盘(工作盘唯一),旧盘信息由使用方记入「## 反馈与澄清」;
|
||||
frontmatter 的 pillars/day_master/true_solar_time 同步为新盘,文件改为新主键名
|
||||
(目标已存在且非同一文件时报错退出 2,不覆盖)。四柱有变动时 stderr 列出变动柱,
|
||||
提示使用方把受影响推断条目(归纳、流年大事)标记【待复核】。
|
||||
|
||||
退出码:0 成功;2 输入不可用;3 库内部错误;4 授权不可用(软锁,见 LICENSE.md)。
|
||||
"""
|
||||
import argparse
|
||||
@@ -21,6 +32,8 @@ from pathlib import Path
|
||||
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
|
||||
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
|
||||
if _VENDOR.is_dir():
|
||||
@@ -28,24 +41,215 @@ if _VENDOR.is_dir():
|
||||
|
||||
import _memory_store as store
|
||||
|
||||
_PILLAR_LABELS = ("年柱", "月柱", "日柱", "时柱")
|
||||
|
||||
# render 层日界文案写定(scripts/render/ 不在此处改动),换盘子正时改写这两行
|
||||
_AUDIT_ZICHU = "- 日界规则:子初换日(真太阳时 23:00)"
|
||||
_AUDIT_ZIZHENG = "- 日界规则:子正换日(真太阳时 00:00)"
|
||||
_WARN_ZICHU = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
|
||||
"已按「子初换日」归入次日。另存「子正换日」一派,"
|
||||
"日柱与时柱将不同,结论需谨慎。")
|
||||
_WARN_ZIZHENG = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
|
||||
"已按「子正换日」定盘(日界 0:00,不换日)。"
|
||||
"另存「子初换日」一派,日柱与时柱将不同,结论需谨慎。")
|
||||
|
||||
|
||||
def _cast_chart(name: str, gender: str, birth: str, place: str,
|
||||
day_boundary: str = "子初"):
|
||||
"""排盘管线(geo → astro → chart → render),与 cast_chart.py 同一调用序列。
|
||||
|
||||
返回 (命盘 Markdown, 四柱 list, 日主, 真太阳时 str);地点/时刻不可用抛
|
||||
LocationError/ValueError(由调用方映射退出码 2)。
|
||||
"""
|
||||
from geo.cities import resolve_location
|
||||
from astro.timezone_resolve import to_utc
|
||||
from astro.solar_time import to_true_solar_time
|
||||
from astro.day_boundary import resolve_calendar_day
|
||||
from chart.tyme_adapter import cast
|
||||
from chart.luck import (
|
||||
childhood_luck, compute_luck, decade_pillars, flow_months,
|
||||
flow_years, is_forward, luck_label_for_year,
|
||||
)
|
||||
from chart.model import build_chart_data
|
||||
from render.markdown import render
|
||||
|
||||
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
|
||||
loc = resolve_location(place)
|
||||
|
||||
# L0-b 出生时刻解析
|
||||
clock = datetime.strptime(birth.strip(), "%Y-%m-%d %H:%M")
|
||||
|
||||
# L0-c 当地钟表时间 → UTC(含历史时区/夏令时审计)
|
||||
utc, tz_audit = to_utc(clock, loc)
|
||||
|
||||
# L0-d UTC → 真太阳时(经度修正 + 均时差)
|
||||
tst, st_audit = to_true_solar_time(utc, loc.lon)
|
||||
|
||||
# L0-e 日界归算:默认子初换日(真太阳时 23:00–24:00 归入次日)
|
||||
day, bd_audit = resolve_calendar_day(tst, rule=day_boundary)
|
||||
|
||||
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
|
||||
|
||||
# L1-a 排盘(tyme4py 唯一接触面)
|
||||
raw = cast(cast_dt, gender)
|
||||
|
||||
# L1-b 起运、大运、流年、流月、小运
|
||||
forward = is_forward(gender, raw["year"]["gan"])
|
||||
luck = compute_luck(
|
||||
birth_tst=tst,
|
||||
prev_jie_time=raw["prev_jie"]["time"],
|
||||
next_jie_time=raw["next_jie"]["time"],
|
||||
forward=forward,
|
||||
)
|
||||
this_year = datetime.now().year
|
||||
n_pillars = max(
|
||||
8,
|
||||
int((this_year + 9 - cast_dt.year - luck["start_age_years"]) / 10) + 2,
|
||||
)
|
||||
decade = decade_pillars(
|
||||
raw["month"]["gan"], raw["month"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year, n=n_pillars,
|
||||
)
|
||||
years = [
|
||||
(y, gz, luck_label_for_year(y, decade))
|
||||
for y, gz in flow_years(cast_dt.year, this_year, 10)
|
||||
]
|
||||
months = flow_months(this_year)
|
||||
childhood = childhood_luck(
|
||||
raw["hour"]["gan"], raw["hour"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year,
|
||||
)
|
||||
|
||||
# L1-c 组装 ChartData
|
||||
chart = build_chart_data(
|
||||
name=name, gender=gender, place=place, clock=birth,
|
||||
loc=loc, tz_audit=tz_audit, st_audit=st_audit, bd_audit=bd_audit,
|
||||
raw=raw, luck=luck, luck_pillars=decade,
|
||||
flow_years=years, flow_months=months, childhood_luck=childhood,
|
||||
)
|
||||
|
||||
# L2 渲染命盘 Markdown(嵌入档案「## 盘面」,不直接打印)
|
||||
chart_md = render(chart)
|
||||
if day_boundary == "子正":
|
||||
# render 层日界文案固定为子初口径,换盘子正时改写警示条与审计行
|
||||
chart_md = chart_md.replace(_WARN_ZICHU, _WARN_ZIZHENG)
|
||||
chart_md = chart_md.replace(_AUDIT_ZICHU, _AUDIT_ZIZHENG)
|
||||
|
||||
pillars = [f"{chart.year.gan}{chart.year.zhi}",
|
||||
f"{chart.month.gan}{chart.month.zhi}",
|
||||
f"{chart.day.gan}{chart.day.zhi}",
|
||||
f"{chart.hour.gan}{chart.hour.zhi}"]
|
||||
return chart_md, pillars, chart.day_master, chart.true_solar_time
|
||||
|
||||
|
||||
def _update_chart(a) -> int:
|
||||
"""换盘模式:重排并替换既有档案的「## 盘面」段,frontmatter 与主键名同步。"""
|
||||
path = Path(a.update_chart).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
print(f"ERROR: 档案不存在:{path}", file=sys.stderr)
|
||||
return 2
|
||||
meta, body = store.parse_frontmatter(path.read_text(encoding="utf-8"))
|
||||
if not meta:
|
||||
print("ERROR: 档案无 frontmatter,无法换盘;请检查文件或用完整建档。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
name = str(meta.get("name", "")).strip()
|
||||
gender = str(meta.get("gender", "")).strip()
|
||||
birth = (a.birth or str(meta.get("birth_clock", ""))).strip()
|
||||
place = (a.place or str(meta.get("birthplace", ""))).strip()
|
||||
if not name or gender not in ("男", "女") or not birth or not place:
|
||||
print("ERROR: 档案 frontmatter 缺 name/gender/birth_clock/birthplace,"
|
||||
"无法重排;请补全档案或改用完整建档。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
from geo.cities import LocationError
|
||||
try:
|
||||
chart_md, pillars, day_master, tst_str = _cast_chart(
|
||||
name, gender, birth, place, a.day_boundary)
|
||||
except LocationError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 2
|
||||
except ValueError:
|
||||
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
|
||||
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
old_pillars = meta.get("pillars", [])
|
||||
if isinstance(old_pillars, str):
|
||||
old_pillars = old_pillars.split()
|
||||
meta.update({
|
||||
"name": name, "gender": gender, "birth_clock": birth, "birthplace": place,
|
||||
"true_solar_time": tst_str, "pillars": pillars, "day_master": day_master,
|
||||
"created_at": meta.get("created_at") or now, "updated_at": now,
|
||||
})
|
||||
body = store.replace_section(body, "盘面", chart_md)
|
||||
|
||||
# 四柱/hash6 变动时改为新主键名;目标已存在则拒绝覆盖
|
||||
new_path = path.with_name(store.archive_name(
|
||||
pillars, gender, store.key_hash(birth, place, gender)))
|
||||
if new_path != path:
|
||||
if new_path.exists():
|
||||
print(f"ERROR: 新盘主键档案已存在:{new_path};"
|
||||
f"为避免覆盖,本次未写入。请先核对两份档案。", file=sys.stderr)
|
||||
return 2
|
||||
new_path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||||
path.unlink()
|
||||
else:
|
||||
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||||
|
||||
changed = [f"{_PILLAR_LABELS[i]} {old_pillars[i]}→{pillars[i]}"
|
||||
for i in range(min(len(old_pillars), 4))
|
||||
if old_pillars[i] != pillars[i]]
|
||||
if changed:
|
||||
print(f"注意:{'、'.join(changed)} 已变。受变动柱影响的已有推断条目"
|
||||
f"(归纳、流年大事)须复核并标【待复核】,复核前不得继续引用;"
|
||||
f"旧盘四柱与换盘原因请记入「## 反馈与澄清」。", file=sys.stderr)
|
||||
print(new_path)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="memory_save.py",
|
||||
description="命主档案建档/更新:排盘一次并写入 memory/archives/(stdout 打印档案路径)",
|
||||
)
|
||||
ap.add_argument("--name", required=True, help="姓名")
|
||||
ap.add_argument("--gender", required=True, choices=["男", "女"],
|
||||
ap.add_argument("--name", default=None, help="姓名")
|
||||
ap.add_argument("--gender", default=None, choices=["男", "女"],
|
||||
help="性别:男 或 女")
|
||||
ap.add_argument("--birth", required=True,
|
||||
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需')
|
||||
ap.add_argument("--place", required=True,
|
||||
help="出生地,精确到地级市一级,如「四川省成都市」")
|
||||
ap.add_argument("--birth", default=None,
|
||||
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需;'
|
||||
"换盘模式下为时辰校正覆盖值")
|
||||
ap.add_argument("--place", default=None,
|
||||
help="出生地,精确到地级市一级,如「四川省成都市」;"
|
||||
"换盘模式下为地点校正覆盖值")
|
||||
ap.add_argument("--update-chart", default=None, metavar="档案路径",
|
||||
help="换盘模式:按档案重排并替换其「## 盘面」段,"
|
||||
"frontmatter 与文件名同步为新盘")
|
||||
ap.add_argument("--day-boundary", default="子初", choices=["子初", "子正"],
|
||||
help="日界规则:子初(默认,真太阳时 23:00 换日)或 子正(0:00 换日);"
|
||||
"仅换盘模式可用")
|
||||
ap.add_argument("--memory-root", default=None,
|
||||
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
|
||||
a = ap.parse_args()
|
||||
|
||||
# 软授权锁:与 cast_chart.py 同位置——建档即一次排盘,先 check,成功后 consume
|
||||
if a.update_chart is None:
|
||||
missing = [k for k in ("name", "gender", "birth", "place")
|
||||
if getattr(a, k) is None]
|
||||
if missing:
|
||||
print(f"ERROR: 建档缺参数:{'、'.join('--' + k for k in missing)}。"
|
||||
f"(换盘请用 --update-chart <档案路径>)", file=sys.stderr)
|
||||
return 2
|
||||
if a.day_boundary != "子初":
|
||||
print("ERROR: --day-boundary 仅换盘模式(--update-chart)可用;"
|
||||
"建档固定子初换日。", file=sys.stderr)
|
||||
return 2
|
||||
elif a.name is not None or a.gender is not None:
|
||||
print("ERROR: 换盘模式不支持 --name/--gender(姓名、性别以档案为准)。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# 软授权锁:与 cast_chart.py 同位置——建档/换盘即一次排盘,先 check,成功后 consume
|
||||
import _license_gate as _gate
|
||||
_deny = _gate.check(Path(__file__).resolve().parents[1])
|
||||
if _deny:
|
||||
@@ -53,91 +257,26 @@ def main() -> int:
|
||||
return 4
|
||||
|
||||
try:
|
||||
# 排盘管线与 cast_chart.py 同一调用序列(geo → astro → chart → render)
|
||||
from geo.cities import resolve_location, LocationError
|
||||
from astro.timezone_resolve import to_utc
|
||||
from astro.solar_time import to_true_solar_time
|
||||
from astro.day_boundary import resolve_calendar_day
|
||||
from chart.tyme_adapter import cast
|
||||
from chart.luck import (
|
||||
childhood_luck, compute_luck, decade_pillars, flow_months,
|
||||
flow_years, is_forward, luck_label_for_year,
|
||||
)
|
||||
from chart.model import build_chart_data
|
||||
from render.markdown import render
|
||||
if a.update_chart is not None:
|
||||
rc = _update_chart(a)
|
||||
if rc == 0:
|
||||
_gate.consume(Path(__file__).resolve().parents[1])
|
||||
return rc
|
||||
|
||||
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
|
||||
# L0/L1/L2 排盘管线(地点/时刻不可用 → 退出码 2)
|
||||
from geo.cities import LocationError
|
||||
try:
|
||||
loc = resolve_location(a.place)
|
||||
chart_md, pillars, day_master, tst_str = _cast_chart(
|
||||
a.name, a.gender, a.birth, a.place)
|
||||
except LocationError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# L0-b 出生时刻解析
|
||||
try:
|
||||
clock = datetime.strptime(a.birth.strip(), "%Y-%m-%d %H:%M")
|
||||
except ValueError:
|
||||
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
|
||||
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# L0-c 当地钟表时间 → UTC(含历史时区/夏令时审计)
|
||||
utc, tz_audit = to_utc(clock, loc)
|
||||
|
||||
# L0-d UTC → 真太阳时(经度修正 + 均时差)
|
||||
tst, st_audit = to_true_solar_time(utc, loc.lon)
|
||||
|
||||
# L0-e 子初换日:真太阳时 23:00–24:00 归入次日
|
||||
day, bd_audit = resolve_calendar_day(tst)
|
||||
|
||||
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
|
||||
|
||||
# L1-a 排盘(tyme4py 唯一接触面)
|
||||
raw = cast(cast_dt, a.gender)
|
||||
|
||||
# L1-b 起运、大运、流年、流月、小运
|
||||
forward = is_forward(a.gender, raw["year"]["gan"])
|
||||
luck = compute_luck(
|
||||
birth_tst=tst,
|
||||
prev_jie_time=raw["prev_jie"]["time"],
|
||||
next_jie_time=raw["next_jie"]["time"],
|
||||
forward=forward,
|
||||
)
|
||||
this_year = datetime.now().year
|
||||
n_pillars = max(
|
||||
8,
|
||||
int((this_year + 9 - cast_dt.year - luck["start_age_years"]) / 10) + 2,
|
||||
)
|
||||
decade = decade_pillars(
|
||||
raw["month"]["gan"], raw["month"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year, n=n_pillars,
|
||||
)
|
||||
years = [
|
||||
(y, gz, luck_label_for_year(y, decade))
|
||||
for y, gz in flow_years(cast_dt.year, this_year, 10)
|
||||
]
|
||||
months = flow_months(this_year)
|
||||
childhood = childhood_luck(
|
||||
raw["hour"]["gan"], raw["hour"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year,
|
||||
)
|
||||
|
||||
# L1-c 组装 ChartData
|
||||
chart = build_chart_data(
|
||||
name=a.name, gender=a.gender, place=a.place, clock=a.birth,
|
||||
loc=loc, tz_audit=tz_audit, st_audit=st_audit, bd_audit=bd_audit,
|
||||
raw=raw, luck=luck, luck_pillars=decade,
|
||||
flow_years=years, flow_months=months, childhood_luck=childhood,
|
||||
)
|
||||
|
||||
# L2 渲染命盘 Markdown(嵌入档案「## 盘面」,不直接打印)
|
||||
chart_md = render(chart)
|
||||
|
||||
# —— 建档/更新 ——
|
||||
pillars = [f"{chart.year.gan}{chart.year.zhi}",
|
||||
f"{chart.month.gan}{chart.month.zhi}",
|
||||
f"{chart.day.gan}{chart.day.zhi}",
|
||||
f"{chart.hour.gan}{chart.hour.zhi}"]
|
||||
root = store.resolve_root(a.memory_root, __file__)
|
||||
arch_dir = root / "archives"
|
||||
arch_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -147,8 +286,8 @@ def main() -> int:
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
meta = {
|
||||
"name": a.name, "gender": a.gender, "birth_clock": a.birth.strip(),
|
||||
"birthplace": a.place.strip(), "true_solar_time": chart.true_solar_time,
|
||||
"pillars": pillars, "day_master": chart.day_master,
|
||||
"birthplace": a.place.strip(), "true_solar_time": tst_str,
|
||||
"pillars": pillars, "day_master": day_master,
|
||||
"created_at": now, "updated_at": now,
|
||||
}
|
||||
if path.is_file():
|
||||
|
||||
@@ -19,6 +19,8 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
+71
-117
@@ -2,52 +2,24 @@
|
||||
"""ChartData → 单文件 HTML 盘面(模板 render/templates/paipan.html)。
|
||||
|
||||
与 Markdown 输出同为纯计算渲染:十神/藏干/空亡/纳音/十二长生/月令旺衰
|
||||
均为固定查表,不含任何命理判断(P1)。
|
||||
均为固定查表,不含任何命理判断(P1)。岁运干支(大运/流年格)的十神/
|
||||
星运/空亡/纳音复用 chart/ganzhi.py,神煞复用 chart/shensha.py 的岁运
|
||||
求值器 evaluate_target(与 query_shensha.py 同一口径),不在渲染层
|
||||
另造规则。
|
||||
|
||||
logo 与品牌字体(render/assets/)以 base64 内嵌,保证 HTML 单文件便携;
|
||||
神煞条目的查法注释(全角/半角括号)仅在本装配层剔除,Markdown 输出保持原样。
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from chart.luck import luck_label_for_year
|
||||
from chart.ganzhi import NAYIN, WX_GAN, WX_ZHI, gz_void, ten_god, terrain
|
||||
from chart.shensha import evaluate_target
|
||||
|
||||
GAN = "甲乙丙丁戊己庚辛壬癸"
|
||||
ZHI = "子丑寅卯辰巳午未申酉戌亥"
|
||||
|
||||
_WX_GAN = {"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土",
|
||||
"己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"}
|
||||
_WX_ZHI = {"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土",
|
||||
"巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金",
|
||||
"戌": "土", "亥": "水"}
|
||||
_YY = {"甲": 1, "丙": 1, "戊": 1, "庚": 1, "壬": 1,
|
||||
"乙": -1, "丁": -1, "己": -1, "辛": -1, "癸": -1}
|
||||
_SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
||||
_KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
|
||||
|
||||
_HIDDEN = {"子": ["癸"], "丑": ["己", "癸", "辛"], "寅": ["甲", "丙", "戊"],
|
||||
"卯": ["乙"], "辰": ["戊", "乙", "癸"], "巳": ["丙", "戊", "庚"],
|
||||
"午": ["丁", "己"], "未": ["己", "丁", "乙"],
|
||||
"申": ["庚", "壬", "戊"], "酉": ["辛"], "戌": ["戊", "辛", "丁"],
|
||||
"亥": ["壬", "甲"]}
|
||||
|
||||
_NAYIN = {"甲子": "海中金", "乙丑": "海中金", "丙寅": "炉中火", "丁卯": "炉中火",
|
||||
"戊辰": "大林木", "己巳": "大林木", "庚午": "路旁土", "辛未": "路旁土",
|
||||
"壬申": "剑锋金", "癸酉": "剑锋金", "甲戌": "山头火", "乙亥": "山头火",
|
||||
"丙子": "涧下水", "丁丑": "涧下水", "戊寅": "城头土", "己卯": "城头土",
|
||||
"庚辰": "白蜡金", "辛巳": "白蜡金", "壬午": "杨柳木", "癸未": "杨柳木",
|
||||
"甲申": "泉中水", "乙酉": "泉中水", "丙戌": "屋上土", "丁亥": "屋上土",
|
||||
"戊子": "霹雳火", "己丑": "霹雳火", "庚寅": "松柏木", "辛卯": "松柏木",
|
||||
"壬辰": "长流水", "癸巳": "长流水", "甲午": "沙中金", "乙未": "沙中金",
|
||||
"丙申": "山下火", "丁酉": "山下火", "戊戌": "平地木", "己亥": "平地木",
|
||||
"庚子": "壁上土", "辛丑": "壁上土", "壬寅": "金箔金", "癸卯": "金箔金",
|
||||
"甲辰": "覆灯火", "乙巳": "覆灯火", "丙午": "天河水", "丁未": "天河水",
|
||||
"戊申": "大驿土", "己酉": "大驿土", "庚戌": "钗钏金", "辛亥": "钗钏金",
|
||||
"壬子": "桑柘木", "癸丑": "桑柘木", "甲寅": "大溪水", "乙卯": "大溪水",
|
||||
"丙辰": "沙中土", "丁巳": "沙中土", "戊午": "天上火", "己未": "天上火",
|
||||
"庚申": "石榴木", "辛酉": "石榴木", "壬戌": "大海水", "癸亥": "大海水"}
|
||||
|
||||
_TERRAIN_ORDER = ["长生", "沐浴", "冠带", "临官", "帝旺", "衰",
|
||||
"病", "死", "墓", "绝", "胎", "养"]
|
||||
_TERRAIN_START = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳",
|
||||
"壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"}
|
||||
_WX_CLASS = {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu",
|
||||
"金": "wx-jin", "水": "wx-shui"}
|
||||
|
||||
_SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "水", "金", "土"),
|
||||
"巳": ("火", "土", "木", "水", "金"), "午": ("火", "土", "木", "水", "金"),
|
||||
@@ -57,43 +29,22 @@ _SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "
|
||||
"丑": ("土", "金", "火", "木", "水"), "未": ("土", "金", "火", "木", "水")}
|
||||
_SEASON_LABEL = ("旺", "相", "休", "囚", "死")
|
||||
|
||||
_ASSETS_DIR = Path(__file__).resolve().parent / "assets"
|
||||
_SHA_NOTE_RE = re.compile(r"[((][^))]*[))]")
|
||||
|
||||
|
||||
def _strip_sha_note(entry: str) -> str:
|
||||
"""神煞条目剔除查法括号注释:`将星(以年支子查,见于月支子)` → `将星`。"""
|
||||
return _SHA_NOTE_RE.sub("", entry).strip()
|
||||
|
||||
|
||||
def _asset_b64(name: str) -> str:
|
||||
return base64.b64encode((_ASSETS_DIR / name).read_bytes()).decode("ascii")
|
||||
|
||||
|
||||
def _wx(ch: str) -> str:
|
||||
wx = _WX_GAN.get(ch) or _WX_ZHI.get(ch)
|
||||
return {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu",
|
||||
"金": "wx-jin", "水": "wx-shui"}.get(wx, "")
|
||||
|
||||
|
||||
def _ten_god(me: str, other: str) -> str:
|
||||
wx_m, wx_o = _WX_GAN[me], _WX_GAN[other]
|
||||
same = _YY[me] == _YY[other]
|
||||
if wx_m == wx_o:
|
||||
return "比肩" if same else "劫财"
|
||||
if _SHENG[wx_m] == wx_o:
|
||||
return "食神" if same else "伤官"
|
||||
if _KE[wx_m] == wx_o:
|
||||
return "偏财" if same else "正财"
|
||||
if _KE[wx_o] == wx_m:
|
||||
return "七杀" if same else "正官"
|
||||
return "偏印" if same else "正印"
|
||||
|
||||
|
||||
def _terrain(me: str, zhi: str) -> str:
|
||||
"""日干对支的十二长生(阳顺阴逆)。"""
|
||||
start = _ZHI_IDX(_TERRAIN_START[me])
|
||||
zi = _ZHI_IDX(zhi)
|
||||
off = (zi - start) % 12 if _YY[me] == 1 else (start - zi) % 12
|
||||
return _TERRAIN_ORDER[off]
|
||||
|
||||
|
||||
def _ZHI_IDX(z: str) -> int:
|
||||
return ZHI.index(z)
|
||||
|
||||
|
||||
def _gz_void(gz: str) -> str:
|
||||
"""一柱干支的旬空两支。"""
|
||||
g, z = GAN.index(gz[0]), ZHI.index(gz[1])
|
||||
return ZHI[(z - g + 10) % 12] + ZHI[(z - g + 11) % 12]
|
||||
wx = WX_GAN.get(ch) or WX_ZHI.get(ch)
|
||||
return _WX_CLASS.get(wx, "")
|
||||
|
||||
|
||||
def _z(zhi: str, voids: str) -> str:
|
||||
@@ -111,51 +62,21 @@ def _pillar_col(chart, key: str, label: str, cur: bool = False) -> dict:
|
||||
"gan": p.gan, "zhi": p.zhi,
|
||||
"hidden": list(zip(p.hidden_stems, p.hidden_ten_gods)),
|
||||
"terrain": chart.terrain.get(key, ""),
|
||||
"void": _gz_void(p.gan + p.zhi),
|
||||
"void": gz_void(p.gan + p.zhi),
|
||||
"nayin": p.na_yin or "",
|
||||
"shensha": chart.shen_sha.get(key, []),
|
||||
"shensha": [_strip_sha_note(s) for s in chart.shen_sha.get(key, [])],
|
||||
"voids": voids,
|
||||
}
|
||||
|
||||
|
||||
def _extra_col(chart, gz: str, label: str, cur: bool) -> dict:
|
||||
"""流年/大运列(仅干支行星级信息,神煞留空)。"""
|
||||
me = chart.day_master
|
||||
voids = chart.void.get("day", "") + chart.void.get("hour", "")
|
||||
return {
|
||||
"label": label, "cur": cur,
|
||||
"god": _ten_god(me, gz[0]),
|
||||
"gan": gz[0], "zhi": gz[1],
|
||||
"hidden": [(h, _ten_god(me, h)) for h in _HIDDEN[gz[1]]],
|
||||
"terrain": _terrain(me, gz[1]),
|
||||
"void": _gz_void(gz),
|
||||
"nayin": _NAYIN[gz],
|
||||
"shensha": [],
|
||||
"voids": voids,
|
||||
}
|
||||
|
||||
|
||||
def _main_table(chart) -> str:
|
||||
this_year = datetime.now().year
|
||||
ly = next(((y, gz) for y, gz, _ in chart.flow_years if y == this_year), None)
|
||||
cur_luck = None
|
||||
for lp in chart.luck_pillars:
|
||||
if lp.start_year <= this_year:
|
||||
cur_luck = lp
|
||||
cols = []
|
||||
if ly:
|
||||
cols.append(_extra_col(chart, ly[1], f"流年 {ly[0]}", cur=True))
|
||||
if cur_luck:
|
||||
cols.append(_extra_col(chart, cur_luck.gan + cur_luck.zhi, "大运", cur=True))
|
||||
for key, label in (("year", "年柱"), ("month", "月柱"),
|
||||
("day", "日柱"), ("hour", "时柱")):
|
||||
cols.append(_pillar_col(chart, key, label, cur=(key == "day")))
|
||||
def _render_table(cols: list) -> str:
|
||||
"""干支列列表 → 排盘表格(主星/天干/地支/藏干/星运/空亡/纳音/神煞)。"""
|
||||
|
||||
def th(c):
|
||||
tag = '<span class="curtag">当前</span>' if c["cur"] else ""
|
||||
return f'<th class="{"cur" if c["cur"] else ""}">{c["label"]}{tag}</th>'
|
||||
|
||||
def row(label, fn, cls=""):
|
||||
def row(label, fn):
|
||||
tds = "".join(
|
||||
f'<td class="{"cur" if c["cur"] else ""}">{fn(c)}</td>' for c in cols)
|
||||
return f'<tr><td class="rowlabel">{label}</td>{tds}</tr>'
|
||||
@@ -178,6 +99,34 @@ def _main_table(chart) -> str:
|
||||
return '<table class="pan">' + "".join(rows) + "</table>"
|
||||
|
||||
|
||||
def _main_table(chart) -> str:
|
||||
"""排盘卡:仅年月日时四柱。"""
|
||||
cols = [_pillar_col(chart, key, label, cur=(key == "day"))
|
||||
for key, label in (("year", "年柱"), ("month", "月柱"),
|
||||
("day", "日柱"), ("hour", "时柱"))]
|
||||
return _render_table(cols)
|
||||
|
||||
|
||||
def _target_shensha(chart, gz: str) -> list[str]:
|
||||
"""岁运干支相对本命锚点所逢的神煞(与 query_shensha.py 同一求值器)。"""
|
||||
pillars = {k: getattr(chart, k).gan + getattr(chart, k).zhi
|
||||
for k in ("year", "month", "day", "hour")}
|
||||
return [star for star, _ in evaluate_target(pillars, chart.gender, gz)["anchored"]]
|
||||
|
||||
|
||||
def _gz_sub(chart, gz: str) -> str:
|
||||
"""岁运小卡的干支附属行:十神·星运 / 空亡 / 纳音 / 神煞(无标签,值本身说话)。"""
|
||||
lines = [
|
||||
f"{ten_god(chart.day_master, gz[0])} · {terrain(chart.day_master, gz[1])}",
|
||||
gz_void(gz),
|
||||
NAYIN[gz],
|
||||
]
|
||||
sha = _target_shensha(chart, gz)
|
||||
if sha:
|
||||
lines.append(" · ".join(sha))
|
||||
return "".join(f'<div class="cs">{line}</div>' for line in lines)
|
||||
|
||||
|
||||
def _luck_strip(chart) -> str:
|
||||
this_year = datetime.now().year
|
||||
cells = []
|
||||
@@ -189,8 +138,7 @@ def _luck_strip(chart) -> str:
|
||||
f'<div class="cy">{lp.start_year} · {lp.start_age:.0f} 岁起</div>'
|
||||
f'<div class="cg"><span class="{_wx(lp.gan)}">{lp.gan}</span>'
|
||||
f'<span class="{_wx(lp.zhi)}">{lp.zhi}</span></div>'
|
||||
f'<div class="cy">{_ten_god(chart.day_master, lp.gan)}'
|
||||
f' · {_terrain(chart.day_master, lp.zhi)}</div></div>')
|
||||
f'{_gz_sub(chart, gz)}</div>')
|
||||
return '<div class="strip">' + "".join(cells) + "</div>"
|
||||
|
||||
|
||||
@@ -218,7 +166,8 @@ def _flow_years(chart) -> str:
|
||||
cls += " jiao"
|
||||
cells.append(f'<div class="{cls}"><div class="cy">{year}</div>'
|
||||
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
|
||||
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>')
|
||||
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div>'
|
||||
f'{_gz_sub(chart, gz)}</div>')
|
||||
return '<div class="strip">' + "".join(cells) + "</div>"
|
||||
|
||||
|
||||
@@ -258,14 +207,18 @@ def build_html(chart) -> str:
|
||||
|
||||
audit = (f"时区 {chart.tz_used}(夏令时:{'是' if chart.is_dst else '否'})· "
|
||||
f"经度修正 {chart.lon_correction_min:+.2f} 分 · 均时差 {chart.eot_minutes:+.2f} 分 · "
|
||||
f"日界 子初换日(23:00)· tyme4py {chart.library_version} · "
|
||||
f"schema {chart.schema_version}")
|
||||
f"日界 子初换日(23:00)")
|
||||
audit_full = (f"{audit}· tyme4py {chart.library_version} · "
|
||||
f"schema {chart.schema_version}")
|
||||
|
||||
mapping = {
|
||||
"TITLE": f"命盘:{chart.name}",
|
||||
"LOGO_B64": _asset_b64("lingyao-logo.png"),
|
||||
"FONT_B64": _asset_b64("logo-font.ttf"),
|
||||
"QRCODE_B64": _asset_b64("qun-qrcode.jpg"),
|
||||
"NAME": chart.name,
|
||||
"GENDER_LABEL": "乾造" if chart.gender == "男" else "坤造",
|
||||
"DAY_MASTER": f"{chart.day_master}{_WX_GAN[chart.day_master]}",
|
||||
"DAY_MASTER": f"{chart.day_master}{WX_GAN[chart.day_master]}",
|
||||
"BIRTHPLACE": chart.birthplace,
|
||||
"BIRTH_CLOCK": chart.birth_clock,
|
||||
"TRUE_SOLAR": chart.true_solar_time,
|
||||
@@ -282,6 +235,7 @@ def build_html(chart) -> str:
|
||||
"FLOW_MONTHS": _flow_months(chart),
|
||||
"SEASON_BAR": _season_bar(chart),
|
||||
"AUDIT": audit,
|
||||
"AUDIT_FULL": audit_full,
|
||||
}
|
||||
out = tpl
|
||||
for k, v in mapping.items():
|
||||
|
||||
@@ -5,24 +5,69 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'LogoFont';
|
||||
src: url("data:font/ttf;base64,{{FONT_B64}}") format("truetype");
|
||||
font-weight: normal; font-style: normal; font-display: swap;
|
||||
}
|
||||
:root {
|
||||
--ink: #2b2b2b; --muted: #8a8577; --line: #e6dfd2; --bg: #f7f4ee;
|
||||
--card: #fffdf8; --accent: #b08d2f;
|
||||
/* 灵爻妙解网站浅色主题(root.css :root.light + paipan_base.css) */
|
||||
--ink: #222831;
|
||||
--muted: rgba(27, 27, 27, .52);
|
||||
--soft: rgba(27, 27, 27, .68);
|
||||
--line: rgba(193, 188, 235, .55);
|
||||
--card-line: rgba(193, 188, 235, .45);
|
||||
--accent: #dc7b28; /* 网站浅色 primary-color-1 */
|
||||
--accent-2: #f97316; /* 网站浅色 primary-color-3 */
|
||||
--wx-mu: #2e7d32; --wx-huo: #c62828; --wx-tu: #8d6e63;
|
||||
--wx-jin: #af8c2c; --wx-shui: #1565c0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 24px; background: var(--bg); color: var(--ink);
|
||||
margin: 0; padding: 28px 24px 32px; color: var(--ink);
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(ellipse 72% 58% at 78% 20%, rgba(150, 125, 245, .18), transparent 68%),
|
||||
radial-gradient(ellipse 66% 48% at 20% 18%, rgba(112, 174, 255, .18), transparent 70%),
|
||||
radial-gradient(ellipse 82% 52% at 52% 92%, rgba(84, 210, 200, .11), transparent 72%),
|
||||
linear-gradient(135deg, #fbfcff 0%, #f5f8ff 42%, #f2edff 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
.page { max-width: 980px; margin: 0 auto; }
|
||||
.page { max-width: 1100px; margin: 0 auto; }
|
||||
|
||||
/* 顶部品牌区:base.html 抽屉顶部 .drawer-brand 的居中放大版 */
|
||||
.brand-head {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 14px; margin: 2px 0 20px;
|
||||
}
|
||||
.brand-logo { width: 52px; height: 52px; display: block; }
|
||||
.brand-name {
|
||||
font-family: 'LogoFont', 'Kaiti SC', 'STKaiti', 'KaiTi', serif;
|
||||
font-weight: 500; font-size: 30px; line-height: 1.25;
|
||||
color: var(--ink); letter-spacing: 1px;
|
||||
}
|
||||
.brand-slogan {
|
||||
margin-top: 3px; font-size: 11px; letter-spacing: 3.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 大卡片:网站左 panel(.panel)亮色风格 */
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 1), rgba(250, 252, 255, 1));
|
||||
box-shadow: 0 16px 38px rgba(48, 56, 96, .12);
|
||||
padding: 16px;
|
||||
}
|
||||
/* 面板内子卡 */
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 18px 20px; margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(60, 50, 30, .06);
|
||||
background: #fff; border: 1px solid var(--card-line);
|
||||
border-radius: 8px; padding: 16px 18px; margin-bottom: 14px;
|
||||
box-shadow: 0 2px 8px rgba(48, 56, 96, .05);
|
||||
}
|
||||
/* 头部 */
|
||||
.card:last-child { margin-bottom: 0; }
|
||||
|
||||
/* 用户信息 */
|
||||
.head { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.name { font-size: 28px; font-weight: 700; font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
|
||||
.chip {
|
||||
@@ -31,90 +76,128 @@
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 13px; line-height: 1.9; }
|
||||
.meta b { color: var(--ink); font-weight: 600; }
|
||||
.meta.tech {
|
||||
margin-top: 8px; padding-top: 8px; font-size: 12.5px;
|
||||
border-top: 1px dashed var(--card-line);
|
||||
}
|
||||
.warn {
|
||||
margin-top: 6px; padding: 6px 10px; border-radius: 8px;
|
||||
background: #fdf3e3; border: 1px solid #ecd9ae; color: #8a6d1d; font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 主排盘表 */
|
||||
table.pan { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.pan th, .pan td {
|
||||
border: 1px solid var(--line); text-align: center;
|
||||
border: 1px solid var(--card-line); text-align: center;
|
||||
padding: 6px 4px; vertical-align: middle; font-size: 14px;
|
||||
}
|
||||
.pan th { background: #f3eee2; color: #6b6455; font-weight: 600; }
|
||||
.pan td.rowlabel { background: #f3eee2; color: #6b6455; font-size: 13px; width: 64px; }
|
||||
.pan th { background: rgba(193, 188, 235, .18); color: var(--soft); font-weight: 600; }
|
||||
.pan td.rowlabel { background: rgba(193, 188, 235, .14); color: var(--soft); font-size: 13px; width: 64px; }
|
||||
.gz { font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; font-size: 34px; line-height: 1.25; }
|
||||
.god { font-size: 12.5px; color: #7a7264; }
|
||||
.god { font-size: 12.5px; color: var(--muted); }
|
||||
.god.day { color: var(--accent); font-weight: 600; }
|
||||
.hidden { font-size: 12.5px; line-height: 1.7; color: #57503f; }
|
||||
.hidden { font-size: 12.5px; line-height: 1.7; color: var(--soft); }
|
||||
.hidden .hg { font-weight: 600; }
|
||||
.small { font-size: 12.5px; color: #57503f; line-height: 1.7; }
|
||||
.small { font-size: 12.5px; color: var(--soft); line-height: 1.7; }
|
||||
.voidz { border-bottom: 2px dashed #c0392b; padding-bottom: 1px; }
|
||||
.cur { background: #fdf6e0; }
|
||||
.curtag { display:block; font-size: 11px; color: var(--accent); }
|
||||
.cur { background: rgba(220, 123, 40, .07); }
|
||||
.curtag { display: block; font-size: 11px; color: var(--accent); }
|
||||
.wx-mu { color: var(--wx-mu); } .wx-huo { color: var(--wx-huo); }
|
||||
.wx-tu { color: var(--wx-tu); } .wx-jin { color: var(--wx-jin); }
|
||||
.wx-shui { color: var(--wx-shui); }
|
||||
|
||||
/* 条带 */
|
||||
.sect { font-size: 15px; font-weight: 700; margin: 2px 0 10px;
|
||||
font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
|
||||
.sect .note { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 8px; }
|
||||
.strip { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.cell {
|
||||
border: 1px solid var(--line); border-radius: 8px; background: #fff;
|
||||
padding: 6px 8px; text-align: center; min-width: 64px;
|
||||
border: 1px solid var(--card-line); border-radius: 8px; background: #fff;
|
||||
padding: 6px 8px; text-align: center; min-width: 64px; max-width: 96px;
|
||||
}
|
||||
.cell .cy { font-size: 11px; color: var(--muted); }
|
||||
.cell .cg { font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; font-size: 20px; }
|
||||
.cell.cur { border-color: var(--accent); background: #fdf6e0; box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.cell .cs { font-size: 11px; color: var(--muted); line-height: 1.55; margin-top: 1px; }
|
||||
.cell.cur { border-color: var(--accent); background: rgba(220, 123, 40, .07); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.cell.jiao .cy { color: #c0392b; }
|
||||
|
||||
/* 尾部交流群卡:左图右文 */
|
||||
.qrcard { display: flex; align-items: center; gap: 16px; }
|
||||
.qrimg { width: 108px; height: 108px; border-radius: 6px; display: block; }
|
||||
.qrtitle { font-size: 15px; font-weight: 700;
|
||||
font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
|
||||
.qrdesc { margin-top: 4px; font-size: 12.5px; color: var(--soft); line-height: 1.8; }
|
||||
|
||||
/* 页脚 */
|
||||
.foot { color: var(--muted); font-size: 12px; line-height: 1.9; }
|
||||
.brand { margin-top: 6px; color: #a89e86; }
|
||||
.foot {
|
||||
text-align: center; color: var(--muted); font-size: 12px;
|
||||
line-height: 1.9; margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<div class="card">
|
||||
<div class="head">
|
||||
<span class="name">{{NAME}}</span>
|
||||
<span class="chip">{{GENDER_LABEL}}</span>
|
||||
<span class="chip">日主 {{DAY_MASTER}}</span>
|
||||
<div class="brand-head">
|
||||
<img class="brand-logo" src="data:image/png;base64,{{LOGO_B64}}" alt="灵爻妙解">
|
||||
<div class="brand-text">
|
||||
<div class="brand-name">灵爻妙解</div>
|
||||
<div class="brand-slogan">传承易学智慧</div>
|
||||
</div>
|
||||
<div class="meta" style="margin-top:8px">
|
||||
出生地:<b>{{BIRTHPLACE}}</b> 钟表时间:<b>{{BIRTH_CLOCK}}</b> 真太阳时:<b>{{TRUE_SOLAR}}</b><br>
|
||||
月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
|
||||
<div class="card">
|
||||
<div class="head">
|
||||
<span class="name">{{NAME}}</span>
|
||||
<span class="chip">{{GENDER_LABEL}}</span>
|
||||
<span class="chip">日主 {{DAY_MASTER}}</span>
|
||||
</div>
|
||||
<div class="meta" style="margin-top:8px">
|
||||
出生地:<b>{{BIRTHPLACE}}</b> 钟表时间:<b>{{BIRTH_CLOCK}}</b> 真太阳时:<b>{{TRUE_SOLAR}}</b><br>
|
||||
月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}}
|
||||
</div>
|
||||
{{WARNINGS}}
|
||||
<div class="meta tech">
|
||||
{{SEASON_BAR}}<br>
|
||||
{{AUDIT}}
|
||||
</div>
|
||||
<!-- 排盘审计原文(开发溯源,含版本信息):{{AUDIT_FULL}} -->
|
||||
</div>
|
||||
{{WARNINGS}}
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">排盘<span class="note">四柱 · 藏干 · 空亡 · 纳音 · 神煞</span></div>
|
||||
{{MAIN_TABLE}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">大运<span class="note">{{LUCK_NOTE}}</span></div>
|
||||
{{LUCK_STRIP}}
|
||||
{{CHILDHOOD_BLOCK}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">流年<span class="note">出生年至今后十年,今年高亮,红字年份为交运年</span></div>
|
||||
{{FLOW_YEARS}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">流月<span class="note">本年节气月</span></div>
|
||||
{{FLOW_MONTHS}}
|
||||
</div>
|
||||
|
||||
<div class="card qrcard">
|
||||
<img class="qrimg" src="data:image/jpeg;base64,{{QRCODE_B64}}" alt="微信群二维码">
|
||||
<div class="qrtext">
|
||||
<div class="qrtitle">灵爻妙解 · 传统文化交流群</div>
|
||||
<div class="qrdesc">扫码加入微信群:反馈解读偏差、交流文化心得、参与档案共建,版本更新动态第一时间同步。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">排盘<span class="note">四柱 · 藏干 · 空亡 · 纳音 · 神煞(当前大运与流年并列对照)</span></div>
|
||||
{{MAIN_TABLE}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">大运<span class="note">{{LUCK_NOTE}}</span></div>
|
||||
{{LUCK_STRIP}}
|
||||
{{CHILDHOOD_BLOCK}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">流年<span class="note">出生年至今后十年,今年高亮,红字年份为交运年</span></div>
|
||||
{{FLOW_YEARS}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">流月<span class="note">本年节气月</span></div>
|
||||
{{FLOW_MONTHS}}
|
||||
</div>
|
||||
|
||||
<div class="card foot">
|
||||
{{SEASON_BAR}}<br>
|
||||
{{AUDIT}}
|
||||
<div class="brand">灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考</div>
|
||||
</div>
|
||||
<div class="foot">灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -31,6 +31,10 @@ import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
# ---- 远端配置(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"
|
||||
|
||||
+125
-17
@@ -6,11 +6,18 @@
|
||||
python share_export.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男
|
||||
|
||||
脱敏规格(见 share/README.md):不含姓名;出生信息只保留年月日与时辰(不留分钟
|
||||
与钟表时间);地域只保留到省级;事件一律类目化;正文自由文本(归纳、盘面细节、
|
||||
反馈原文)不入包。盘 hash = sha1(四柱+出生地) 前 8 位,用于去重与撤回。
|
||||
与钟表时间);地域保留到城市级(省+地级市,区县及以下删除);事件带类目与简介
|
||||
(note),简介中城市级地名保留(规则蒸馏要用方位信息),姓名、详细住址
|
||||
(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除;归纳、盘面细节等
|
||||
正文自由文本不入包。盘 hash = sha1(四柱+出生地) 前 8 位,用于去重与撤回。
|
||||
|
||||
完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态);
|
||||
不满足拒绝导出,退出码 2 并说明缺什么。退出码:0 成功;2 不可用/不完整;3 内部错误。
|
||||
不满足拒绝导出,退出码 2 并说明缺什么。事件简介缺失(档案未写事件内容)
|
||||
不挡导出,JSON 中如实反映(events 为空或 note 为 null)。
|
||||
换盘残留提示:导出时若 pending/ 中已有同出生日、同性别但盘 hash 不同的
|
||||
旧包(多为换盘/信息更正后的孤儿包),stderr 打 WARN 列出路径,提醒人工清理
|
||||
(只提示,不自动删)。
|
||||
退出码:0 成功;2 不可用/不完整;3 内部错误。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,6 +32,7 @@ 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"
|
||||
@@ -37,7 +45,7 @@ CATEGORIES: list[tuple[str, tuple[str, ...]]] = [
|
||||
("事业", ("事业", "工作", "升职", "跳槽", "调动", "失业", "创业", "辞职", "职务", "官非")),
|
||||
("财运", ("财运", "破财", "投资", "收入", "赚钱", "欠债", "债务", "置业", "买房")),
|
||||
("健康", ("健康", "生病", "手术", "伤病", "身体", "住院", "疾病", "意外")),
|
||||
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书")),
|
||||
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书", "大学")),
|
||||
("六亲", ("父亲", "母亲", "父母", "子女", "孩子", "兄弟", "姐妹", "六亲", "亲人", "丧")),
|
||||
("流年运势", ("流年", "运势", "大运", "运程")),
|
||||
]
|
||||
@@ -60,6 +68,9 @@ _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)]
|
||||
@@ -71,7 +82,7 @@ def _categorize(text: str) -> str:
|
||||
|
||||
|
||||
def _province_of(birthplace: str) -> str | None:
|
||||
"""地域脱敏:只保留到省级;识别不出省级则舍弃(宁缺毋滥)。"""
|
||||
"""地域脱敏:省级;识别不出省级则舍弃(宁缺毋滥)。"""
|
||||
for p in _PROVINCES:
|
||||
if p in birthplace:
|
||||
return p
|
||||
@@ -82,6 +93,42 @@ def _province_of(birthplace: str) -> str | None:
|
||||
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()
|
||||
@@ -129,22 +176,59 @@ def _parse_focus(body: str) -> list[str]:
|
||||
return cats
|
||||
|
||||
|
||||
def _parse_feedback(body: str) -> list[dict]:
|
||||
"""反馈事件:只取年份、类目、核实状态——反馈原文不入包。"""
|
||||
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
|
||||
status = "未核实"
|
||||
for tag, name in _STATUS_TAGS.items():
|
||||
if tag in t:
|
||||
status = name
|
||||
break
|
||||
m = re.search(r"(?:19|20)\d{2}", t)
|
||||
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})
|
||||
"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
|
||||
|
||||
|
||||
@@ -154,13 +238,18 @@ def chart_hash(pillars: list[str], birthplace: str) -> str:
|
||||
|
||||
|
||||
def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
|
||||
"""组装脱敏包;返回 (package, 缺失项列表)。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", ""))
|
||||
name = str(meta.get("name", ""))
|
||||
|
||||
pkg: dict = {"schema": SCHEMA,
|
||||
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
@@ -169,7 +258,8 @@ def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
|
||||
"pillars": pillars,
|
||||
"luck_pillars": _parse_luck(body),
|
||||
"focus_categories": _parse_focus(body),
|
||||
"feedback": _parse_feedback(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())
|
||||
@@ -180,6 +270,9 @@ def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
|
||||
province = _province_of(birthplace)
|
||||
if province:
|
||||
pkg["region_province"] = province
|
||||
city = _city_of(birthplace)
|
||||
if city:
|
||||
pkg["region_city"] = city
|
||||
|
||||
missing = []
|
||||
if not gender:
|
||||
@@ -210,7 +303,7 @@ def _find_archive(args) -> Path | None:
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="share_export.py",
|
||||
description="从 memory 档案导出结构化脱敏包(JSON)到 share/pending/;不含姓名、分钟、省以下地域")
|
||||
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="性别(检索条件)")
|
||||
@@ -249,6 +342,21 @@ def main() -> int:
|
||||
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)
|
||||
|
||||
+45
-20
@@ -9,7 +9,8 @@ ZIPING_SHARE_GITEA_REMOTE 覆盖)。两个远端逐个尝试,至少一个成
|
||||
|
||||
推送成功后:调 _license_gate.grant_uses(root, 10, 盘hash) 计励(同一盘只计一次),
|
||||
并把该包移入 share/pushed/。推送失败保留 pending,退出码 3。
|
||||
无 git 或无网络安静失败(不打栈)。
|
||||
无 git 或无网络安静失败(不打栈);远端推送失败时在 stderr 带该远端
|
||||
git 报错的末几行摘要,GitHub 线失败另附 HTTPS_PROXY 代理提示。
|
||||
|
||||
退出码:0 至少一包推送成功或无包可推;2 输入不可用;3 推送失败。
|
||||
"""
|
||||
@@ -26,6 +27,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
import _license_gate as gate
|
||||
|
||||
GRANT_USES = 10 # 每盘推送成功的计励次数
|
||||
@@ -39,24 +41,38 @@ def _git(*args: str, cwd: str | None = None) -> subprocess.CompletedProcess:
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
def _push_to_remote(remote: str, pkg: Path, workdir: str) -> bool:
|
||||
"""克隆远端 → 放入脱敏包 → commit+push。任一步失败返回 False(安静)。"""
|
||||
def _err_tail(cp: subprocess.CompletedProcess, n: int = 2) -> str:
|
||||
"""取 git 命令报错的末几行(stderr 优先,退而 stdout)。"""
|
||||
for text in (cp.stderr, cp.stdout):
|
||||
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
|
||||
if lines:
|
||||
return " / ".join(lines[-n:])
|
||||
return f"退出码 {cp.returncode}"
|
||||
|
||||
|
||||
def _push_to_remote(remote: str, pkg: Path, workdir: str) -> tuple[bool, str]:
|
||||
"""克隆远端 → 放入脱敏包 → commit+push。返回 (成功与否, 失败摘要)。"""
|
||||
try:
|
||||
if _git("clone", "--depth", "50", remote, workdir).returncode != 0:
|
||||
return False
|
||||
r = _git("clone", "--depth", "50", remote, workdir)
|
||||
if r.returncode != 0:
|
||||
return False, f"clone 失败:{_err_tail(r)}"
|
||||
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
|
||||
r = _git("add", pkg.name, cwd=workdir)
|
||||
if r.returncode != 0:
|
||||
return False, f"add 失败:{_err_tail(r)}"
|
||||
r = _git("-c", "user.name=ziping-share",
|
||||
"-c", "user.email=ziping-share@localhost",
|
||||
"commit", "--allow-empty", "-m", f"share: {pkg.stem}",
|
||||
cwd=workdir)
|
||||
if r.returncode != 0:
|
||||
return False, f"commit 失败:{_err_tail(r)}"
|
||||
r = _git("push", "origin", "HEAD:main", cwd=workdir)
|
||||
if r.returncode != 0:
|
||||
return False, f"push 失败:{_err_tail(r)}"
|
||||
return True, ""
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def _remotes(root: Path) -> list[tuple[str, str]]:
|
||||
@@ -106,13 +122,22 @@ def main() -> int:
|
||||
for pkg in packages:
|
||||
chart_hash = pkg.stem
|
||||
ok_remotes = []
|
||||
failures = []
|
||||
for name, remote in remotes:
|
||||
with tempfile.TemporaryDirectory(prefix="ziping-share-") as td:
|
||||
if _push_to_remote(remote, pkg, td):
|
||||
ok_remotes.append(name)
|
||||
ok, why = _push_to_remote(remote, pkg, td)
|
||||
if ok:
|
||||
ok_remotes.append(name)
|
||||
else:
|
||||
if name == "github_remote":
|
||||
why += (";若本机直连 GitHub 不通,可通过 HTTPS_PROXY "
|
||||
"环境变量配置代理")
|
||||
failures.append(f"{name}:{why}")
|
||||
for f in failures:
|
||||
print(f"WARN {pkg.name} {f}", file=sys.stderr)
|
||||
if not ok_remotes:
|
||||
print(f"FAIL {pkg.name}:所有远端推送失败(无网络或远端不可达),"
|
||||
f"pending 保留。", file=sys.stderr)
|
||||
print(f"FAIL {pkg.name}:所有远端推送失败,pending 保留。",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
granted = gate.grant_uses(root, GRANT_USES, chart_hash)
|
||||
shutil.move(str(pkg), str(pushed / pkg.name))
|
||||
|
||||
Reference in New Issue
Block a user