4 Commits
Author SHA1 Message Date
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
moxiaonian f725df8ff6 v3.5.1:自更新抓取回退系统 curl(修根证书缺失环境) 2026-10-09 22:34:37 +08:00
moxiaonian a62f2a7d74 新增 HTML 盘面渲染(cast_chart --html,模板 scripts/render/templates/paipan.html) 2026-10-09 22:23:38 +08:00
moxiaonian 4797702e62 share 双远端同时推送;数据仓接入 Gitea 受限上传账号 2026-10-09 22:05:59 +08:00
34 changed files with 1876 additions and 211 deletions
+3 -3
View File
@@ -1,14 +1,14 @@
# ZiPingEightChar · 子平八字(公众授权版) # ZiPingEightChar · 子平八字(公众授权版)
> 子平八字排盘 + 《子平真诠》格局法解释的 AI 技能(Skill)。 > 子平八字排盘 + 《子平真诠》格局法解释的 AI 技能(Skill)。
> 输入姓名、性别、出生日期时刻、出生地,输出结构化命盘 Markdown,并由模型依随附的 541 条编号规则完成取格、成败、用神相神、行运、六亲与流年流月解释。 > 输入姓名、性别、出生日期时刻、出生地,输出结构化命盘 Markdown,并由模型依随附的 542 条编号规则完成取格、成败、用神相神、行运、六亲与流年流月解释。
## 这是什么 ## 这是什么
这是一个面向 AI Agent 的技能包(Skill),由两部分组成: 这是一个面向 AI Agent 的技能包(Skill),由两部分组成:
- **排盘脚本(`scripts/`)**:纯计算器,不含任何命理判断。把出生钟表时间换算成真太阳时(经度修正 + 均时差 + 夏令时回拨),经子初换日定日历日,调用内置的 tyme4py 排出命盘,输出自包含 Markdown:四柱、地支藏干、空亡、纳音、神煞(子平通行查法、注明锚点)、节令、大运、小运(童限)、全量流年(出生年至今 +10 年、逐年标注所属大运)、流月及排盘审计;生于交节前后 12 小时内的盘自带节气边界提示。 - **排盘脚本(`scripts/`)**:纯计算器,不含任何命理判断。把出生钟表时间换算成真太阳时(经度修正 + 均时差 + 夏令时回拨),经子初换日定日历日,调用内置的 tyme4py 排出命盘,输出自包含 Markdown:四柱、地支藏干、空亡、纳音、神煞(子平通行查法、注明锚点)、节令、大运、小运(童限)、全量流年(出生年至今 +10 年、逐年标注所属大运)、流月及排盘审计;生于交节前后 12 小时内的盘自带节气边界提示。
- **规则库(`rules/`)**:541 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。 - **规则库(`rules/`)**:542 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。
- **命主档案(`memory/`)**:本地留存每位命主的排盘与解读归纳(含「已核实/已证伪/未核实」三态反馈记录),同一命主再看时查档延续。档案只存本机,不随副本分发。 - **命主档案(`memory/`)**:本地留存每位命主的排盘与解读归纳(含「已核实/已证伪/未核实」三态反馈记录),同一命主再看时查档延续。档案只存本机,不随副本分发。
## 快速开始 ## 快速开始
@@ -49,7 +49,7 @@ python scripts/cast_chart.py --name 张三 --gender 男 \
``` ```
SKILL.md 技能入口:用法、输出契约、解释规则、文风要求 SKILL.md 技能入口:用法、输出契约、解释规则、文风要求
rules/ 541 条编号解释规则(20 个文件) rules/ 542 条编号解释规则(20 个文件)
sources/ 五书原文归档(回查用) sources/ 五书原文归档(回查用)
tests/golden/ 原书命例黄金集 tests/golden/ 原书命例黄金集
scripts/ 排盘脚本(astro/ chart/ geo/ render/ 分层)与岁运神煞查询 scripts/ 排盘脚本(astro/ chart/ geo/ render/ 分层)与岁运神煞查询
+42 -15
View File
@@ -18,6 +18,15 @@ python scripts/cast_chart.py --name 张三 --gender 男 \
成功时命盘 Markdown 打印到 stdout,退出码 0。诊断信息只走 stderr。 成功时命盘 Markdown 打印到 stdout,退出码 0。诊断信息只走 stderr。
可选:加 `--html` 同步生成 HTML 盘面图(`--html 路径.html` 指定输出,省略路径则写 `./命盘_<姓名>.html`;生成路径打在 stderr):
```bash
python scripts/cast_chart.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市" --html
```
加 `--shot` 则在生成 HTML 的同时截图 PNG(playwright 优先、Chrome 无头兜底,见「盘面图」节),HTML 与 PNG 路径打在 stdout 末尾;截图工具不可用时只出 HTML 并附降级提示,不影响命盘输出。
另配岁运神煞查询工具(本命神煞已列于命盘 `## 神煞`,此工具查某流年/流月/大运/流日干支相对本命所逢之神煞): 另配岁运神煞查询工具(本命神煞已列于命盘 `## 神煞`,此工具查某流年/流月/大运/流日干支相对本命所逢之神煞):
```bash ```bash
@@ -27,7 +36,7 @@ python scripts/query_shensha.py --gender 女 \
## 运行环境 ## 运行环境
- **只需 Python ≥ 3.10,无需安装任何依赖。** tyme4py 1.5.0 与 tzdata 已内置在 `vendor/`(纯 Python / 纯数据,跨平台跨架构),脚本启动时自动优先加载,优先于环境中已安装的同名包。原理与升级方法见 `vendor/VENDOR.md`。 - **只需 Python ≥ 3.10,无需安装任何依赖。** tyme4py 1.5.0 与 tzdata 已内置在 `vendor/`(纯 Python / 纯数据,跨平台跨架构),脚本启动时自动优先加载,优先于环境中已安装的同名包。原理与升级方法见 `vendor/VENDOR.md`。解释器低于 3.10 时脚本拒绝运行:stderr 打印中文提示(说明所需版本与当前解释器路径)并以退出码 3 结束——macOS 系统自带 python3 为 3.9,不可用,请改用更新版本的 Python 运行。
- 开发模式回退:删除 `vendor/` 即改用环境安装的包(`requirements.txt` 锁定不变),两种方式输出一致。 - 开发模式回退:删除 `vendor/` 即改用环境安装的包(`requirements.txt` 锁定不变),两种方式输出一致。
## 输入参数(四个,全部必需) ## 输入参数(四个,全部必需)
@@ -41,14 +50,16 @@ python scripts/query_shensha.py --gender 女 \
**时刻是必需的。** 时辰决定时柱,差一个时辰就是另一张盘。用户只给了日期(「1990 年 5 月 14 日」)时,**必须追问具体时刻**,不要自行假设 12:00 或任何时刻。 **时刻是必需的。** 时辰决定时柱,差一个时辰就是另一张盘。用户只给了日期(「1990 年 5 月 14 日」)时,**必须追问具体时刻**,不要自行假设 12:00 或任何时刻。
## Intake 追问话术 ## Intake 对话式询问(强制)
输入不全或不可用时,按退出码与下列口径追问,语气温和、一次只问必要的项;多项缺失时合成一次提问,不逐条单独打断: 命理询问是分支式的——前一个回答决定下一个问题。**所有询问一律以普通对话消息进行,禁止调用结构化问卷/点选工具(AskUserQuestion 之类),也禁止在正文罗列编号问题清单让用户勾选。** 口吻是命理老师傅聊天:自然、简短、口语化。
- **缺时刻**:「时辰决定时柱,差一个时辰就是另一张盘,所以出生时间需要精确到小时。您大概几点出生?(如『早上 8 点半』『下午 3 点左右』均可)」若用户确实记不清:「那我按该时段的两个相邻时辰各排一张,并说明哪些结论会随时柱变化、哪些不受影响。」 - **节奏**:一次最多问 1~2 个最关键的问题,得到回答再决定下一个问题。缺多项时按 时刻 → 地点 → 性别 的优先级逐个补,不把整串问题一次甩给用户。
- **缺地点或地点无法解析**(退出码 2):「出生地用来换算真太阳时,差一个经度时辰就可能变。请告诉我您出生的城市或县(如『四川成都』『浙江义乌』)。」 - **先铺垫再问**:盘已排出时,提问前先用一两句不依赖提问即可断出的盘面信息做铺垫(日主、月令、显著特征),再自然带出问题。例如:「这盘甲木生腊月,底子偏寒——先问一句:十来岁那年是不是住过院?」出生信息还不全、盘未排出时没有铺垫可用,直接问缺项即可。
- **缺性别**:「性别决定大运顺排还是逆排,请补充一下性别。」 - **缺时刻**:「时辰决定时柱,差一个时辰就是另一张盘,出生时间得精确到小时。大概几点出生?(『早上 8 点半』『下午 3 点左右』都行)」若用户确实记不清:「那我按该时段的两个相邻时辰各排一张,哪些结论会随时柱变化、哪些不受影响,分开说明。」
- **场景判定**(断运、回溯类问题先问):「这盘是您本人看,还是替别人看?本人或知情人在场、能核对以往的事吗?」——定人称、定能否走核对轮(R-运-33)、定事件候选的颗粒度(命主本人可核到月份与部位,代看通常只能核到年份)。与缺项追问合成一次提问,不单独多占一轮。 - **缺地点或地点无法解析**(退出码 2):「出生地用来换算真太阳时,差一个经度时辰就可能变。生在哪个城市或县?(如『四川成都』『浙江义乌』)」
- **缺性别**:「性别决定大运顺排还是逆排,补一下性别。」
- **场景判定**(断运、回溯类问题先问):「这盘是您本人看,还是替别人看?本人或知情人在场、能核对以往的事吗?」——定人称、定能否走核对轮(R-运-33)、定事件候选的颗粒度(命主本人可核到月份与部位,代看通常只能核到年份)。
- **时间格式错误**(退出码 2):照 stderr 提示转述,不自行猜测改写。 - **时间格式错误**(退出码 2):照 stderr 提示转述,不自行猜测改写。
- **库内部错误**(退出码 3):把 stderr 原样报告给用户,**不要猜盘**,不要凭印象编造四柱。 - **库内部错误**(退出码 3):把 stderr 原样报告给用户,**不要猜盘**,不要凭印象编造四柱。
- **授权不可用**(退出码 4):把 stderr 原样转述给用户(次数用尽 / 已到期 / 授权文件被改动)。**不要尝试绕过或猜测授权状态**;需要续期请用户联系授权方(见 LICENSE.md)。 - **授权不可用**(退出码 4):把 stderr 原样转述给用户(次数用尽 / 已到期 / 授权文件被改动)。**不要尝试绕过或猜测授权状态**;需要续期请用户联系授权方(见 LICENSE.md)。
@@ -59,7 +70,7 @@ python scripts/query_shensha.py --gender 女 \
|---|---|---| |---|---|---|
| 0 | 成功 | 读 stdout 的 Markdown,进入解释 | | 0 | 成功 | 读 stdout 的 Markdown,进入解释 |
| 2 | 输入不可用(出生地无法解析 / 时间格式错误或缺时刻) | 读 stderr 的提示,**向用户追问**后重试 | | 2 | 输入不可用(出生地无法解析 / 时间格式错误或缺时刻) | 读 stderr 的提示,**向用户追问**后重试 |
| 3 | 库内部错误 | 把 stderr 内容原样报告给用户。**不要猜盘**,不要凭印象编造四柱 | | 3 | 库内部错误 / 运行环境不符(Python < 3.10) | 把 stderr 内容原样报告给用户。**不要猜盘**,不要凭印象编造四柱 |
| 4 | 授权不可用(软锁:次数用尽 / 已到期 / 授权文件被改动) | 把 stderr 原样转述给用户,**不要绕过** | | 4 | 授权不可用(软锁:次数用尽 / 已到期 / 授权文件被改动) | 把 stderr 原样转述给用户,**不要绕过** |
## 授权与分发 ## 授权与分发
@@ -96,13 +107,13 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
- **节气边界提示**:出生真太阳时距交节不足 12 小时时,命盘开头出现 ⚠️ 提示。月令对出生记录误差敏感,误差超过提示值则月令改变、四柱全变;解释须声明此敏感性,断运证伪回查(R-运-33 第 4 步)时重排另一月令之盘对照。 - **节气边界提示**:出生真太阳时距交节不足 12 小时时,命盘开头出现 ⚠️ 提示。月令对出生记录误差敏感,误差超过提示值则月令改变、四柱全变;解释须声明此敏感性,断运证伪回查(R-运-33 第 4 步)时重排另一月令之盘对照。
- **夏令时**:1986–1991 年全国夏令时期间(每年 4 月中旬至 9 月中旬)出生者,钟表时间先回拨 1 小时再作后续修正——「钟表 23 点 ≠ 真太阳时 23 点」,是否晚子时以真太阳时为准(「排盘审计」段有「夏令时:是/否」)。 - **夏令时**:1986–1991 年全国夏令时期间(每年 4 月中旬至 9 月中旬)出生者,钟表时间先回拨 1 小时再作后续修正——「钟表 23 点 ≠ 真太阳时 23 点」,是否晚子时以真太阳时为准(「排盘审计」段有「夏令时:是/否」)。
## 解释规则(rules/ 目录,库版本 3.5) ## 解释规则(rules/ 目录,库版本 3.6)
取格、用神、断运等解释规则在 `rules/` 下,共 20 个文件、542 条编号规则,以《子平真诠》格局法为骨架,并蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》四书为格局定论后的参看层(性情/事类/六亲女命增补、调候、神煞书证、旺衰扶抑、童限;拓展条均逐条标注【拓展】);`rules/sources/` 按书归档五书原书全文可供回查,书目登记见 `rules/sources/README.md`。格式规范与锚点约定见 `rules/README.md`。 取格、用神、断运等解释规则在 `rules/` 下,共 20 个文件、542 条编号规则,以《子平真诠》格局法为骨架,并蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》四书为格局定论后的参看层(性情/事类/六亲女命增补、调候、神煞书证、旺衰扶抑、童限;拓展条均逐条标注【拓展】);`rules/sources/` 按书归档五书原书全文可供回查,书目登记见 `rules/sources/README.md`。格式规范与锚点约定见 `rules/README.md`。
**执行顺序**: **执行顺序**:
0. **问题归类**:判定用户所问的主线(健康/感情/财运/事业/学业/六亲)、前瞻或回溯、以及场景(命主本人/替人看、能否核对——见 Intake 场景判定)。次级关注(隐藏线)只作附带:输出时以一条「顺带看了 X 这条线」声明并简答,不铺成第二主线; 0. **问题归类**:判定用户所问的主线(健康/感情/财运/事业/学业/六亲)、前瞻或回溯、以及场景(命主本人/替人看、能否核对——见「Intake 对话式询问」场景判定)。次级关注(隐藏线)只作附带:输出时以一条「顺带看了 X 这条线」声明并简答,不铺成第二主线;
1. 读命盘 Markdown,加载 `rules/00_总纲_取格.md`,按 §1 六步决策流程定格; 1. 读命盘 Markdown,加载 `rules/00_总纲_取格.md`,按 §1 六步决策流程定格;
2. 命中后加载对应分格文件(`rules/11_正官格.md` … `rules/19_杂格.md`),判成/败/救/应、用神相神、格局高低; 2. 命中后加载对应分格文件(`rules/11_正官格.md` … `rules/19_杂格.md`),判成/败/救/应、用神相神、格局高低;
3. 断运时加载 `rules/90_行运.md`(§7 断运操作序列定喜忌应期,§8 事类落地把关键流年译成现实事件候选,§9 断年核对回路,§10 童限)与分格文件的「取运」节;覆盖多个流年的批量断运按 R-运-33 **核对前置**——先以场景判定分流,可核对者先锚点核对、不可核对者直推,输出只出现一次。**推断任一具体流年/流月前,必须按 R-运-34 五步核对重新打开规则文件查表,不得凭会话记忆推断**;编号记录只列本轮实际查阅的条目(R-运-35),收调试层(见「调试与溯源」); 3. 断运时加载 `rules/90_行运.md`(§7 断运操作序列定喜忌应期,§8 事类落地把关键流年译成现实事件候选,§9 断年核对回路,§10 童限)与分格文件的「取运」节;覆盖多个流年的批量断运按 R-运-33 **核对前置**——先以场景判定分流,可核对者先锚点核对、不可核对者直推,输出只出现一次。**推断任一具体流年/流月前,必须按 R-运-34 五步核对重新打开规则文件查表,不得凭会话记忆推断**;编号记录只列本轮实际查阅的条目(R-运-35),收调试层(见「调试与溯源」);
@@ -127,6 +138,8 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
> 以上内容由灵爻妙解依据古籍《子平真诠》制作技能,ai生成,仅供娱乐参考,不替代医疗、法律、投资、心理咨询等任何专业意见。灵爻妙解,传承易学智慧,欢迎访问 https://lingyaomiaojie.com > 以上内容由灵爻妙解依据古籍《子平真诠》制作技能,ai生成,仅供娱乐参考,不替代医疗、法律、投资、心理咨询等任何专业意见。灵爻妙解,传承易学智慧,欢迎访问 https://lingyaomiaojie.com
**共建提示(首次讲解后)**:本次会话的第一次完整讲解(第 1–6 段全部输出完毕)之轮,在免责引用块**之前**顺带一句共建提示——第 6 段引用块保持一字不改、仍在全轮末尾;**同一会话只提示一次**,后续问答轮不再重复。口吻延续老师傅人设,自然一句带过,不写成广告,参考:「这盘先看到这。若愿意为这门手艺出份力——会话结束后直接发我『推送』二字,会把脱敏后的盘面存入共建档案库,用来校准后续版本;推与不推,都不影响看盘。」用户回应「推送」(或「推送黄金集」等明确意图)后,进入「档案共建(share/)」节既有流程:导出过目 → 授权问答 → 推送授权话术 → 推送计励。
**★边界声明的次数上限**:单轮回答中「这一层我给不出 / 这不是干这个的 / 硬报数字就是忽悠」型的方法边界说明**至多出现一次**,且必须并入第 5 段,不得另起一段、不得打断解读节奏。其余场合一律改用模糊语自然带过(见「文风」节)。声明本身不得写成辩解或自嘲。 **★边界声明的次数上限**:单轮回答中「这一层我给不出 / 这不是干这个的 / 硬报数字就是忽悠」型的方法边界说明**至多出现一次**,且必须并入第 5 段,不得另起一段、不得打断解读节奏。其余场合一律改用模糊语自然带过(见「文风」节)。声明本身不得写成辩解或自嘲。
**阶段纪律**:落笔之前遇下列情形,**必须先向用户声明分歧再继续**—— **阶段纪律**:落笔之前遇下列情形,**必须先向用户声明分歧再继续**——
@@ -141,10 +154,10 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
## 断运交互(核对前置) ## 断运交互(核对前置)
用户问「过往发生了什么 / 哪几年是转折」这类覆盖多个流年的问题时,按 `rules/90_行运.md` R-运-33 执行**核对前置**——所有提问发生在铺开断语之前,输出只出现一次: 用户问「过往发生了什么 / 哪几年是转折」这类覆盖多个流年的问题时,按 `rules/90_行运.md` R-运-33 执行**核对前置**——所有提问发生在铺开断语之前,输出只出现一次。核对同样走对话式询问(见「Intake 对话式询问」):不用结构化工具、不列编号选项,一次最多问一两个锚点,答完再定下一个;R-运-33 的核对程序(锚点选取、候选排序、证伪回查)照旧执行。
1. **先定场景**:intake 一问分流(命主本人还是替人看、能否核对过往),与缺项追问合成一次提问; 1. **先定场景**:intake 一问分流(命主本人还是替人看、能否核对过往);
2. **可核对 → 锚点核对**:挑 3 个左右锚点年(组数最多、落在人生阶段窗口、用户所问之线各占其一),每个锚点年一个问题,选项为按可能性排序的 2–3 个候选事件、外加「都对不上」(证伪入口);几个锚点年合成一次结构化提问,不逐条单独打断。断语涉及学业年份时,学制锚点并入同一次提问,不单独多问一轮。核对后按 R-运-32 第 4 步收敛,一次性输出完整断运。无询问工具的环境,在正文列出编号选项请用户回复编号; 2. **可核对 → 锚点核对**:挑 3 个左右锚点年(组数最多、落在人生阶段窗口、用户所问之线各占其一)。每问先给铺垫——该年盘上的显著信号一两句——再自然带出按可能性排序的 2–3 个候选事件,外加「都对不上」(证伪入口);用户答完再挑下一个锚点。断语涉及学业年份时,学制锚点顺嘴并入当轮提问,不单独多问一轮。核对后按 R-运-32 第 4 步收敛,一次性输出完整断运;
3. **不可核对 → 直推**:命主不在场、不知情,或用户明言测试、「不用问、直接说」时,跳过核对,一次性输出最佳推断——直答给主答案与置信度,全量候选收分析推理段。 3. **不可核对 → 直推**:命主不在场、不知情,或用户明言测试、「不用问、直接说」时,跳过核对,一次性输出最佳推断——直答给主答案与置信度,全量候选收分析推理段。
锚点全部落空时先回头查组数判定、阶段假设、时辰边界与节气边界,不硬铺(R-运-33 第 4 步)。 锚点全部落空时先回头查组数判定、阶段假设、时辰边界与节气边界,不硬铺(R-运-33 第 4 步)。
@@ -182,15 +195,19 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
`python scripts/memory_lookup.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男` `python scripts/memory_lookup.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男`
(亦可按 `--name` / `--pillars "丁亥 乙巳 庚子 辛巳"` 任意组合检索)。有档则读档延续——此前的归纳、已核实/已证伪反馈在分析推理时直接参用,已证伪的断法不得再断;无档则正常排盘解释,解释完成后用 `python scripts/memory_save.py --name ... --gender ... --birth ... --place ...` 建档(建档即一次排盘,授权副本照常扣次)。 (亦可按 `--name` / `--pillars "丁亥 乙巳 庚子 辛巳"` 任意组合检索)。有档则读档延续——此前的归纳、已核实/已证伪反馈在分析推理时直接参用,已证伪的断法不得再断;无档则正常排盘解释,解释完成后用 `python scripts/memory_save.py --name ... --gender ... --birth ... --place ...` 建档(建档即一次排盘,授权副本照常扣次)。
- **回写纪律**:用户核对或澄清了任何断语后,当轮把结果写回该档案的 `## 反馈与澄清` 小节,每条以状态标签开头——推测性归纳标【未核实】,命主确认改【已核实】,被否改【已证伪】并补一句实际情况;结构性结论同步更新 `## 归纳` 各行。 - **回写纪律**:用户核对或澄清了任何断语后,当轮把结果写回该档案的 `## 反馈与澄清` 小节,每条以状态标签开头——推测性归纳标【未核实】,命主确认改【已核实】,被否改【已证伪】并补一句实际情况;结构性结论同步更新 `## 归纳` 各行。
- **换盘纪律(工作盘唯一 + 变更留痕)**:经用户确认换盘(日界规则分歧、时辰/出生地校正等)后,**必须当轮重排并把档案 `## 盘面` 段整体替换为新盘**——档案任何时候只有一个工作盘,旧盘不得留在盘面段,防止续推时误用。操作:
`python scripts/memory_save.py --update-chart <档案路径> [--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后地点"]`
(重排一次,授权副本照常扣次;frontmatter 四柱/日主同步为新盘,四柱变动时文件自动改为新主键名;目标档案已存在则拒绝覆盖)。留痕:旧盘四柱、换盘原因、确认方式与时间记入 `## 反馈与澄清` 校订条目;凡受变动柱(如日柱/时柱)影响的已有推断条目(`## 归纳`、`## 流年大事` 各行),同轮标记【待复核】,复核前不得继续引用。
- **隐私**:档案属命主个人信息,只存本机、不随授权副本分发、档案原文不进 share;不得把档案原文外发(不粘贴到外部对话、不上传网络、不写入其他文件)。回传授权方的只有按下节规格生成的结构化脱敏包,且必须经用户授权。 - **隐私**:档案属命主个人信息,只存本机、不随授权副本分发、档案原文不进 share;不得把档案原文外发(不粘贴到外部对话、不上传网络、不写入其他文件)。回传授权方的只有按下节规格生成的结构化脱敏包,且必须经用户授权。
## 档案共建(share/) ## 档案共建(share/)
用户可自愿把**脱敏后**的命盘数据回传授权方,用于技能优化与规则蒸馏(脱敏规格、隐私协议与撤回方式见 `share/README.md`)。每成功推送一盘,本副本授权次数 **+10**;同一盘(按盘 hash)只计一次,不重复加次。 用户可自愿把**脱敏后**的命盘数据回传授权方,用于技能优化与规则蒸馏(脱敏规格、隐私协议与撤回方式见 `share/README.md`)。每成功推送一盘,本副本授权次数 **+10**;同一盘(按盘 hash)只计一次,不重复加次。
- **导出**:`python scripts/share_export.py --archive <档案路径>`(或按 `--birth/--place/--gender` 检索定位),生成结构化 JSON 脱敏包到 `share/pending/<盘hash>.json`。完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态),不满足则拒绝导出(退出码 2)。**未核实推测不得入包**:归纳小节的推测性结论一律不导出;反馈事件(带核实状态标签)可入包,自由文本(反馈原文、盘面细节、归纳文字)一律不入。 - **导出**:`python scripts/share_export.py --archive <档案路径>`(或按 `--birth/--place/--gender` 检索定位),生成结构化 JSON 脱敏包到 `share/pending/<盘hash>.json`。完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态),不满足则拒绝导出(退出码 2);事件简介缺失(档案未写事件内容)不挡导出,包内如实反映。**未核实推测不得入包**:归纳小节的推测性结论一律不导出;反馈事件与流年大事(带核实状态标签)连同事件简介(note)可入包。脱敏纪律:姓名一律删除;出生信息只留年月日与时辰;地域保留到城市级(省+地级市,区县及以下删除);事件简介中**城市级地名保留**(方位信息供规则蒸馏定位),真实姓名、详细住址(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除——脚本自动脱敏,书写事件简介时也只写到城市级;归纳文字、盘面细节等其余自由文本一律不入。**换盘清理纪律**:换盘/出生信息重大更正后重新导出前,先清理 `share/pending/` 中同一命主的旧 hash 包——盘 hash 随四柱/出生地变化,旧包即成孤儿,可能被误推;share_export.py 导出时检测到疑似残留(同出生日同性别、盘 hash 不同)会打 WARN 提示,确认后手动删除。
- **授权询问(强制)**:推送前必须先把脱敏包**全文展示**给用户,并询问是否授权推送——用户同意才执行推送;不同意则取消,脱敏包保留在 `share/pending/`(用户可随时删除),不影响技能正常使用。 - **授权询问(强制)**:推送前必须先把脱敏包**全文展示**给用户,并询问是否授权推送——用户同意才执行推送;不同意则取消,脱敏包保留在 `share/pending/`(用户可随时删除),不影响技能正常使用。
- **推送**:`python scripts/share_push.py`,把 `share/pending/` 的包推送到 `share/config.json` 配置的共建远端(GitHub / Gitea 逐个尝试,至少一个成功即算成功;远端地址由授权方填写)。推送成功后包移入 `share/pushed/`,并告知用户新增授权次数(+10;该盘已计励过则不重复加)。推送失败(无 git / 无网络 / 远端不可达)保留 pending,不丢数据。 - **推送**:`python scripts/share_push.py`,把 `share/pending/` 的包推送到 `share/config.json` 配置的共建远端(GitHub 与 Gitea **两条线都推**,至少一个成功即算成功;远端地址由授权方填写)。推送成功后包移入 `share/pushed/`,并告知用户新增授权次数(+10;该盘已计励过则不重复加)。推送失败(无 git / 无网络 / 远端不可达)保留 pending,不丢数据。
- **推送授权话术(宿主权限层)**:调用推送脚本时宿主会请求执行权限,请用户确认——**「点一下允许」是唯一主路径**。① **推送前先预告**:得到用户口头授权后、调用脚本前,先发一句话预告,如「接下来运行推送脚本,你的环境可能弹出执行权限确认,点『允许』即可」;② **被拦即停、零重试**:脚本被权限层拦截后不得重试(权限状态不会因重试改变),直接向用户说明并问「是否看到权限确认?点允许我就能继续」;③ 终端手动执行 `python scripts/share_push.py` 仅作为宿主静默拦截(不弹确认、直接拒绝)时的兜底,向用户提及时一句话带过,**不列为推荐步骤**;④ **话术宿主中立**:不写死任何具体宿主的权限界面名称,统一表述为「宿主会请求执行权限,请用户确认」。
## 版本与自更新 ## 版本与自更新
@@ -198,6 +215,16 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
- **更新提示(缓存制、不打扰排盘)**:每次排盘成功后,若本地缓存的检查结果已过期(24 小时)则后台线程拉一次远端 VERSION 刷新缓存(超时 3 秒、失败静默,主流程不等);缓存显示有新版本时,stderr 提示一行「技能有新版本 X.Y.Z,运行 `python scripts/self_update.py --apply` 可更新」。 - **更新提示(缓存制、不打扰排盘)**:每次排盘成功后,若本地缓存的检查结果已过期(24 小时)则后台线程拉一次远端 VERSION 刷新缓存(超时 3 秒、失败静默,主流程不等);缓存显示有新版本时,stderr 提示一行「技能有新版本 X.Y.Z,运行 `python scripts/self_update.py --apply` 可更新」。
- **应用更新(须用户同意)**:用户说「更新技能」时再运行 `python scripts/self_update.py --apply`——从 GitHub 拉取(失败转 Gitea),**先按 manifest.json 逐文件校验 SHA256、任一不符即中止不动本地**,通过后覆盖更新;`data/.license.json`、`memory/archives/`、`share/pending/`、`share/pushed/`、`.git` 保留不动。不得替用户自动执行 --apply。 - **应用更新(须用户同意)**:用户说「更新技能」时再运行 `python scripts/self_update.py --apply`——从 GitHub 拉取(失败转 Gitea),**先按 manifest.json 逐文件校验 SHA256、任一不符即中止不动本地**,通过后覆盖更新;`data/.license.json`、`memory/archives/`、`share/pending/`、`share/pushed/`、`.git` 保留不动。不得替用户自动执行 --apply。
## 盘面图(HTML,默认生成)
拿到出生信息排盘后,**默认立即生成盘面图、截图并发给用户**,然后才进入对话式核对与分析。流程次序:排盘(`cast_chart.py --shot`)→ 生成盘面图并发送 → 对话式核对/补缺 → 分析 → 结论。
- **生成**:`--shot` 一步完成——stdout 出命盘 Markdown,同目录出 HTML 盘面图与 PNG 截图(路径打在 stdout 末尾;`--html 路径` 可另指定 HTML 位置)。截图分级回退:环境装有 playwright 用 playwright(file:// 全页、2 倍分辨率),否则用本机 Chrome 无头截图;两者都不可用只出 HTML 并附降级提示,不影响排盘。也可单独调 `scripts/chart_shot.py <html路径>` 补截图。
- **发给用户**:以宿主支持的方式把盘面图发给用户(图片引用或文件链接);截图不可用时给出 HTML 文件路径并说明可直接打开查看。
- **重排必重生**:出生信息有任何变动(校正时辰、节气边界确认、晚子时分歧对照等)重排时,必须重新生成盘面图与截图,不得沿用旧图。
- 盘面内容:四柱主表(当前大运与流年并列对照)、大运/小运条、全量流年、当年流月,五行配色、空亡标虚线、节气边界与晚子时出警示条。模板在 `scripts/render/templates/paipan.html`,样式可直接改。
- HTML 只是 Markdown 命盘的图形化重渲染,内容以 stdout 命盘为准;解释时引用的字段名仍以 Markdown 章节为准。
## 文风与反 AI 腔(强制) ## 文风与反 AI 腔(强制)
命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止: 命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止:
+1 -1
View File
@@ -1 +1 @@
3.5.0 3.6.0
+34 -23
View File
@@ -2,13 +2,13 @@
"files": { "files": {
".gitignore": "1e4671dee2d3123a45609ed82a4329531e0d41bf1b6895ab169ecd8813cfb8f5", ".gitignore": "1e4671dee2d3123a45609ed82a4329531e0d41bf1b6895ab169ecd8813cfb8f5",
"LICENSE.md": "6b839540e0fd4ab75530ec31fc5c3cbc3c2d5de5fe85068ef1c0901e54be9315", "LICENSE.md": "6b839540e0fd4ab75530ec31fc5c3cbc3c2d5de5fe85068ef1c0901e54be9315",
"README.md": "1e11e5d19856fbd9bb15068c74e184617a10b2e0b18c7e42d9b617cd0d8f534a", "README.md": "adb4481f20360c66e67fd89076c1535c577cdf85f34122d2dac1ff727dd81c38",
"SKILL.md": "8b493ae2236b5941c88e282d4793126487e4d81a6f3c24976020fb26e50cf67a", "SKILL.md": "720aa2de6e7e6f65c44f63421f0aece401146921e54a6c99f24db1af14241813",
"VERSION": "76120c1f2c4311c2154c22a6b58e00e411e0ebe76afae0daf608d9196c61f114", "VERSION": "138203e3f604a08fad0c4d0a297efe2c486219820dcdd053e72b7d843504c7a1",
"data/.public_edition.json": "e8692c45d512c78903fc89a924d0e6d4dc9b95a78ca6b359bb7a5ef5c946ffee", "data/.public_edition.json": "e8692c45d512c78903fc89a924d0e6d4dc9b95a78ca6b359bb7a5ef5c946ffee",
"data/cities.json": "00f17905c8843218162e25238a78aeb4c4a3bb5424b611cc30ed9e436db31854", "data/cities.json": "00f17905c8843218162e25238a78aeb4c4a3bb5424b611cc30ed9e436db31854",
"memory/README.md": "b3cf2c58a8418a8c1a54243c2e09fbdb42f31dcd48ba8c841b74f1234c7fe063", "memory/README.md": "a979f985dd87e87bfca49676ebf10a537d299b1eeebac121c506c320c88a3afd",
"pyproject.toml": "58e415e53d2418721fa07bd81b829e4bc5bbfb2155a446bce6222d3f7afeda12", "pyproject.toml": "d71541e952e8b1650a157bf17d2bed9946052df9f6647032218ca272973d93b4",
"requirements.txt": "46a4c971b192cbf2c4972dd0f420e7928b7e44cd3cb9f7dc5a36efb321ab6fd6", "requirements.txt": "46a4c971b192cbf2c4972dd0f420e7928b7e44cd3cb9f7dc5a36efb321ab6fd6",
"rules/00_总纲_取格.md": "829a35454f5c91966ecafe4dd2113b913588933efe08d0e1c127ae82ccec0da8", "rules/00_总纲_取格.md": "829a35454f5c91966ecafe4dd2113b913588933efe08d0e1c127ae82ccec0da8",
"rules/11_正官格.md": "385193d93dfba89a935ca8907098f70548ad1fc12ab6a07a5280fd04224c83b6", "rules/11_正官格.md": "385193d93dfba89a935ca8907098f70548ad1fc12ab6a07a5280fd04224c83b6",
@@ -21,7 +21,7 @@
"rules/18_建禄月劫格.md": "f0b400292d22cb3b38ab5011bda8080d511a065c661305f7a9c0485f1c57705c", "rules/18_建禄月劫格.md": "f0b400292d22cb3b38ab5011bda8080d511a065c661305f7a9c0485f1c57705c",
"rules/19_杂格.md": "78939bc9d61621a427fd41a31156086b3ae6675eccc77c899a3aa7443408ef26", "rules/19_杂格.md": "78939bc9d61621a427fd41a31156086b3ae6675eccc77c899a3aa7443408ef26",
"rules/89_旺衰扶抑.md": "758180d5ee0aa46f4b22e8817baa831974c9c3b239078ba51e0a7b9016f7f47b", "rules/89_旺衰扶抑.md": "758180d5ee0aa46f4b22e8817baa831974c9c3b239078ba51e0a7b9016f7f47b",
"rules/90_行运.md": "510c243e84f3cffacf0a85887b8cc8650bb009083112390c50d5f8ab8c969587", "rules/90_行运.md": "bacb9d99d2c1b117b0a9c297712068f3d41c73aa210ee01d2d2754d017bdf438",
"rules/91_何知事类.md": "3775448e1830929dbcbc90cacf1065a4ab0592c9c4ee984c09ec187911bcf2c1", "rules/91_何知事类.md": "3775448e1830929dbcbc90cacf1065a4ab0592c9c4ee984c09ec187911bcf2c1",
"rules/92_调候.md": "8d37d2cb722ae4b581532b7b73218fd648f1e75d8e16c6bedbabe0f93a2e1946", "rules/92_调候.md": "8d37d2cb722ae4b581532b7b73218fd648f1e75d8e16c6bedbabe0f93a2e1946",
"rules/93_性情.md": "c9d3003cf4e0869e5075623e34bad41faa294623df1b2481e0f30b80fc61cec6", "rules/93_性情.md": "c9d3003cf4e0869e5075623e34bad41faa294623df1b2481e0f30b80fc61cec6",
@@ -30,7 +30,7 @@
"rules/96_六亲.md": "96cf362bd180558352d0181401ac8e297ac89c09d91d1098972a9d1c40468198", "rules/96_六亲.md": "96cf362bd180558352d0181401ac8e297ac89c09d91d1098972a9d1c40468198",
"rules/97_女命.md": "52962aa666faa3fa2a5175d352380df14f54fefb8f57cfe0761779ad51e18696", "rules/97_女命.md": "52962aa666faa3fa2a5175d352380df14f54fefb8f57cfe0761779ad51e18696",
"rules/99_附录_干支通例.md": "da906502391833eb1744ff0145a03c7d1e10781ac7156034632dc5994da0d2f4", "rules/99_附录_干支通例.md": "da906502391833eb1744ff0145a03c7d1e10781ac7156034632dc5994da0d2f4",
"rules/README.md": "80a77a744fcac31673dd7eb9f84cb80b83c9d61c4d0aaabcb39e86f98f661c2f", "rules/README.md": "27118f8338549ae4f92d1c9ed66e6bea82e028f2a5f68769bc43e08770857b51",
"rules/sources/README.md": "183ba554b1d89c16b19fb65ac8a4d0614069c865676fb4c1bd20d95b5581c209", "rules/sources/README.md": "183ba554b1d89c16b19fb65ac8a4d0614069c865676fb4c1bd20d95b5581c209",
"rules/sources/三命通会/00_卷一·原造化之始.md": "6329058dd08aed5f41d1111da99027cac38761aeea7120a84eff02683426294d", "rules/sources/三命通会/00_卷一·原造化之始.md": "6329058dd08aed5f41d1111da99027cac38761aeea7120a84eff02683426294d",
"rules/sources/三命通会/01_卷一·论五行生成.md": "c9db87683a6d3f9b7eea665e3814e383e11a69b608114d9317beb2a2c56825f8", "rules/sources/三命通会/01_卷一·论五行生成.md": "c9db87683a6d3f9b7eea665e3814e383e11a69b608114d9317beb2a2c56825f8",
@@ -565,41 +565,52 @@
"rules/tests/golden/97_女命.yaml": "dc229eae558be6592f392be4f71e72ec6650d64590c9bac3361febed9ead9b89", "rules/tests/golden/97_女命.yaml": "dc229eae558be6592f392be4f71e72ec6650d64590c9bac3361febed9ead9b89",
"scripts/_license_gate.py": "4f3a24685c86abc6c66a158b441b89e2738bd14f5d63e8bdd41489654a29e837", "scripts/_license_gate.py": "4f3a24685c86abc6c66a158b441b89e2738bd14f5d63e8bdd41489654a29e837",
"scripts/_memory_store.py": "690e088a208ee5aeb9f66e6d4b5d4342f44bfc852bad51870cdfe397fbdf7660", "scripts/_memory_store.py": "690e088a208ee5aeb9f66e6d4b5d4342f44bfc852bad51870cdfe397fbdf7660",
"scripts/_runtime_guard.py": "c4f9d00acbb5b2ba49680b23ed4f846c60211e67b9d58950079b3e8909dcfe81",
"scripts/astro/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "scripts/astro/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"scripts/astro/day_boundary.py": "5dbd942f08a009400c2fbc60faa77c9d9a0c315d01e0cc6edf3d7b2efc09d2c3", "scripts/astro/day_boundary.py": "b5a24b3937fbcd9c278331b5b19f8cf708acc987f6f6f97f346524786735af9a",
"scripts/astro/equation_of_time.py": "ee9331022b4ed35d407664e97b5ce9ce99167a06ce7cb8c9ffba5497a437da8e", "scripts/astro/equation_of_time.py": "ee9331022b4ed35d407664e97b5ce9ce99167a06ce7cb8c9ffba5497a437da8e",
"scripts/astro/solar_time.py": "35b45edc365083a6ce90afe076176c6f3e798509e76d3f03819fd1845491b153", "scripts/astro/solar_time.py": "35b45edc365083a6ce90afe076176c6f3e798509e76d3f03819fd1845491b153",
"scripts/astro/timezone_resolve.py": "3a287a64bd50736ce91ac15876e88edeb98745eb6d0459cf18e7e6ee3c026aba", "scripts/astro/timezone_resolve.py": "3a287a64bd50736ce91ac15876e88edeb98745eb6d0459cf18e7e6ee3c026aba",
"scripts/cast_chart.py": "bc08b53ab50f7d9e51b4d7e84deb3c2660040c025dd03c578f7fd51aeba6f315", "scripts/cast_chart.py": "9b9f33cfae9af797859554c630529d8fc5c27c5688a0414fbc5038ae2e5c90d8",
"scripts/chart/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "scripts/chart/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"scripts/chart/ganzhi.py": "94d53d2a8cd7674312223e976ce6e1379a6fbb53873253b34963b3e3d645508e",
"scripts/chart/luck.py": "d930f3f402cf814eaaa10fdcaa0a8be4a47411e6961dd0324ac38a8f898b4299", "scripts/chart/luck.py": "d930f3f402cf814eaaa10fdcaa0a8be4a47411e6961dd0324ac38a8f898b4299",
"scripts/chart/model.py": "8279145da10901cfb1a7b4823762074169dfd554bbe8ce39ae15862334b001f6", "scripts/chart/model.py": "8279145da10901cfb1a7b4823762074169dfd554bbe8ce39ae15862334b001f6",
"scripts/chart/shensha.py": "1a9a59fa35ad5f2e2700d6b1a0155979dd7bf6f9fea4637356bfd41c5eae7e05", "scripts/chart/shensha.py": "9db4a0e4cfaaa667efa9bbdcf7b8fd3d0c461cafbdf125e037cb467d75803939",
"scripts/chart/tyme_adapter.py": "06a46f16efc3d745edb5f35bbdcfee1c78552a44235fc85de0af3eb6c163eaca", "scripts/chart/tyme_adapter.py": "06a46f16efc3d745edb5f35bbdcfee1c78552a44235fc85de0af3eb6c163eaca",
"scripts/chart_shot.py": "0096a01e64128a2628ee0e2d3bc211d93fbb0e2b17a49a34f4f2f9a37da4960c",
"scripts/geo/__init__.py": "1211b885ec767b399472df572f970b07d6d028a5889af49fe50f6fc1a2a0cf7d", "scripts/geo/__init__.py": "1211b885ec767b399472df572f970b07d6d028a5889af49fe50f6fc1a2a0cf7d",
"scripts/geo/cities.py": "9d393608c03a2ad5c7352939e70ea717dae5dd4bc833dfdd0e2f663940c61fb5", "scripts/geo/cities.py": "9d393608c03a2ad5c7352939e70ea717dae5dd4bc833dfdd0e2f663940c61fb5",
"scripts/memory_lookup.py": "853c815a97b457ca20d5b4c03aff234dfb218a58a5511f6c1a55dc635de31c90", "scripts/memory_lookup.py": "23f02b04636924a1f6872d338e85ee2145ed1cde742b3e2e5b6ee4592ff92d50",
"scripts/memory_save.py": "42638841cceb586783b8764eacff91a8518d8df7dfe99b3919b3caa5a432e0ec", "scripts/memory_save.py": "cfdf6dcd652475fb9bf04521655581dad10f36d7d7f69d079bf07833b99ba4e6",
"scripts/query_shensha.py": "1d77205c8781676f3536bbeb2f36d397b825ff3065a4b968344fb95e5b8859dd", "scripts/query_shensha.py": "e4c852c8e56fb8b32bde0fb376d985647bcfd93a74fe30fd34c5884d2ab77dc3",
"scripts/render/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "scripts/render/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"scripts/render/assets/lingyao-logo.png": "3d63f71ff9af61a443708f2665d1331737bb567a4e731d049676dfeb4710e323",
"scripts/render/assets/logo-font.ttf": "0b3a28b91d30276a68501a837ce7c1874274276c21793957b83f1efbb4a9264a",
"scripts/render/assets/qun-qrcode.jpg": "437854ff4ea6d2b3452b16371afbba1b9bb97e1f2c7fdbc77580ae2cceb3cbaf",
"scripts/render/html.py": "3196b808d83b876ff1d4b7ea13bbb97f27154bfcd27b491cd10b2e4c0c254874",
"scripts/render/markdown.py": "4b2bcc3fc6581728d74bbaa357bf1db2e8aec1d3ec54c2c27ca645b6c5179977", "scripts/render/markdown.py": "4b2bcc3fc6581728d74bbaa357bf1db2e8aec1d3ec54c2c27ca645b6c5179977",
"scripts/self_update.py": "95dc0302ca2b78d71b2893ebaced7e233dbc00944a5e3c5b06081b5c9a749f8a", "scripts/render/templates/paipan.html": "aecc6a7ec5d6dcb567e56a986dc803678838f12551828ff58ae313ae7a6499a5",
"scripts/share_export.py": "21396143ad5cc2d244c7cc060a3ece25702594465689a9f12438dfe92de3296f", "scripts/self_update.py": "ca8cf55dffd805a3643e71167912e6468f33e1b7fe0a270ec263f6d9bf949bb8",
"scripts/share_push.py": "8a367a3a4aa1bad27fbf3e9b241ebf69bf687a134045a06842e06047b58458ae", "scripts/share_export.py": "eab38048e3737de1eb8731e6f4860f4061d29dedb9e9f3a1c9d9ac44ec200c20",
"share/README.md": "42ebed92285752af93c7c7af5eb4b6e84ed836408d0cedb57e1b32c796746b26", "scripts/share_push.py": "307e83747b8d244f20632a198b7bfba51352a6b6bf0a8f98b73c904e719a6b89",
"share/config.json": "22a5615dd7db5aa688e1ed8e464874c70d90d21e6aa126e214b9c7896b99c7cd", "share/README.md": "adccd0f7691bedd64441b11cdd8ce66e20adce55fe4d8de1333f531b689f82b8",
"share/config.json": "b0747cb29b01e65c79e612062e1d572f6658c3046ae8bb5d97020ab2d7c6f04e",
"tests/golden/history_tz.yaml": "3fb74e90c4f53abebaac0a5de8825ac3794fc0b5b41dc84121201e70cecade47", "tests/golden/history_tz.yaml": "3fb74e90c4f53abebaac0a5de8825ac3794fc0b5b41dc84121201e70cecade47",
"tests/golden/longitudes.yaml": "6fda1e6b632d29c135362c9273b931c4416c9f24c31b07e2a7baadc6e2077bc2", "tests/golden/longitudes.yaml": "6fda1e6b632d29c135362c9273b931c4416c9f24c31b07e2a7baadc6e2077bc2",
"tests/golden/time_boundary.yaml": "637090a1c081b9b824fc0073d29dad073d29138c3b48082bdf41ce77f5af3b37", "tests/golden/time_boundary.yaml": "637090a1c081b9b824fc0073d29dad073d29138c3b48082bdf41ce77f5af3b37",
"tests/test_chart_shot.py": "7923d2868ded898331a65152b237cf014fa49b180b08706584ff7319f88b7a33",
"tests/test_day_boundary.py": "0e6c9ee32177a178f21a7812f83032abe4d1f585de48d07d21e2b432bdbcbd04", "tests/test_day_boundary.py": "0e6c9ee32177a178f21a7812f83032abe4d1f585de48d07d21e2b432bdbcbd04",
"tests/test_e2e.py": "e324930fad582dc38618fa3599425bb48ec718beaf426efcb76531911c9464fa", "tests/test_e2e.py": "e324930fad582dc38618fa3599425bb48ec718beaf426efcb76531911c9464fa",
"tests/test_equation_of_time.py": "8a3a1baf7e61c42015405cc80fc0bdf4fd9bc732d440bc156f9c92c94e30f790", "tests/test_equation_of_time.py": "8a3a1baf7e61c42015405cc80fc0bdf4fd9bc732d440bc156f9c92c94e30f790",
"tests/test_html.py": "29fbab116aaa48ef9b97f26b6eec6c22157cd8c6af9da61943e20dd93157e49d",
"tests/test_license_gate.py": "ee44226b75620b3965ba52cc2a0cc5404fbff37822fd96a6faf2aed89d07d47e", "tests/test_license_gate.py": "ee44226b75620b3965ba52cc2a0cc5404fbff37822fd96a6faf2aed89d07d47e",
"tests/test_luck.py": "821f98bc19ab1e920353de58c6ffacac5d757c887c690a2d596f51c1485b21ea", "tests/test_luck.py": "821f98bc19ab1e920353de58c6ffacac5d757c887c690a2d596f51c1485b21ea",
"tests/test_memory.py": "d3decfdc8d7c5bbfc4af5e3aed93bb3c53ab09071b682a0f4808614ac2cd3c6f", "tests/test_memory.py": "068241927df08301806451ff6e66206ceb9d439d95197b3506fe519d892c28ed",
"tests/test_self_update.py": "455461ba121814c8cdbe98bb2d31cd2dac630ef111eafb23118583c42f65a71c", "tests/test_runtime_guard.py": "7fd7e2f334433432452ea68fde30df1698def2126a1920f8da6d6aab236c1caf",
"tests/test_share.py": "399f20da22689d00e71eb78c76d60507060c29a7d68865e6b0e9f591d19be871", "tests/test_self_update.py": "183c2fb82d2d20285941b6c75a1890c4417d400c3c7e7d5f8a9680b4fa7bf76c",
"tests/test_shensha.py": "b8a20d264e18c79f1fe546281381b0d929543af44ec68dc890ef8b64efc625b0", "tests/test_share.py": "7938344415cbba95f683b2a09ecec9af28d00658f475ff0a56563dd13beff630",
"tests/test_shensha.py": "2aaa181279739356828f05a2175027b0ae711b322b4bd0e60d5c5e62538bcac8",
"tests/test_solar_time.py": "f072e5ea82fd0cb331c8a4fdc6dce08148cd9ac81ef9fe4506196fa68a42d1e4", "tests/test_solar_time.py": "f072e5ea82fd0cb331c8a4fdc6dce08148cd9ac81ef9fe4506196fa68a42d1e4",
"tests/test_timezone.py": "3698878999d232bd431d6908c42fe8f4845c696da6085731abaf2f74017d722d", "tests/test_timezone.py": "3698878999d232bd431d6908c42fe8f4845c696da6085731abaf2f74017d722d",
"tests/test_version_sentinel.py": "9b2bc603a2e86aa33d904f1381ecd8ab41eb0e759803ec47122d662c10a7df83", "tests/test_version_sentinel.py": "9b2bc603a2e86aa33d904f1381ecd8ab41eb0e759803ec47122d662c10a7df83",
@@ -1265,5 +1276,5 @@
"vendor/tzdata/zoneinfo/zonenow.tab": "7f9d1c11445af7a17862c20a9081226d31a09a1730e270ff59a34c8d2b4c7f7f", "vendor/tzdata/zoneinfo/zonenow.tab": "7f9d1c11445af7a17862c20a9081226d31a09a1730e270ff59a34c8d2b4c7f7f",
"vendor/tzdata/zones": "28d8f3eb25839095fa8a209997bb20e822ebfbebe21e60edc483a3d5e20dc881" "vendor/tzdata/zones": "28d8f3eb25839095fa8a209997bb20e822ebfbebe21e60edc483a3d5e20dc881"
}, },
"version": "3.5.0" "version": "3.6.0"
} }
+15 -1
View File
@@ -7,6 +7,11 @@
python scripts/memory_save.py --name 张三 --gender 男 \ python scripts/memory_save.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市" --birth "1990-05-14 08:30" --place "四川省成都市"
# 换盘(经用户确认换盘后):重排并整体替换「## 盘面」段,frontmatter 同步;
# 姓名/性别/出生信息读档案,--birth/--place 仅在校正时给覆盖值
python scripts/memory_save.py --update-chart archives/<档案>.md \
[--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后地点"]
# 检索(条件任意组合、至少一项;大小写/空白容错) # 检索(条件任意组合、至少一项;大小写/空白容错)
python scripts/memory_lookup.py [--name 张三] [--birth "1990-05-14 08:30"] \ python scripts/memory_lookup.py [--name 张三] [--birth "1990-05-14 08:30"] \
[--place 四川省成都市] [--gender 男] [--pillars "庚午 辛巳 己卯 戊辰"] [--place 四川省成都市] [--gender 男] [--pillars "庚午 辛巳 己卯 戊辰"]
@@ -21,7 +26,16 @@ python scripts/memory_lookup.py [--name 张三] [--birth "1990-05-14 08:30"] \
`archives/<四柱连写>-<性别>-<hash6>.md`,其中 `archives/<四柱连写>-<性别>-<hash6>.md`,其中
hash6 = sha1(出生钟表时间 + 出生地 + 性别)(UTF-8)前 6 位十六进制, hash6 = sha1(出生钟表时间 + 出生地 + 性别)(UTF-8)前 6 位十六进制,
用于区分同盘不同人。同主键再次建档只刷新 frontmatter 与「## 盘面」, 用于区分同盘不同人。同主键再次建档只刷新 frontmatter 与「## 盘面」,
正文其余小节原样保留。 正文其余小节原样保留。换盘(`--update-chart`)后四柱或 hash6 变动时,
文件改为新主键名(目标已存在则拒绝覆盖,报错退出 2)。
## 换盘纪律(工作盘唯一 + 变更留痕)
经用户确认换盘(日界规则分歧、时辰/出生地校正等)后,必须重排并把
「## 盘面」段整体替换为新盘:档案任何时候只有一个工作盘,旧盘信息
(原四柱、换盘原因、确认方式与时间)记入「反馈与澄清」校订条目,不留盘面段。
凡受变动柱影响的已有推断条目(归纳、流年大事各行)同轮标记【待复核】,
复核前不得继续引用。
## Schema ## Schema
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "ZiPingEightChar" name = "ZiPingEightChar"
version = "0.1.0" version = "3.6.0"
requires-python = ">=3.10" # zoneinfo 需 3.9+,dataclass 语法需 3.10 requires-python = ">=3.10" # zoneinfo 需 3.9+,dataclass 语法需 3.10
dependencies = [ dependencies = [
"tyme4py==1.5.0", # 精确锁定:历表数据跨版本会变,锁版本 = 锁可复现性 "tyme4py==1.5.0", # 精确锁定:历表数据跨版本会变,锁版本 = 锁可复现性
+3 -3
View File
@@ -363,10 +363,10 @@
- **原文**:(综括,无单一原文) - **原文**:(综括,无单一原文)
### R-运-33 核对前置:先定场景,再定断运路径 ### R-运-33 核对前置:先定场景,再定断运路径
- **条件**:断运输出覆盖五年以上、或多个流年候选需要用户核对时。**核对一律在铺开断语之前完成**——不得先输出一大段再回头提问。agent 环境具备询问工具时**必须用询问工具**,无询问工具时以正文编号选项代替。 - **条件**:断运输出覆盖五年以上、或多个流年候选需要用户核对时。**核对一律在铺开断语之前完成**——不得先输出一大段再回头提问。核对提问一律走对话式询问(见 SKILL.md「Intake 对话式询问」):以普通对话消息进行,禁用结构化问卷/点选工具,也不在正文罗列编号问题清单让用户勾选;一次最多问 1~2 个,答完再定下一个。
- **判定**: - **判定**:
1. **一问定场景(并入 intake)**:「这盘是您本人,还是替别人看?本人或知情人在场、能核对以往的事吗?」此问与缺项追问合成一次提问(见 SKILL.md「Intake 追问话术」),不单独多占一轮。 1. **一问定场景(并入 intake)**:「这盘是您本人,还是替别人看?本人或知情人在场、能核对以往的事吗?」此问与缺项追问合成一次提问(见 SKILL.md「Intake 对话式询问」),不单独多占一轮。
2. **可核对 → 锚点核对**:从全部候选年中挑出 3 个左右**锚点年**请用户核对。锚点年选取规则:引动组数最多者优先;落在人生阶段窗口(升学、毕业、婚育、换工作)者优先;用户所问之线每条至少占一个。每个锚点年给 2–3 个候选事件(R-运-32)。**核对用结构化选项**:每个锚点年一个问题,选项为按可能性排序的候选事件、外加「都对不上」(证伪入口);几个锚点年合成一次提问,不逐条单独打断用户。核对结果按 R-运-32 第 4 步收敛后,一次性输出完整断运。 2. **可核对 → 锚点核对**:从全部候选年中挑出 3 个左右**锚点年**请用户核对。锚点年选取规则:引动组数最多者优先;落在人生阶段窗口(升学、毕业、婚育、换工作)者优先;用户所问之线每条至少占一个。每个锚点年给 2–3 个候选事件(R-运-32)。**核对走对话式询问**:一次最多问一两个锚点年,每问先以该年盘上一两句显著信号作铺垫,再自然带出按可能性排序的候选事件,并留「都对不上」的证伪入口;用户答完再挑下一个锚点,不逐条单独打断、也不合成清单一次抛出。核对结果按 R-运-32 第 4 步收敛后,一次性输出完整断运。
3. **不可核对 → 直推**:命主不在场、不知情,或用户明言测试、「不用问、直接说」时,跳过核对,一次性输出最佳推断;直答按 R-运-32 落笔纪律给主答案与置信度,全量候选收在分析推理段。 3. **不可核对 → 直推**:命主不在场、不知情,或用户明言测试、「不用问、直接说」时,跳过核对,一次性输出最佳推断;直答按 R-运-32 落笔纪律给主答案与置信度,全量候选收在分析推理段。
4. **证伪回查**:锚点全部落空时不得硬铺——先回头检查:组数判定是否偏重、学制等阶段假设是否错位、出生时刻是否跨时辰或跨日界(晚子时)、是否临近节气边界(命盘带 ⚠️ 节气边界提示者,重排另一月令之盘对照);修正后重新给锚点。 4. **证伪回查**:锚点全部落空时不得硬铺——先回头检查:组数判定是否偏重、学制等阶段假设是否错位、出生时刻是否跨时辰或跨日界(晚子时)、是否临近节气边界(命盘带 ⚠️ 节气边界提示者,重排另一月令之盘对照);修正后重新给锚点。
5. **学制锚点**:断语涉及学业年份时,把一个学制锚点(哪年上大学/哪年毕业、有无复读休学)并入第 2 步那次提问,不单独多问一轮。 5. **学制锚点**:断语涉及学业年份时,把一个学制锚点(哪年上大学/哪年毕业、有无复读休学)并入第 2 步那次提问,不单独多问一轮。
+4 -1
View File
@@ -102,7 +102,10 @@ rules/
## 6. 版本与溯源 ## 6. 版本与溯源
- 规则库版本: - 规则库版本:
- **3.5**(2026-10-09)。文件头注释的 `rules_version` 标记该文件的**格式版本**;库版本以本文件为准。 - **3.6**(2026-10-10)。文件头注释的 `rules_version` 标记该文件的**格式版本**;库版本以本文件为准。
### 3.6 相对 3.5:
- 询问契约改对话式(规则条数不变,542 条、20 文件)——SKILL.md Intake 节改「对话式询问(强制)」(禁用结构化问卷/点选工具与正文编号选项清单,一次最多 1~2 问、先铺垫再问),断运交互节同步;`90_行运.md` R-运-33 措辞随之修订——原「具备询问工具时必须用询问工具,否则以正文编号选项代替」「核对用结构化选项、多锚点年合成一次提问」改为对话式口径(普通对话消息、一次最多一两个锚点、先铺垫再问、答完再定下一个);核对前置的内部程序(场景分流、锚点选取、候选排序、证伪回查、收敛后一次性输出)不变。
### 3.5 相对 3.4: ### 3.5 相对 3.4:
- description 改写为按问题域与使用场景触发;新增 R-禁-22 声明式偏离通道;R-运-31 事类映射表改开放清单措辞;规则计数相应更新(541 → 542 条,20 文件不变)。 - description 改写为按问题域与使用场景触发;新增 R-禁-22 声明式偏离通道;R-运-31 事类映射表改开放清单措辞;规则计数相应更新(541 → 542 条,20 文件不变)。
+32
View File
@@ -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)
+6 -4
View File
@@ -3,9 +3,10 @@ from datetime import datetime, timedelta
ZI_HOUR_START = 23 # 真太阳时 23:00 换日 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) (日历日 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 is_late_zi = tst.hour == ZI_HOUR_START
day = tst.replace(hour=0, minute=0, second=0, microsecond=0) 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) day += timedelta(days=1)
return day, { return day, {
"rule": "子初换日@真太阳时23:00", "rule": ("子正换日@真太阳时00:00" if rule == "子正"
else "子初换日@真太阳时23:00"),
"is_late_zi": is_late_zi, "is_late_zi": is_late_zi,
"tst_time": tst.strftime("%Y-%m-%d %H:%M:%S"), "tst_time": tst.strftime("%Y-%m-%d %H:%M:%S"),
} }
+28
View File
@@ -18,6 +18,8 @@ from pathlib import Path
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入 # 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
sys.path.insert(0, str(Path(__file__).resolve().parent)) 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/,版本锁定 tyme4py==1.5.0 + tzdata)
# vendor 目录不存在时回退到环境中已安装的包(开发模式) # vendor 目录不存在时回退到环境中已安装的包(开发模式)
_VENDOR = Path(__file__).resolve().parents[1] / "vendor" _VENDOR = Path(__file__).resolve().parents[1] / "vendor"
@@ -37,6 +39,11 @@ def main() -> int:
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需') help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需')
ap.add_argument("--place", required=True, ap.add_argument("--place", required=True,
help="出生地,精确到地级市一级,如「四川省成都市」") 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() a = ap.parse_args()
# 软授权锁:定制副本(含 data/.license.json)按授权次数/期限放行;母版无状态文件直接放行 # 软授权锁:定制副本(含 data/.license.json)按授权次数/期限放行;母版无状态文件直接放行
@@ -130,6 +137,27 @@ def main() -> int:
print(render(chart)) print(render(chart))
_gate.consume(Path(__file__).resolve().parents[1]) _gate.consume(Path(__file__).resolve().parents[1])
# 可选:同步生成 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")
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)
# 自更新提示:只读本地缓存往 stderr 打一行;缓存过期则后台线程刷新, # 自更新提示:只读本地缓存往 stderr 打一行;缓存过期则后台线程刷新,
# 主流程不等它(用当次缓存)。任何异常静默,绝不影响 stdout 命盘契约。 # 主流程不等它(用当次缓存)。任何异常静默,绝不影响 stdout 命盘契约。
try: try:
+68
View File
@@ -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)}
+3 -14
View File
@@ -10,21 +10,9 @@
""" """
from __future__ import annotations from __future__ import annotations
GAN = "甲乙丙丁戊己庚辛壬癸" from .ganzhi import GAN, NAYIN, ZHI
ZHI = "子丑寅卯辰巳午未申酉戌亥"
YANG_GAN = set("甲丙戊庚壬")
# 六十甲子纳音( pairwise ),末字即五行 YANG_GAN = set("甲丙戊庚壬")
_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)}
# —— 日干查地支 —— # —— 日干查地支 ——
TIANYI = {"甲": "丑未", "戊": "丑未", "庚": "丑未", TIANYI = {"甲": "丑未", "戊": "丑未", "庚": "丑未",
@@ -102,6 +90,7 @@ SANQI = ("乙丙丁", "甲戊庚", "辛壬癸")
# 输出排序(每柱内部按此序) # 输出排序(每柱内部按此序)
STAR_ORDER = [ STAR_ORDER = [
"天德", "天德合", "月德", "月德合", "天乙贵人", "太极贵人", "福星贵人", "天德", "天德合", "月德", "月德合", "天乙贵人", "太极贵人", "福星贵人",
"三奇贵人",
"文昌", "禄神", "羊刃", "金舆", "驿马", "桃花", "红艳", "华盖", "将星", "文昌", "禄神", "羊刃", "金舆", "驿马", "桃花", "红艳", "华盖", "将星",
"劫煞", "灾煞", "亡神", "元辰", "勾煞", "绞煞", "孤辰", "寡宿", "劫煞", "灾煞", "亡神", "元辰", "勾煞", "绞煞", "孤辰", "寡宿",
"天罗", "地网", "童子煞", "天赦", "魁罡", "阴差阳错", "十灵日", "十恶大败", "天罗", "地网", "童子煞", "天赦", "魁罡", "阴差阳错", "十灵日", "十恶大败",
+96
View File
@@ -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())
+1
View File
@@ -19,6 +19,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
import _memory_store as store import _memory_store as store
+222 -83
View File
@@ -4,12 +4,23 @@
用法: 用法:
python memory_save.py --name 张三 --gender 男 \ python memory_save.py --name 张三 --gender 男 \
--birth "1990-05-14 08:30" --place "四川省成都市" --birth "1990-05-14 08:30" --place "四川省成都市"
# 换盘(更新既有档案盘面段):按档案重排并替换「## 盘面」,frontmatter 同步
python memory_save.py --update-chart memory/archives/丙子庚子庚辰丙子-女-2825d1.md \
[--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后出生地"]
输出:档案文件路径到 stdout。 输出:档案文件路径到 stdout。
建档即一次排盘:授权副本照常过软锁、扣减一次(与 cast_chart.py 同一 check/consume 位置)。 建档即一次排盘:授权副本照常过软锁、扣减一次(与 cast_chart.py 同一 check/consume 位置)。
同主键(四柱+性别+hash6)档案已存在时只刷新 frontmatter 与「## 盘面」, 同主键(四柱+性别+hash6)档案已存在时只刷新 frontmatter 与「## 盘面」,
正文其余小节(归纳、反馈与澄清等)原样保留。档案规范见 memory/README.md。 正文其余小节(归纳、反馈与澄清等)原样保留。档案规范见 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)。 退出码:0 成功;2 输入不可用;3 库内部错误;4 授权不可用(软锁,见 LICENSE.md)。
""" """
import argparse import argparse
@@ -21,6 +32,8 @@ from pathlib import Path
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入 # 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
sys.path.insert(0, str(Path(__file__).resolve().parent)) 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/,版本锁定 tyme4py==1.5.0 + tzdata)
_VENDOR = Path(__file__).resolve().parents[1] / "vendor" _VENDOR = Path(__file__).resolve().parents[1] / "vendor"
if _VENDOR.is_dir(): if _VENDOR.is_dir():
@@ -28,24 +41,215 @@ if _VENDOR.is_dir():
import _memory_store as store 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: def main() -> int:
ap = argparse.ArgumentParser( ap = argparse.ArgumentParser(
prog="memory_save.py", prog="memory_save.py",
description="命主档案建档/更新:排盘一次并写入 memory/archives/(stdout 打印档案路径)", description="命主档案建档/更新:排盘一次并写入 memory/archives/(stdout 打印档案路径)",
) )
ap.add_argument("--name", required=True, help="姓名") ap.add_argument("--name", default=None, help="姓名")
ap.add_argument("--gender", required=True, choices=["男", "女"], ap.add_argument("--gender", default=None, choices=["男", "女"],
help="性别:男 或 女") help="性别:男 或 女")
ap.add_argument("--birth", required=True, ap.add_argument("--birth", default=None,
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需') help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需;'
ap.add_argument("--place", required=True, "换盘模式下为时辰校正覆盖值")
help="出生地,精确到地级市一级,如「四川省成都市」") 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, ap.add_argument("--memory-root", default=None,
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})") help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
a = ap.parse_args() 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 import _license_gate as _gate
_deny = _gate.check(Path(__file__).resolve().parents[1]) _deny = _gate.check(Path(__file__).resolve().parents[1])
if _deny: if _deny:
@@ -53,91 +257,26 @@ def main() -> int:
return 4 return 4
try: try:
# 排盘管线与 cast_chart.py 同一调用序列(geo → astro → chart → render) if a.update_chart is not None:
from geo.cities import resolve_location, LocationError rc = _update_chart(a)
from astro.timezone_resolve import to_utc if rc == 0:
from astro.solar_time import to_true_solar_time _gate.consume(Path(__file__).resolve().parents[1])
from astro.day_boundary import resolve_calendar_day return rc
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) # L0/L1/L2 排盘管线(地点/时刻不可用 → 退出码 2)
from geo.cities import LocationError
try: 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: except LocationError as e:
print(f"ERROR: {e}", file=sys.stderr) print(f"ERROR: {e}", file=sys.stderr)
return 2 return 2
# L0-b 出生时刻解析
try:
clock = datetime.strptime(a.birth.strip(), "%Y-%m-%d %H:%M")
except ValueError: except ValueError:
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;" print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
"若用户只给了日期,请追问具体时刻。", file=sys.stderr) "若用户只给了日期,请追问具体时刻。", file=sys.stderr)
return 2 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__) root = store.resolve_root(a.memory_root, __file__)
arch_dir = root / "archives" arch_dir = root / "archives"
arch_dir.mkdir(parents=True, exist_ok=True) arch_dir.mkdir(parents=True, exist_ok=True)
@@ -147,8 +286,8 @@ def main() -> int:
now = datetime.now().strftime("%Y-%m-%d %H:%M") now = datetime.now().strftime("%Y-%m-%d %H:%M")
meta = { meta = {
"name": a.name, "gender": a.gender, "birth_clock": a.birth.strip(), "name": a.name, "gender": a.gender, "birth_clock": a.birth.strip(),
"birthplace": a.place.strip(), "true_solar_time": chart.true_solar_time, "birthplace": a.place.strip(), "true_solar_time": tst_str,
"pillars": pillars, "day_master": chart.day_master, "pillars": pillars, "day_master": day_master,
"created_at": now, "updated_at": now, "created_at": now, "updated_at": now,
} }
if path.is_file(): if path.is_file():
+2
View File
@@ -19,6 +19,8 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser( 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

+243
View File
@@ -0,0 +1,243 @@
# scripts/render/html.py
"""ChartData → 单文件 HTML 盘面(模板 render/templates/paipan.html)。
与 Markdown 输出同为纯计算渲染:十神/藏干/空亡/纳音/十二长生/月令旺衰
均为固定查表,不含任何命理判断(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.ganzhi import NAYIN, WX_GAN, WX_ZHI, gz_void, ten_god, terrain
from chart.shensha import evaluate_target
_WX_CLASS = {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu",
"金": "wx-jin", "水": "wx-shui"}
_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_CLASS.get(wx, "")
def _z(zhi: str, voids: str) -> str:
cls = "voidz" if zhi in voids else ""
return f'<span class="{_wx(zhi)} {cls}">{zhi}</span>'
def _pillar_col(chart, key: str, label: str, cur: bool = False) -> dict:
"""四柱之一 → 各行的 HTML 单元格内容。"""
p = getattr(chart, key)
voids = chart.void.get("day", "") + chart.void.get("hour", "")
return {
"label": label, "cur": cur,
"god": p.gan_ten_god or ("日主" if key == "day" else ""),
"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),
"nayin": p.na_yin or "",
"shensha": [_strip_sha_note(s) for s in chart.shen_sha.get(key, [])],
"voids": voids,
}
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):
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>'
rows = [
"<tr>" + '<td class="rowlabel"></td>'
+ "".join(th(c) for c in cols) + "</tr>",
row("主星", lambda c: f'<span class="god{" day" if c["god"] == "日主" else ""}">{c["god"]}</span>'),
row("天干", lambda c: f'<span class="gz {_wx(c["gan"])}">{c["gan"]}</span>'),
row("地支", lambda c: f'<span class="gz">{_z(c["zhi"], c["voids"])}</span>'),
row("藏干", lambda c: '<div class="hidden">'
+ "<br>".join(f'<span class="hg {_wx(h)}">{h}</span> <span class="god">{g}</span>'
for h, g in c["hidden"]) + "</div>"),
row("星运", lambda c: f'<span class="small">{c["terrain"]}</span>'),
row("空亡", lambda c: f'<span class="small">{c["void"]}</span>'),
row("纳音", lambda c: f'<span class="small">{c["nayin"]}</span>'),
row("神煞", lambda c: '<div class="small">'
+ ("<br>".join(c["shensha"]) if c["shensha"] else "—") + "</div>"),
]
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 = []
for lp in chart.luck_pillars:
gz = lp.gan + lp.zhi
cur = lp.start_year <= this_year < lp.start_year + 10
cells.append(
f'<div class="cell{" cur" if cur else ""}">'
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'{_gz_sub(chart, gz)}</div>')
return '<div class="strip">' + "".join(cells) + "</div>"
def _childhood_block(chart) -> str:
if not chart.childhood_luck:
return ""
cells = "".join(
f'<div class="cell"><div class="cy">{age} 岁 · {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>'
for age, year, gz in chart.childhood_luck)
return ('<div class="sect" style="margin-top:14px">小运'
'<span class="note">童限(未交大运)各虚岁</span></div>'
f'<div class="strip">{cells}</div>')
def _flow_years(chart) -> str:
this_year = datetime.now().year
cells = []
for year, gz, label in chart.flow_years:
cls = "cell"
if year == this_year:
cls += " cur"
if label.startswith("交运年"):
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>'
f'{_gz_sub(chart, gz)}</div>')
return '<div class="strip">' + "".join(cells) + "</div>"
def _flow_months(chart) -> str:
cells = []
for label, gz in chart.flow_months:
cells.append(f'<div class="cell"><div class="cy">{label}</div>'
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>')
return '<div class="strip">' + "".join(cells) + "</div>"
def _season_bar(chart) -> str:
seq = _SEASON.get(chart.month_ling)
if not seq:
return ""
parts = " ".join(f"{wx}{lab}" for wx, lab in zip(seq, _SEASON_LABEL))
return f"月令 {chart.month_ling}:{parts}"
def build_html(chart) -> str:
"""ChartData → 完整 HTML 文档。"""
tpl = (Path(__file__).resolve().parent
/ "templates" / "paipan.html").read_text(encoding="utf-8")
warns = []
if chart.is_late_zi:
warns.append("⚠️ 晚子时:本造按子初换日归入次日;子正换日一派日柱、时柱不同。")
if chart.jie_gap_warning:
warns.append(chart.jie_gap_warning)
warnings = "".join(f'<div class="warn">{w}</div>' for w in warns)
qiyun = ""
if chart.luck_pillars:
qiyun = (f"起运:{chart.start_age_text}"
f"({'顺行' if chart.luck_is_forward else '逆行'})")
audit = (f"时区 {chart.tz_used}(夏令时:{'是' if chart.is_dst else '否'})· "
f"经度修正 {chart.lon_correction_min:+.2f} 分 · 均时差 {chart.eot_minutes:+.2f} 分 · "
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]}",
"BIRTHPLACE": chart.birthplace,
"BIRTH_CLOCK": chart.birth_clock,
"TRUE_SOLAR": chart.true_solar_time,
"MONTH_LING": chart.month_ling,
"TAI_YUAN": chart.tai_yuan,
"MING_GONG": chart.ming_gong,
"QIYUN": qiyun,
"WARNINGS": warnings,
"MAIN_TABLE": _main_table(chart),
"LUCK_NOTE": f"{chart.start_age_text}起运,当前一步高亮",
"LUCK_STRIP": _luck_strip(chart),
"CHILDHOOD_BLOCK": _childhood_block(chart),
"FLOW_YEARS": _flow_years(chart),
"FLOW_MONTHS": _flow_months(chart),
"SEASON_BAR": _season_bar(chart),
"AUDIT": audit,
"AUDIT_FULL": audit_full,
}
out = tpl
for k, v in mapping.items():
out = out.replace("{{" + k + "}}", str(v))
return out
+204
View File
@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<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 {
/* 灵爻妙解网站浅色主题(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: 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: 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: #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 {
display: inline-block; padding: 2px 10px; border-radius: 999px;
border: 1px solid var(--accent); color: var(--accent); font-size: 13px;
}
.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(--card-line); text-align: center;
padding: 6px 4px; vertical-align: middle; font-size: 14px;
}
.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: var(--muted); }
.god.day { color: var(--accent); font-weight: 600; }
.hidden { font-size: 12.5px; line-height: 1.7; color: var(--soft); }
.hidden .hg { font-weight: 600; }
.small { font-size: 12.5px; color: var(--soft); line-height: 1.7; }
.voidz { border-bottom: 2px dashed #c0392b; padding-bottom: 1px; }
.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(--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 .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 {
text-align: center; color: var(--muted); font-size: 12px;
line-height: 1.9; margin-top: 16px;
}
</style>
</head>
<body>
<div class="page">
<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>
<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>
<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="foot">灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考</div>
</div>
</body>
</html>
+27 -5
View File
@@ -12,12 +12,17 @@
代理环境变量由 urllib 默认读取、自动生效。测试或自建场景可用环境变量 代理环境变量由 urllib 默认读取、自动生效。测试或自建场景可用环境变量
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS(逗号分隔)整体覆盖远端地址, ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS(逗号分隔)整体覆盖远端地址,
支持 file:// 形式。 支持 file:// 形式。
抓取通道:urllib 优先;macOS 框架版 Python 常见的根证书缺失
(SSL CERTIFICATE_VERIFY_FAILED)时自动回退到系统 curl(curl 自带
系统信任库且自带代理环境变量支持)。
""" """
import argparse import argparse
import hashlib import hashlib
import json import json
import os import os
import shutil import shutil
import subprocess
import sys import sys
import tempfile import tempfile
import threading import threading
@@ -26,6 +31,10 @@ import urllib.request
import zipfile import zipfile
from pathlib import Path 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 优先,Gitea 兜底)----
GITHUB_VERSION_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/VERSION" GITHUB_VERSION_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/VERSION"
GITHUB_ZIPBALL_URL = "https://codeload.github.com/mxiaonian/ZiPingEightChar/zip/refs/heads/main" GITHUB_ZIPBALL_URL = "https://codeload.github.com/mxiaonian/ZiPingEightChar/zip/refs/heads/main"
@@ -62,15 +71,28 @@ def _zipball_urls() -> list:
return [GITHUB_ZIPBALL_URL, GITEA_ZIPBALL_URL] return [GITHUB_ZIPBALL_URL, GITEA_ZIPBALL_URL]
def _fetch_bytes(url: str, timeout: int) -> bytes:
"""urllib 优先;SSL 证书缺失等失败时回退系统 curl(macOS 框架版 Python 常见)。"""
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.read()
except Exception:
curl = shutil.which("curl")
if not curl:
raise
r = subprocess.run([curl, "-fsSL", "-m", str(timeout), url],
capture_output=True)
if r.returncode != 0:
raise RuntimeError(f"curl 抓取失败({r.returncode}):{url}")
return r.stdout
def _fetch_text(url: str, timeout: int = NETWORK_TIMEOUT) -> str: def _fetch_text(url: str, timeout: int = NETWORK_TIMEOUT) -> str:
with urllib.request.urlopen(url, timeout=timeout) as r: return _fetch_bytes(url, timeout).decode("utf-8")
return r.read().decode("utf-8")
def _download(url: str, dest: Path, timeout: int = NETWORK_TIMEOUT) -> None: def _download(url: str, dest: Path, timeout: int = NETWORK_TIMEOUT) -> None:
with urllib.request.urlopen(url, timeout=timeout) as r: dest.write_bytes(_fetch_bytes(url, timeout))
with open(dest, "wb") as fh:
shutil.copyfileobj(r, fh)
def parse_version(text: str) -> tuple: def parse_version(text: str) -> tuple:
+125 -17
View File
@@ -6,11 +6,18 @@
python share_export.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男 python share_export.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男
脱敏规格(见 share/README.md):不含姓名;出生信息只保留年月日与时辰(不留分钟 脱敏规格(见 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 from __future__ import annotations
@@ -25,6 +32,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
import _memory_store as store import _memory_store as store
SCHEMA = "share/v1" SCHEMA = "share/v1"
@@ -37,7 +45,7 @@ CATEGORIES: list[tuple[str, tuple[str, ...]]] = [
("事业", ("事业", "工作", "升职", "跳槽", "调动", "失业", "创业", "辞职", "职务", "官非")), ("事业", ("事业", "工作", "升职", "跳槽", "调动", "失业", "创业", "辞职", "职务", "官非")),
("财运", ("财运", "破财", "投资", "收入", "赚钱", "欠债", "债务", "置业", "买房")), ("财运", ("财运", "破财", "投资", "收入", "赚钱", "欠债", "债务", "置业", "买房")),
("健康", ("健康", "生病", "手术", "伤病", "身体", "住院", "疾病", "意外")), ("健康", ("健康", "生病", "手术", "伤病", "身体", "住院", "疾病", "意外")),
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书")), ("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书", "大学")),
("六亲", ("父亲", "母亲", "父母", "子女", "孩子", "兄弟", "姐妹", "六亲", "亲人", "丧")), ("六亲", ("父亲", "母亲", "父母", "子女", "孩子", "兄弟", "姐妹", "六亲", "亲人", "丧")),
("流年运势", ("流年", "运势", "大运", "运程")), ("流年运势", ("流年", "运势", "大运", "运程")),
] ]
@@ -60,6 +68,9 @@ _PROVINCE_SHORT = {
_STATUS_TAGS = {"【已核实】": "已核实", "【已证伪】": "已证伪", "【未核实】": "未核实"} _STATUS_TAGS = {"【已核实】": "已核实", "【已证伪】": "已证伪", "【未核实】": "未核实"}
_GZ_RE = re.compile(r"[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]")
_YEAR_RE = re.compile(r"(?:19|20)\d{2}")
def _categorize_all(text: str) -> list[str]: def _categorize_all(text: str) -> list[str]:
return [cat for cat, keywords in CATEGORIES if any(k in text for k in keywords)] 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: def _province_of(birthplace: str) -> str | None:
"""地域脱敏:只保留到省级;识别不出省级则舍弃(宁缺毋滥)。""" """地域脱敏:省级;识别不出省级则舍弃(宁缺毋滥)。"""
for p in _PROVINCES: for p in _PROVINCES:
if p in birthplace: if p in birthplace:
return p return p
@@ -82,6 +93,42 @@ def _province_of(birthplace: str) -> str | None:
return m.group(1) if m else 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]: def _section_lines(body: str, heading: str) -> list[str]:
"""取档案正文某固定小节(SECTIONS 内)的行,到下一个固定小节或文末。""" """取档案正文某固定小节(SECTIONS 内)的行,到下一个固定小节或文末。"""
lines = body.splitlines() lines = body.splitlines()
@@ -129,22 +176,59 @@ def _parse_focus(body: str) -> list[str]:
return cats 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 = [] events = []
for ln in _section_lines(body, "反馈与澄清"): for ln in _section_lines(body, "反馈与澄清"):
t = ln.strip().lstrip("-•· ").strip() t = ln.strip().lstrip("-•· ").strip()
if not t or t in PLACEHOLDERS: if not t or t in PLACEHOLDERS:
continue continue
status = "未核实" m = _YEAR_RE.search(t)
for tag, name in _STATUS_TAGS.items(): note = _sanitize_note(_strip_tags(t), name)
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, events.append({"year": int(m.group(0)) if m else None,
"category": _categorize(t), "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 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]]: def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
"""组装脱敏包;返回 (package, 缺失项列表)。package 保证不含姓名/分钟/省以下地域。""" """组装脱敏包;返回 (package, 缺失项列表)。
保证不含姓名、出生分钟/钟表时间、区县及以下地域;事件简介中城市级地名保留,
直接定位信息(详细住址、证件、电话)已删除。
"""
pillars = meta.get("pillars", []) pillars = meta.get("pillars", [])
if isinstance(pillars, str): if isinstance(pillars, str):
pillars = pillars.split() pillars = pillars.split()
pillars = [str(p) for p in pillars] pillars = [str(p) for p in pillars]
birthplace = str(meta.get("birthplace", "")) birthplace = str(meta.get("birthplace", ""))
gender = str(meta.get("gender", "")) gender = str(meta.get("gender", ""))
name = str(meta.get("name", ""))
pkg: dict = {"schema": SCHEMA, pkg: dict = {"schema": SCHEMA,
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M"), "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, "pillars": pillars,
"luck_pillars": _parse_luck(body), "luck_pillars": _parse_luck(body),
"focus_categories": _parse_focus(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()) 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) province = _province_of(birthplace)
if province: if province:
pkg["region_province"] = province pkg["region_province"] = province
city = _city_of(birthplace)
if city:
pkg["region_city"] = city
missing = [] missing = []
if not gender: if not gender:
@@ -210,7 +303,7 @@ def _find_archive(args) -> Path | None:
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser( ap = argparse.ArgumentParser(
prog="share_export.py", prog="share_export.py",
description="从 memory 档案导出结构化脱敏包(JSON)到 share/pending/;不含姓名、分钟、省以下地域") description="从 memory 档案导出结构化脱敏包(JSON)到 share/pending/;不含姓名、分钟、区县及以下地域")
ap.add_argument("--archive", default=None, help="档案文件路径(直接指定)") ap.add_argument("--archive", default=None, help="档案文件路径(直接指定)")
ap.add_argument("--name", default=None, help="姓名(检索条件)") ap.add_argument("--name", default=None, help="姓名(检索条件)")
ap.add_argument("--gender", default=None, choices=["男", "女"], help="性别(检索条件)") ap.add_argument("--gender", default=None, choices=["男", "女"], help="性别(检索条件)")
@@ -249,6 +342,21 @@ def main() -> int:
pending = share_root / "pending" pending = share_root / "pending"
pending.mkdir(parents=True, exist_ok=True) pending.mkdir(parents=True, exist_ok=True)
out = pending / f"{pkg['chart_hash']}.json" 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", out.write_text(json.dumps(pkg, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8") encoding="utf-8")
print(out) print(out)
+48 -24
View File
@@ -9,7 +9,8 @@ ZIPING_SHARE_GITEA_REMOTE 覆盖)。两个远端逐个尝试,至少一个成
推送成功后:调 _license_gate.grant_uses(root, 10, 盘hash) 计励(同一盘只计一次), 推送成功后:调 _license_gate.grant_uses(root, 10, 盘hash) 计励(同一盘只计一次),
并把该包移入 share/pushed/。推送失败保留 pending,退出码 3。 并把该包移入 share/pushed/。推送失败保留 pending,退出码 3。
无 git 或无网络安静失败(不打栈)。 无 git 或无网络安静失败(不打栈);远端推送失败时在 stderr 带该远端
git 报错的末几行摘要,GitHub 线失败另附 HTTPS_PROXY 代理提示。
退出码:0 至少一包推送成功或无包可推;2 输入不可用;3 推送失败。 退出码:0 至少一包推送成功或无包可推;2 输入不可用;3 推送失败。
""" """
@@ -26,6 +27,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
import _license_gate as gate import _license_gate as gate
GRANT_USES = 10 # 每盘推送成功的计励次数 GRANT_USES = 10 # 每盘推送成功的计励次数
@@ -39,24 +41,38 @@ def _git(*args: str, cwd: str | None = None) -> subprocess.CompletedProcess:
capture_output=True, text=True, timeout=60) capture_output=True, text=True, timeout=60)
def _push_to_remote(remote: str, pkg: Path, workdir: str) -> bool: def _err_tail(cp: subprocess.CompletedProcess, n: int = 2) -> str:
"""克隆远端 → 放入脱敏包 → commit+push。任一步失败返回 False(安静)。""" """取 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: try:
if _git("clone", "--depth", "50", remote, workdir).returncode != 0: r = _git("clone", "--depth", "50", remote, workdir)
return False if r.returncode != 0:
return False, f"clone 失败:{_err_tail(r)}"
dst = Path(workdir) / pkg.name dst = Path(workdir) / pkg.name
shutil.copy2(pkg, dst) shutil.copy2(pkg, dst)
if _git("add", pkg.name, cwd=workdir).returncode != 0: r = _git("add", pkg.name, cwd=workdir)
return False if r.returncode != 0:
commit = _git("-c", "user.name=ziping-share", return False, f"add 失败:{_err_tail(r)}"
"-c", "user.email=ziping-share@localhost", r = _git("-c", "user.name=ziping-share",
"commit", "--allow-empty", "-m", f"share: {pkg.stem}", "-c", "user.email=ziping-share@localhost",
cwd=workdir) "commit", "--allow-empty", "-m", f"share: {pkg.stem}",
if commit.returncode != 0: cwd=workdir)
return False if r.returncode != 0:
return _git("push", "origin", "HEAD:main", cwd=workdir).returncode == 0 return False, f"commit 失败:{_err_tail(r)}"
except (OSError, subprocess.SubprocessError): r = _git("push", "origin", "HEAD:main", cwd=workdir)
return False 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]]: def _remotes(root: Path) -> list[tuple[str, str]]:
@@ -105,21 +121,29 @@ def main() -> int:
n_ok = 0 n_ok = 0
for pkg in packages: for pkg in packages:
chart_hash = pkg.stem chart_hash = pkg.stem
ok_remote = None ok_remotes = []
failures = []
for name, remote in remotes: for name, remote in remotes:
with tempfile.TemporaryDirectory(prefix="ziping-share-") as td: with tempfile.TemporaryDirectory(prefix="ziping-share-") as td:
if _push_to_remote(remote, pkg, td): ok, why = _push_to_remote(remote, pkg, td)
ok_remote = name if ok:
break ok_remotes.append(name)
if ok_remote is None: else:
print(f"FAIL {pkg.name}:所有远端推送失败(无网络或远端不可达)," if name == "github_remote":
f"pending 保留。", file=sys.stderr) 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}:所有远端推送失败,pending 保留。",
file=sys.stderr)
continue continue
granted = gate.grant_uses(root, GRANT_USES, chart_hash) granted = gate.grant_uses(root, GRANT_USES, chart_hash)
shutil.move(str(pkg), str(pushed / pkg.name)) shutil.move(str(pkg), str(pushed / pkg.name))
note = (f"+{GRANT_USES} 次(新增计励)" if granted note = (f"+{GRANT_USES} 次(新增计励)" if granted
else "该盘已计励过,本次不重复加次") else "该盘已计励过,本次不重复加次")
print(f"OK {pkg.name} → {ok_remote};{note}") print(f"OK {pkg.name} → {'、'.join(ok_remotes)};{note}")
n_ok += 1 n_ok += 1
return 0 if n_ok else 3 return 0 if n_ok else 3
+4 -4
View File
@@ -15,11 +15,11 @@
| 项目 | 处理 | | 项目 | 处理 |
|---|---| |---|---|
| 姓名 | 一律删除,不入包 | | 姓名 | 一律删除,不入包(事件简介中出现的姓名替换为「命主」) |
| 出生信息 | 只保留年月日与时辰(时辰以时支表示),不留分钟与钟表时间 | | 出生信息 | 只保留年月日与时辰(时辰以时支表示),不留分钟与钟表时间 |
| 地域 | 只保留到省级,省以下(市、县、区县)一律删除 | | 地域 | 保留到城市级(省+地级市),区县及以下一律删除 |
| 事件 | 一律类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他),不写细节 | | 事件 | 类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他)+ 事件简介(note);简介中**城市级地名保留**(规则蒸馏要用方位信息),详细住址(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除;书写事件简介时只写到城市级 |
| 正文自由文本 | 不入包(归纳、盘面细节、反馈原文均不导出) | | 正文自由文本 | 归纳、盘面细节不入包;入包的事件简介按上行规格脱敏 |
| 标识 | 仅以盘 hash(四柱+出生地 sha1 前 8 位)标识,用于去重与撤回 | | 标识 | 仅以盘 hash(四柱+出生地 sha1 前 8 位)标识,用于去重与撤回 |
## 用途与许可 ## 用途与许可
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"github_remote": "https://github.com/mxiaonian/ZiPingEightChar-archives.git", "github_remote": "https://github.com/mxiaonian/ZiPingEightChar-archives.git",
"gitea_remote": "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar-archives.git", "gitea_remote": "https://archives-uploader:df6be7f4a974bf65d796cd24e850e014a7faa8fc@git.lingyaomiaojie.com/ziping-eightchar/ZiPingEightChar-archives.git",
"_note": "脱敏包接收远端。推送时优先用本机 git 凭证;公开用户的免登录推送令牌待授权方签发受限账号后另行下发(勿在此写入管理员令牌)。亦可用环境变量 ZIPING_SHARE_GITHUB_REMOTE / ZIPING_SHARE_GITEA_REMOTE 覆盖(可在 URL 中内嵌凭证)。" "_note": "脱敏包接收远端。gitea_remote 内嵌的是 archives-uploader 受限账号令牌(仅数据仓可写,泄漏也只影响数据仓);GitHub 线路依赖本机 git 凭证。环境变量 ZIPING_SHARE_GITHUB_REMOTE / ZIPING_SHARE_GITEA_REMOTE 可覆盖。"
} }
+113
View File
@@ -0,0 +1,113 @@
"""chart_shot.py 与 cast_chart --shot:HTML → PNG 截图的分级回退与优雅失败。"""
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SKILL_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
import chart_shot # noqa: E402
CHROME_OK = Path(chart_shot.CHROME).is_file()
try:
import playwright # noqa: F401
PLAYWRIGHT_OK = True
except ImportError:
PLAYWRIGHT_OK = False
SHOT_OK = CHROME_OK or PLAYWRIGHT_OK
HTML = "<!DOCTYPE html><html><body><h1>命盘</h1></body></html>"
class ShootUnitTest(unittest.TestCase):
def test_missing_html_exit_2(self):
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "chart_shot.py"),
"/nonexistent/x.html"], capture_output=True, text=True, timeout=30)
self.assertEqual(r.returncode, 2)
self.assertIn("ERROR", r.stderr)
def test_no_tools_fails_gracefully(self):
"""playwright 与 Chrome 都不可用时:shoot 返回 None,CLI 退出码 2 并给提示。"""
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
with mock.patch.object(chart_shot, "_shot_playwright",
return_value=False), \
mock.patch.object(chart_shot, "_shot_chrome",
return_value=False):
self.assertIsNone(chart_shot.shoot(html))
self.assertFalse(html.with_suffix(".png").exists())
with mock.patch.object(sys, "argv",
["chart_shot.py", str(html)]):
with mock.patch("sys.stderr") as err:
self.assertEqual(chart_shot.main(), 2)
err.write.assert_called()
def test_content_height_bounds(self):
"""高度估算不越界:空页夹到下限,超长页夹到上限。"""
with tempfile.TemporaryDirectory() as td:
empty = Path(td) / "e.html"
empty.write_text(HTML, encoding="utf-8")
self.assertEqual(chart_shot._content_height(empty), 1300)
big = Path(td) / "b.html"
big.write_text(
'<div class="strip">' + '<div class="cell"></div>' * 5000
+ "</div>", encoding="utf-8")
self.assertEqual(chart_shot._content_height(big), 4200)
@unittest.skipUnless(SHOT_OK, "环境无 playwright 与 Chrome,跳过真实截图")
class ShootRealTest(unittest.TestCase):
def test_real_png(self):
"""真实截图:PNG 非空、宽 2200(1100 窗口 × 2 倍分辨率)。"""
import struct
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
png = chart_shot.shoot(html)
self.assertIsNotNone(png)
data = png.read_bytes()
self.assertGreater(len(data), 1000)
self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n")
w, _h = struct.unpack(">II", data[16:24])
self.assertEqual(w, 2200)
def test_cli_success(self):
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
out = Path(td) / "o.png"
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "chart_shot.py"),
str(html), "-o", str(out)],
capture_output=True, text=True, timeout=120)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn(str(out), r.stdout)
self.assertGreater(out.stat().st_size, 0)
@unittest.skipUnless(SHOT_OK, "环境无 playwright 与 Chrome,跳过 --shot 端到端")
class CastShotE2ETest(unittest.TestCase):
def test_shot_flag_outputs_paths(self):
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "cast_chart.py"),
"--name", "张三", "--gender", "男", "--birth", "1990-05-14 08:30",
"--place", "四川省成都市", "--html", str(html), "--shot"],
capture_output=True, text=True, timeout=120,
cwd=str(SKILL_ROOT / "scripts"))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertTrue(r.stdout.startswith("# 命盘:张三"))
self.assertIn(f"HTML 盘面:{html.resolve()}", r.stdout)
png = html.with_suffix(".png")
self.assertIn(f"盘面截图:{png.resolve()}", r.stdout)
self.assertGreater(png.stat().st_size, 0)
if __name__ == "__main__":
unittest.main()
+193
View File
@@ -0,0 +1,193 @@
"""HTML 盘面渲染测试(render/html.py + cast_chart --html)。"""
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SKILL_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
def _chart():
"""用真实排盘管线造一份 ChartData(成都 1990 男盘)。"""
from datetime import datetime
from astro.solar_time import to_true_solar_time
from astro.timezone_resolve import to_utc
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 geo.cities import resolve_location
loc = resolve_location("四川省成都市")
clock = datetime(1990, 5, 14, 8, 30)
utc, tz_audit = to_utc(clock, loc)
tst, st_audit = to_true_solar_time(utc, loc.lon)
day, bd_audit = resolve_calendar_day(tst)
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
raw = cast(cast_dt, "男")
forward = is_forward("男", 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
decade = decade_pillars(raw["month"]["gan"], raw["month"]["zhi"], forward,
luck["start_age_years"], cast_dt.year)
years = [(y, gz, luck_label_for_year(y, decade))
for y, gz in flow_years(cast_dt.year, this_year, 10)]
return build_chart_data(
name="张三", gender="男", place="四川省成都市",
clock="1990-05-14 08:30", 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=flow_months(this_year),
childhood_luck=childhood_luck(raw["hour"]["gan"], raw["hour"]["zhi"],
forward, luck["start_age_years"],
cast_dt.year),
)
class HtmlRenderTest(unittest.TestCase):
def test_render_complete(self):
from render.html import build_html
html = build_html(_chart())
self.assertFalse(re.findall(r"\{\{[A-Z_]+\}\}", html), "存在未填充占位符")
for ch in ("庚", "午", "辛", "巳", "己", "卯", "丁"):
self.assertIn(ch, html)
for sect in ("排盘", "大运", "流年", "流月", "空亡", "神煞", "纳音"):
self.assertIn(sect, html)
def test_brand_head_embedded(self):
"""顶部品牌区:logo 与 LogoFont 均 base64 内嵌,口号在。"""
from render.html import build_html
html = build_html(_chart())
self.assertIn("灵爻妙解", html)
self.assertIn("传承易学智慧", html)
self.assertIn('class="brand-head"', html)
self.assertIn("data:image/png;base64,", html)
self.assertIn("@font-face", html)
self.assertIn("data:font/ttf;base64,", html)
def test_qrcode_card(self):
"""尾部交流群卡:二维码 jpeg base64 内嵌,标题与正文文案各出现一次。"""
from render.html import build_html
html = build_html(_chart())
self.assertIn('class="card qrcard"', html)
self.assertIn("data:image/jpeg;base64,", html)
self.assertEqual(html.count("灵爻妙解 · 传统文化交流群"), 1)
self.assertEqual(
html.count("扫码加入微信群:反馈解读偏差、交流文化心得、"
"参与档案共建,版本更新动态第一时间同步。"), 1)
def test_audit_versions_only_in_comment(self):
"""技术信息并入用户信息卡:展示行删版本段,完整原文留在 HTML 注释。"""
from render.html import build_html
html = build_html(_chart())
self.assertEqual(html.count("tyme4py"), 1)
self.assertEqual(html.count("schema"), 1)
m = re.search(r"<!--[^>]*tyme4py[^>]*-->", html)
self.assertIsNotNone(m, "注释中应保留含 tyme4py/schema 的完整审计原文")
self.assertIn("schema", m.group(0))
self.assertIn("时区", m.group(0))
self.assertIn("月令", html)
self.assertIn("日界 子初换日(23:00)", html)
def test_shensha_notes_stripped(self):
"""神煞括号注释在装配层剔除(全角/半角),Markdown 侧不受影响。"""
from render.html import _strip_sha_note, build_html
self.assertEqual(_strip_sha_note("将星(以年支子查,见于月支子)"), "将星")
self.assertEqual(_strip_sha_note("天乙贵人(以日干查)"), "天乙贵人")
self.assertEqual(_strip_sha_note("驿马"), "驿马")
html = build_html(_chart())
self.assertNotIn("(以", html)
def test_luck_flow_card_removed(self):
"""「大运 · 流年」对照卡已整张删除;排盘卡仍只含四柱。"""
from render.html import build_html
html = build_html(_chart())
self.assertNotIn("大运 · 流年", html)
self.assertNotIn("当前一步大运与本年流年对照", html)
self.assertNotIn("LUCK_FLOW_TABLE", html)
pan_seg = html[html.find("排盘<span"):html.find('class="sect">大运')]
self.assertIn("年柱", pan_seg)
self.assertNotIn("大运", pan_seg)
self.assertNotIn("流年", pan_seg)
def test_luck_strip_enriched(self):
"""大运格:起运年+干支之外带十神·星运/空亡/纳音/神煞(2018 甲申运对拍)。"""
from render.html import build_html
html = build_html(_chart())
seg = html[html.find('class="sect">大运'):html.find('class="sect">流年')]
self.assertIn("2018 · 28 岁起", seg)
self.assertIn("正官 · 沐浴", seg) # 甲对日干己 / 己对申
self.assertIn("午未", seg) # 甲申旬空
self.assertIn("泉中水", seg) # 甲申纳音
self.assertIn("天乙贵人 · 金舆 · 驿马 · 劫煞 · 孤辰", seg) # 甲申岁运神煞
self.assertIn('class="cell cur"', seg) # 当前一步高亮
def test_flow_year_strip_enriched(self):
"""流年格:年份+干支之外带主星/星运/空亡/纳音/神煞(2026 丙午对拍)。"""
from render.html import build_html
html = build_html(_chart())
seg = html[html.find('class="sect">流年'):html.find('class="sect">流月')]
self.assertIn("正印 · 临官", seg) # 丙对日干己 / 己对午
self.assertIn("寅卯", seg) # 丙午旬空(甲辰旬)
self.assertIn("天河水", seg) # 丙午纳音
self.assertIn("天德合 · 禄神 · 将星", seg) # 丙午岁运神煞
self.assertIn('class="cell cur"', seg) # 本年高亮
def test_strips_have_no_field_labels(self):
"""条带小卡只排值:不出现「主星/星运/空亡/纳音/神煞」字段标签。"""
from render.html import build_html
html = build_html(_chart())
segs = [html[html.find('class="sect">大运'):html.find('class="sect">流年')],
html[html.find('class="sect">流年'):html.find('class="sect">流月')]]
for seg in segs:
for label in ("主星", "星运", "空亡", "纳音", "神煞"):
self.assertNotIn(label, seg)
def test_ten_god_table(self):
from chart.ganzhi import ten_god
self.assertEqual(ten_god("庚", "丁"), "正官")
self.assertEqual(ten_god("庚", "丙"), "七杀")
self.assertEqual(ten_god("庚", "辛"), "劫财")
self.assertEqual(ten_god("庚", "壬"), "食神")
def test_void_and_terrain(self):
from chart.ganzhi import gz_void, terrain
self.assertEqual(gz_void("庚子"), "辰巳")
self.assertEqual(gz_void("甲子"), "戌亥")
self.assertEqual(terrain("庚", "巳"), "长生")
self.assertEqual(terrain("庚", "子"), "死")
def test_nayin_shared_with_shensha_module(self):
"""纳音表集中于 chart/ganzhi.py,chart/shensha.py 复用同一份。"""
from chart.ganzhi import NAYIN
from chart.shensha import NAYIN as SHA_NAYIN
self.assertIs(NAYIN, SHA_NAYIN)
self.assertEqual(NAYIN["甲辰"], "覆灯火")
self.assertEqual(NAYIN["丙午"], "天河水")
class CastHtmlE2ETest(unittest.TestCase):
def test_html_flag_writes_file(self):
with tempfile.TemporaryDirectory() as td:
out = Path(td) / "p.html"
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "cast_chart.py"),
"--name", "张三", "--gender", "男", "--birth", "1990-05-14 08:30",
"--place", "四川省成都市", "--html", str(out)],
capture_output=True, text=True, timeout=120,
cwd=str(SKILL_ROOT / "scripts"))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertTrue(r.stdout.startswith("# 命盘:张三"))
self.assertIn("HTML 盘面:", r.stderr)
html = out.read_text(encoding="utf-8")
self.assertIn("<table", html)
if __name__ == "__main__":
unittest.main()
+98
View File
@@ -16,6 +16,10 @@ LOOKUP = SCRIPTS_DIR / "memory_lookup.py"
ZHANGSAN = ["--name", "张三", "--gender", "男", ZHANGSAN = ["--name", "张三", "--gender", "男",
"--birth", "1990-05-14 08:30", "--place", "四川省成都市"] "--birth", "1990-05-14 08:30", "--place", "四川省成都市"]
# 晚子时案例:真太阳时落于 23:00–24:00,子初/子正换日四柱不同
WANZI = ["--name", "李四", "--gender", "女",
"--birth", "1996-12-08 00:30", "--place", "四川省乐山市"]
def run(script, mem_root, *args, use_env=False): def run(script, mem_root, *args, use_env=False):
env = dict(os.environ) env = dict(os.environ)
@@ -117,3 +121,97 @@ class TestLookupNoMatch:
def test_no_condition_exit_2(self, tmp_path): def test_no_condition_exit_2(self, tmp_path):
r = run(LOOKUP, tmp_path) r = run(LOOKUP, tmp_path)
assert r.returncode == 2 assert r.returncode == 2
class TestUpdateChart:
"""换盘(--update-chart):工作盘唯一——盘面段整体替换为新盘,frontmatter
与文件名同步,其余小节保留;旧盘信息由使用方记入反馈与澄清。"""
def test_zizheng_replaces_chart_and_renames(self, tmp_path):
r1 = run(SAVE, tmp_path, *WANZI)
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
path = Path(r1.stdout.strip())
assert "丙子庚子庚辰丙子" in path.name # 默认子初换日盘
# 模拟核对后的回写 + 固定 created_at
text = path.read_text(encoding="utf-8")
text = text.replace("(无)", "- 【已核实】2016 年离乡赴厦门读大学。")
text = re.sub(r'^created_at:.*$', 'created_at: "2000-01-01 00:00"',
text, count=1, flags=re.M)
path.write_text(text, encoding="utf-8")
# 用户确认子正换日:重排替换盘面段,日柱/时柱变 → 改新主键名
r2 = run(SAVE, tmp_path, "--update-chart", str(path),
"--day-boundary", "子正")
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
assert "日柱 庚辰→己卯" in r2.stderr and "时柱 丙子→甲子" in r2.stderr
assert "待复核" in r2.stderr # 变动柱影响条目提示复核
new_path = Path(r2.stdout.strip())
assert new_path != path and not path.exists()
assert "丙子庚子己卯甲子" in new_path.name
assert list((tmp_path / "archives").glob("*.md")) == [new_path]
text = new_path.read_text(encoding="utf-8")
meta = read_meta(text)
assert meta["pillars"] == '["丙子", "庚子", "己卯", "甲子"]'
assert meta["day_master"] == "己"
assert meta["created_at"] == "2000-01-01 00:00" # 保留
# 盘面段只剩新盘:日界审计与晚子时警示均为子正口径,无旧盘残留
assert "- 日界规则:子正换日(真太阳时 00:00)" in text
assert "已按「子正换日」定盘" in text
assert text.count("# 命盘") == 1
chart_section = text.split("## 盘面")[1].split("## 主要关注方向")[0]
assert "日柱:己卯" in chart_section and "日柱:庚辰" not in chart_section
# 其余小节保留
assert "- 【已核实】2016 年离乡赴厦门读大学。" in text
def test_birth_correction_updates_meta(self, tmp_path):
r1 = run(SAVE, tmp_path, *ZHANGSAN)
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
path = Path(r1.stdout.strip())
r2 = run(SAVE, tmp_path, "--update-chart", str(path),
"--birth", "1990-05-14 10:30")
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
assert "时柱" in r2.stderr and "待复核" in r2.stderr
new_path = Path(r2.stdout.strip())
assert new_path != path and not path.exists() # hash6 随 birth 变
meta = read_meta(new_path.read_text(encoding="utf-8"))
assert meta["birth_clock"] == "1990-05-14 10:30"
def test_same_chart_keeps_filename(self, tmp_path):
r1 = run(SAVE, tmp_path, *ZHANGSAN)
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
path = Path(r1.stdout.strip())
r2 = run(SAVE, tmp_path, "--update-chart", str(path))
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
assert Path(r2.stdout.strip()) == path # 四柱未变,文件名不动
assert "待复核" not in r2.stderr
def test_conflict_refuses_overwrite(self, tmp_path):
run(SAVE, tmp_path, *WANZI)
olds = list((tmp_path / "archives").glob("*.md"))
assert len(olds) == 1
old = olds[0]
r = run(SAVE, tmp_path, "--update-chart", str(old),
"--day-boundary", "子正")
assert r.returncode == 0, f"stderr:\n{r.stderr}"
# 重新建子初盘档案,再换子正 → 目标已存在,拒绝覆盖
run(SAVE, tmp_path, *WANZI)
r = run(SAVE, tmp_path, "--update-chart", str(old),
"--day-boundary", "子正")
assert r.returncode == 2
assert "已存在" in r.stderr
assert len(list((tmp_path / "archives").glob("*.md"))) == 2
def test_missing_archive_exit_2(self, tmp_path):
r = run(SAVE, tmp_path, "--update-chart",
str(tmp_path / "archives" / "nope.md"))
assert r.returncode == 2
def test_rejects_name_gender_overrides(self, tmp_path):
r1 = run(SAVE, tmp_path, *ZHANGSAN)
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
path = r1.stdout.strip()
r = run(SAVE, tmp_path, "--update-chart", path, "--name", "王五")
assert r.returncode == 2
+74
View File
@@ -0,0 +1,74 @@
"""运行环境守卫(scripts/_runtime_guard.py):版本判定文案、import 时拦截、入口脚本接线。
import 时拦截用子进程模拟:伪造 sys.version_info = (3, 9, 6) 后再 import,
守卫应打印中文报错并以退出码 3 结束进程。
"""
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
sys.path.insert(0, str(SCRIPTS))
import _runtime_guard as guard
ENTRY_SCRIPTS = ("cast_chart.py", "chart_shot.py", "memory_lookup.py",
"memory_save.py", "query_shensha.py", "self_update.py",
"share_export.py", "share_push.py")
def run_with_fake_py39(code: str) -> subprocess.CompletedProcess:
"""在子进程中把 sys.version_info 伪造成 3.9.6 后执行 code。"""
prefix = "import sys, runpy; sys.version_info = (3, 9, 6); "
return subprocess.run([sys.executable, "-c", prefix + code],
capture_output=True, text=True, timeout=60)
class TestVersionError:
def test_current_interpreter_passes(self):
assert guard.version_error() is None
def test_py39_denied_with_chinese_message(self):
msg = guard.version_error((3, 9, 6))
assert msg is not None
assert "Python 3.10" in msg
assert "3.9.6" in msg
assert sys.executable in msg
def test_boundary_versions(self):
assert guard.version_error((3, 10, 0)) is None
assert guard.version_error((4, 0, 0)) is None
assert guard.version_error((3, 8, 10)) is not None
assert guard.version_error((2, 7, 18)) is not None
class TestImportTimeGuard:
def test_import_denied_exits_3(self):
cp = run_with_fake_py39(
f"sys.path.insert(0, {str(SCRIPTS)!r}); import _runtime_guard")
assert cp.returncode == 3
assert "Python 3.10" in cp.stderr
def test_import_allowed_is_silent(self):
cp = subprocess.run(
[sys.executable, "-c",
f"import sys; sys.path.insert(0, {str(SCRIPTS)!r}); import _runtime_guard"],
capture_output=True, text=True, timeout=60)
assert cp.returncode == 0
assert cp.stderr == ""
def test_entry_script_intercepts_before_deep_error(self):
"""chart_shot.py 在 3.9 下原本 def 期即 TypeError;接线后守卫先拦截,报错清晰。"""
cp = run_with_fake_py39(
f"sys.argv = ['chart_shot.py', 'x.html']; "
f"runpy.run_path({str(SCRIPTS / 'chart_shot.py')!r}, run_name='__main__')")
assert cp.returncode == 3
assert "Python 3.10" in cp.stderr
assert "TypeError" not in cp.stderr
class TestEntryScriptsWired:
def test_every_entry_script_imports_guard(self):
for name in ENTRY_SCRIPTS:
src = (SCRIPTS / name).read_text(encoding="utf-8")
assert "import _runtime_guard" in src, f"{name} 未接入 _runtime_guard"
+33
View File
@@ -245,3 +245,36 @@ class TestCastChartIntegration:
cache_path.unlink(missing_ok=True) cache_path.unlink(missing_ok=True)
else: else:
cache_path.write_bytes(old_bytes) cache_path.write_bytes(old_bytes)
class TestFetchFallback:
def test_curl_fallback_when_urllib_ssl_fails(self, tmp_path, monkeypatch):
"""urllib 证书校验失败时回退系统 curl(macOS 框架版 Python 场景)。"""
import urllib.request
def _boom(*a, **k):
raise urllib.request.URLError("CERTIFICATE_VERIFY_FAILED")
monkeypatch.setattr(urllib.request, "urlopen", _boom)
fake_curl = tmp_path / "curl"
fake_curl.write_text("#!/bin/sh\ncat \"$3\" | sed 's|^file://||' | xargs cat\n",
encoding="utf-8")
src = tmp_path / "VERSION"
src.write_text("9.9.9\n", encoding="utf-8")
fake_curl.write_text(f'#!/bin/sh\ncat "{src}"\n', encoding="utf-8")
fake_curl.chmod(0o755)
monkeypatch.setattr(su.shutil, "which", lambda name: str(fake_curl))
assert su._fetch_text("https://example.invalid/VERSION") == "9.9.9\n"
def test_no_curl_no_urllib_raises(self, monkeypatch):
import urllib.request
def _boom(*a, **k):
raise urllib.request.URLError("SSL")
monkeypatch.setattr(urllib.request, "urlopen", _boom)
monkeypatch.setattr(su.shutil, "which", lambda name: None)
with pytest.raises(Exception):
su._fetch_text("https://example.invalid/VERSION")
+85 -9
View File
@@ -19,7 +19,7 @@ import _memory_store as store
PILLARS = ["庚午", "辛巳", "己卯", "戊辰"] PILLARS = ["庚午", "辛巳", "己卯", "戊辰"]
BIRTH = "1990-05-14 08:30" BIRTH = "1990-05-14 08:30"
PLACE = "四川省成都市" PLACE = "四川省成都市武侯区"
git = pytest.mark.skipif(shutil.which("git") is None, reason="无 git") git = pytest.mark.skipif(shutil.which("git") is None, reason="无 git")
@@ -31,11 +31,14 @@ def run(script, *args):
capture_output=True, text=True, timeout=120, env=env) capture_output=True, text=True, timeout=120, env=env)
def make_archive(mem_root: Path, feedback: str | None = None) -> Path: def make_archive(mem_root: Path, feedback: str | None = None,
"""造一份手工档案:四柱/大运/关注方向齐全,反馈小节可定制。""" events: str | None = None,
pillars: list[str] | None = None) -> Path:
"""造一份手工档案:四柱/大运/关注方向齐全,流年大事与反馈小节可定制。"""
pillars = pillars or PILLARS
meta = {"name": "张三", "gender": "男", "birth_clock": BIRTH, meta = {"name": "张三", "gender": "男", "birth_clock": BIRTH,
"birthplace": PLACE, "true_solar_time": "1990-05-14 08:23", "birthplace": PLACE, "true_solar_time": "1990-05-14 08:23",
"pillars": PILLARS, "day_master": "己", "pillars": pillars, "day_master": "己",
"created_at": "2026-10-01 10:00", "updated_at": "2026-10-08 15:00"} "created_at": "2026-10-01 10:00", "updated_at": "2026-10-08 15:00"}
body = ( body = (
"# 档案:张三(男)\n\n" "# 档案:张三(男)\n\n"
@@ -49,15 +52,19 @@ def make_archive(mem_root: Path, feedback: str | None = None) -> Path:
"## 小运\n\n(无)\n\n" "## 小运\n\n(无)\n\n"
"## 主要关注方向\n\n- 婚姻与事业走向\n\n" "## 主要关注方向\n\n- 婚姻与事业走向\n\n"
"## 归纳\n\n- 性格:未断\n- 身体:未断\n- 事业:未断\n- 感情:未断\n- 婚姻:未断\n\n" "## 归纳\n\n- 性格:未断\n- 身体:未断\n- 事业:未断\n- 感情:未断\n- 婚姻:未断\n\n"
"## 流年大事\n\n(未记录)\n\n" "## 流年大事\n\n"
"## 反馈与澄清\n\n" + (events if events is not None else
"- 2018(戊戌):【已核实】升职,自成都赴北京工作。\n"
"- 2021(辛丑):【已核实】张三搬家,新居在幸福小区3栋2单元501室,"
"电话 13812345678。\n")
+ "\n## 反馈与澄清\n\n"
+ (feedback if feedback is not None else + (feedback if feedback is not None else
"- 【已核实】2018 年升职,跳槽到外企\n- 【未核实】2020 年感情波动\n") "- 【已核实】2018 年升职,跳槽到外企\n- 【未核实】2020 年感情波动\n")
) )
arch_dir = mem_root / "archives" arch_dir = mem_root / "archives"
arch_dir.mkdir(parents=True, exist_ok=True) arch_dir.mkdir(parents=True, exist_ok=True)
path = arch_dir / store.archive_name( path = arch_dir / store.archive_name(
PILLARS, "男", store.key_hash(BIRTH, PLACE, "男")) pillars, "男", store.key_hash(BIRTH, PLACE, "男"))
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8") path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
return path return path
@@ -77,11 +84,13 @@ class TestExport:
text = out.read_text("utf-8") text = out.read_text("utf-8")
pkg = json.loads(text) pkg = json.loads(text)
# 脱敏:不含姓名、出生分钟/钟表时间、省以下地域 # 脱敏:不含姓名、出生分钟/钟表时间、区县及以下地域、直接定位信息
assert "name" not in pkg assert "name" not in pkg
assert "张三" not in text assert "张三" not in text
assert "08:30" not in text and "08:23" not in text assert "08:30" not in text and "08:23" not in text
assert "成都市" not in text assert "武侯区" not in text
assert "幸福小区" not in text and "501" not in text
assert "13812345678" not in text
assert "birth_clock" not in pkg and "birthplace" not in pkg assert "birth_clock" not in pkg and "birthplace" not in pkg
# 结构字段 # 结构字段
@@ -90,17 +99,50 @@ class TestExport:
assert pkg["pillars"] == PILLARS assert pkg["pillars"] == PILLARS
assert pkg["birth_date"] == "1990-05-14" assert pkg["birth_date"] == "1990-05-14"
assert pkg["birth_shichen"] == "辰" assert pkg["birth_shichen"] == "辰"
# 地域放宽到城市级:省+地级市保留
assert pkg["region_province"] == "四川省" assert pkg["region_province"] == "四川省"
assert pkg["region_city"] == "成都市"
assert [l["pillar"] for l in pkg["luck_pillars"]] == ["壬午", "癸未", "甲申"] assert [l["pillar"] for l in pkg["luck_pillars"]] == ["壬午", "癸未", "甲申"]
assert pkg["luck_pillars"][0]["start_year"] == 1998 assert pkg["luck_pillars"][0]["start_year"] == 1998
assert pkg["focus_categories"] == ["婚姻", "事业"] assert pkg["focus_categories"] == ["婚姻", "事业"]
assert {f["year"] for f in pkg["feedback"]} == {2018, 2020} assert {f["year"] for f in pkg["feedback"]} == {2018, 2020}
assert {f["status"] for f in pkg["feedback"]} == {"已核实", "未核实"} assert {f["status"] for f in pkg["feedback"]} == {"已核实", "未核实"}
assert all(f["category"] for f in pkg["feedback"]) assert all(f["category"] for f in pkg["feedback"])
# 反馈带事件简介(状态标签已剥离)
fb = {f["year"]: f for f in pkg["feedback"]}
assert fb[2018]["note"] == "2018 年升职,跳槽到外企"
assert fb[2020]["note"] == "2020 年感情波动"
# 盘 hash = sha1(四柱+出生地) 前 8,文件名即 hash # 盘 hash = sha1(四柱+出生地) 前 8,文件名即 hash
assert out.stem == pkg["chart_hash"] assert out.stem == pkg["chart_hash"]
assert len(pkg["chart_hash"]) == 8 assert len(pkg["chart_hash"]) == 8
def test_events_carry_notes_with_city_kept(self, tmp_path, share_root):
arch = make_archive(tmp_path / "memory")
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
assert r.returncode == 0, r.stderr
pkg = json.loads(Path(r.stdout.strip()).read_text("utf-8"))
ev = pkg["events"]
assert len(ev) == 2
# 目标格式:2018(戊戌):升职,自成都赴北京工作。——城市级地名保留
assert ev[0]["year"] == 2018 and ev[0]["ganzhi"] == "戊戌"
assert ev[0]["label"] == "戊戌"
assert ev[0]["note"] == "升职,自成都赴北京工作。"
assert ev[0]["status"] == "已核实" and ev[0]["category"] == "事业"
# 姓名替换为「命主」,详细住址与电话删除,事件内容保留
assert ev[1]["year"] == 2021 and ev[1]["ganzhi"] == "辛丑"
assert "搬家" in ev[1]["note"] and "命主" in ev[1]["note"]
assert "(已删)" in ev[1]["note"]
def test_events_missing_still_exports(self, tmp_path, share_root):
# 流年大事为空不挡导出,events 如实为空
arch = make_archive(tmp_path / "memory", events="(未记录)\n")
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
assert r.returncode == 0, r.stderr
pkg = json.loads(Path(r.stdout.strip()).read_text("utf-8"))
assert pkg["events"] == []
assert len(pkg["feedback"]) == 2
def test_lookup_by_birth_place_gender(self, tmp_path, share_root): def test_lookup_by_birth_place_gender(self, tmp_path, share_root):
make_archive(tmp_path / "memory") make_archive(tmp_path / "memory")
r = run(EXPORT, "--birth", BIRTH, "--place", PLACE, "--gender", "男", r = run(EXPORT, "--birth", BIRTH, "--place", PLACE, "--gender", "男",
@@ -121,6 +163,20 @@ class TestExport:
"--share-root", str(share_root)) "--share-root", str(share_root))
assert r.returncode == 2 assert r.returncode == 2
def test_export_warns_orphan_pending_after_recast(self, tmp_path, share_root):
# 先导出原盘,再导出同出生日同性别但时柱不同的换盘 → WARN 提示残留,不自动删
arch = make_archive(tmp_path / "memory")
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
assert r.returncode == 0, r.stderr
arch2 = make_archive(tmp_path / "memory2",
pillars=["庚午", "辛巳", "己卯", "己巳"])
r2 = run(EXPORT, "--archive", str(arch2), "--share-root", str(share_root))
assert r2.returncode == 0, r2.stderr
assert r.stdout.strip() != r2.stdout.strip() # hash 确实变了
assert "WARN" in r2.stderr and "残留" in r2.stderr
assert r.stdout.strip().split("/")[-1] in r2.stderr # 列出了旧包文件名
assert len(list((share_root / "pending").glob("*.json"))) == 2 # 不自动删
@git @git
class TestPush: class TestPush:
@@ -190,6 +246,26 @@ class TestPush:
assert pkg.is_file() # 失败保留 pending assert pkg.is_file() # 失败保留 pending
st = json.loads((skill / "data" / ".license.json").read_text("utf-8")) st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 5 # 未计励 assert st["remaining_uses"] == 5 # 未计励
# 失败诊断:带该远端 git 报错末行;GitHub 线附 HTTPS_PROXY 提示
assert "FAIL" in r.stderr and "no-such-repo" in r.stderr
assert "HTTPS_PROXY" in r.stderr
def test_push_partial_failure_warns_but_succeeds(self, skill, tmp_path):
# github 坏、gitea 好:整体成功,但 stderr 带 github 线的失败摘要
(skill / "share" / "config.json").write_text(json.dumps(
{"github_remote": str(skill / "no-such-repo.git"),
"gitea_remote": str(tmp_path / "gitea.git")}, ensure_ascii=False),
"utf-8")
pkg = skill / "share" / "pending" / "baddcafe.json"
pkg.write_text(json.dumps({"schema": "share/v1",
"chart_hash": "baddcafe"}), "utf-8")
r = run(PUSH, "--root", str(skill))
assert r.returncode == 0, r.stderr
assert (skill / "share" / "pushed" / "baddcafe.json").is_file()
assert "WARN" in r.stderr and "github_remote" in r.stderr
assert "no-such-repo" in r.stderr and "HTTPS_PROXY" in r.stderr
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 15 # gitea 成功,正常计励
def test_no_remote_configured(self, tmp_path): def test_no_remote_configured(self, tmp_path):
root = tmp_path / "skill" root = tmp_path / "skill"
+66 -1
View File
@@ -9,7 +9,16 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from chart.shensha import evaluate_natal, evaluate_target # noqa: E402 from chart.ganzhi import GAN, ZHI # noqa: E402
from chart.shensha import STAR_ORDER, evaluate_natal, evaluate_target # noqa: E402
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
if _VENDOR.is_dir():
sys.path.insert(0, str(_VENDOR))
try: # 排盘适配层(含排序逻辑);无 tyme4py 的环境跳过其回归测试
from chart.tyme_adapter import _shen_sha
except ImportError: # pragma: no cover
_shen_sha = None
def natal(year, month, day, hour, gender="男"): def natal(year, month, day, hour, gender="男"):
@@ -267,5 +276,61 @@ class TargetQueryTest(unittest.TestCase):
self.assertEqual(own, {"十恶大败"}) self.assertEqual(own, {"十恶大败"})
def _jiazi60() -> list[str]:
return [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
# 甲子…乙亥:12 个干支覆盖全部 10 天干与 12 地支(月、时柱扫面用)
_SPREAD12 = _jiazi60()[:12]
class StarOrderExhaustiveTest(unittest.TestCase):
"""根治校验:求值器一切可能产出的神煞名都必须落在 STAR_ORDER 键集内。
3.6.0 事故:三奇贵人漏录排序表,排盘排序 KeyError。此处穷举求值器
全部输入维度(年柱 60 × 月柱 12 全干支行 × 日柱 60 × 时柱 12 全干支行
× 男女),收集实际产出的星名集合,断言与 STAR_ORDER 完全一致——
新增神煞忘补排序表、或排序表残留死项,都会被本测试当场抓住。
"""
def test_star_order_no_duplicates(self):
self.assertEqual(len(STAR_ORDER), len(set(STAR_ORDER)))
def test_natal_outputs_exactly_star_order(self):
produced: set[str] = set()
for year in _jiazi60():
for month in _SPREAD12:
for day in _jiazi60():
for hour in _SPREAD12:
for gender in ("男", "女"):
r = evaluate_natal(
{"year": year, "month": month,
"day": day, "hour": hour}, gender)
for entries in r.values():
produced.update(s for s, _ in entries)
self.assertEqual(produced, set(STAR_ORDER))
def test_target_outputs_subset_of_star_order(self):
"""岁运查询同理:年 60 × 月 4(四季各一)× 日 60 × 目标干支 60。"""
produced: set[str] = set()
for year in _jiazi60():
for month in ("甲寅", "乙巳", "丙申", "丁亥"):
for day in _jiazi60():
p = {"year": year, "month": month, "day": day, "hour": "甲子"}
for target in _jiazi60():
r = evaluate_target(p, "男", target)
produced.update(s for s, _ in r["anchored"])
produced.update(s for s, _ in r["own"])
missing = produced - set(STAR_ORDER)
self.assertEqual(missing, set())
@unittest.skipIf(_shen_sha is None, "tyme4py 不可用")
def test_sanqi_combo_survives_adapter_sort(self):
"""回归:combo 组三奇贵人经 tyme_adapter._shen_sha 排序不再 KeyError。"""
out = _shen_sha({"year": "甲辰", "month": "戊辰",
"day": "庚子", "hour": "辛巳"}, "男")
self.assertTrue(any(s.startswith("三奇贵人") for s in out["combo"]))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()