Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
415cb472b1 | ||
|
|
433c9b4a41 |
@@ -1,14 +1,14 @@
|
||||
# ZiPingEightChar · 子平八字(公众授权版)
|
||||
|
||||
> 子平八字排盘 + 《子平真诠》格局法解释的 AI 技能(Skill)。
|
||||
> 输入姓名、性别、出生日期时刻、出生地,输出结构化命盘 Markdown,并由模型依随附的 541 条编号规则完成取格、成败、用神相神、行运、六亲与流年流月解释。
|
||||
> 输入姓名、性别、出生日期时刻、出生地,输出结构化命盘 Markdown,并由模型依随附的 542 条编号规则完成取格、成败、用神相神、行运、六亲与流年流月解释。
|
||||
|
||||
## 这是什么
|
||||
|
||||
这是一个面向 AI Agent 的技能包(Skill),由两部分组成:
|
||||
|
||||
- **排盘脚本(`scripts/`)**:纯计算器,不含任何命理判断。把出生钟表时间换算成真太阳时(经度修正 + 均时差 + 夏令时回拨),经子初换日定日历日,调用内置的 tyme4py 排出命盘,输出自包含 Markdown:四柱、地支藏干、空亡、纳音、神煞(子平通行查法、注明锚点)、节令、大运、小运(童限)、全量流年(出生年至今 +10 年、逐年标注所属大运)、流月及排盘审计;生于交节前后 12 小时内的盘自带节气边界提示。
|
||||
- **规则库(`rules/`)**:541 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。
|
||||
- **规则库(`rules/`)**:542 条编号规则,以《子平真诠》格局法为骨架,蒸馏《滴天髓阐微》《穷通宝鉴》《命理探源》《三命通会》为参看层(`rules/sources/` 按书归档五书原文可供回查),含女命专篇、神煞细节、童限等拓展层(拓展条逐条标注【拓展】)。模型按 SKILL.md 的执行顺序加载规则完成解释。
|
||||
- **命主档案(`memory/`)**:本地留存每位命主的排盘与解读归纳(含「已核实/已证伪/未核实」三态反馈记录),同一命主再看时查档延续。档案只存本机,不随副本分发。
|
||||
|
||||
## 快速开始
|
||||
@@ -49,7 +49,7 @@ python scripts/cast_chart.py --name 张三 --gender 男 \
|
||||
|
||||
```
|
||||
SKILL.md 技能入口:用法、输出契约、解释规则、文风要求
|
||||
rules/ 541 条编号解释规则(20 个文件)
|
||||
rules/ 542 条编号解释规则(20 个文件)
|
||||
sources/ 五书原文归档(回查用)
|
||||
tests/golden/ 原书命例黄金集
|
||||
scripts/ 排盘脚本(astro/ chart/ geo/ render/ 分层)与岁运神煞查询
|
||||
|
||||
@@ -25,6 +25,8 @@ 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
|
||||
@@ -34,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` 锁定不变),两种方式输出一致。
|
||||
|
||||
## 输入参数(四个,全部必需)
|
||||
@@ -48,14 +50,16 @@ python scripts/query_shensha.py --gender 女 \
|
||||
|
||||
**时刻是必需的。** 时辰决定时柱,差一个时辰就是另一张盘。用户只给了日期(「1990 年 5 月 14 日」)时,**必须追问具体时刻**,不要自行假设 12:00 或任何时刻。
|
||||
|
||||
## Intake 追问话术
|
||||
## Intake 对话式询问(强制)
|
||||
|
||||
输入不全或不可用时,按退出码与下列口径追问,语气温和、一次只问必要的项;多项缺失时合成一次提问,不逐条单独打断:
|
||||
命理询问是分支式的——前一个回答决定下一个问题。**所有询问一律以普通对话消息进行,禁止调用结构化问卷/点选工具(AskUserQuestion 之类),也禁止在正文罗列编号问题清单让用户勾选。** 口吻是命理老师傅聊天:自然、简短、口语化。
|
||||
|
||||
- **缺时刻**:「时辰决定时柱,差一个时辰就是另一张盘,所以出生时间需要精确到小时。您大概几点出生?(如『早上 8 点半』『下午 3 点左右』均可)」若用户确实记不清:「那我按该时段的两个相邻时辰各排一张,并说明哪些结论会随时柱变化、哪些不受影响。」
|
||||
- **缺地点或地点无法解析**(退出码 2):「出生地用来换算真太阳时,差一个经度时辰就可能变。请告诉我您出生的城市或县(如『四川成都』『浙江义乌』)。」
|
||||
- **缺性别**:「性别决定大运顺排还是逆排,请补充一下性别。」
|
||||
- **场景判定**(断运、回溯类问题先问):「这盘是您本人看,还是替别人看?本人或知情人在场、能核对以往的事吗?」——定人称、定能否走核对轮(R-运-33)、定事件候选的颗粒度(命主本人可核到月份与部位,代看通常只能核到年份)。与缺项追问合成一次提问,不单独多占一轮。
|
||||
- **节奏**:一次最多问 1~2 个最关键的问题,得到回答再决定下一个问题。缺多项时按 时刻 → 地点 → 性别 的优先级逐个补,不把整串问题一次甩给用户。
|
||||
- **先铺垫再问**:盘已排出时,提问前先用一两句不依赖提问即可断出的盘面信息做铺垫(日主、月令、显著特征),再自然带出问题。例如:「这盘甲木生腊月,底子偏寒——先问一句:十来岁那年是不是住过院?」出生信息还不全、盘未排出时没有铺垫可用,直接问缺项即可。
|
||||
- **缺时刻**:「时辰决定时柱,差一个时辰就是另一张盘,出生时间得精确到小时。大概几点出生?(『早上 8 点半』『下午 3 点左右』都行)」若用户确实记不清:「那我按该时段的两个相邻时辰各排一张,哪些结论会随时柱变化、哪些不受影响,分开说明。」
|
||||
- **缺地点或地点无法解析**(退出码 2):「出生地用来换算真太阳时,差一个经度时辰就可能变。生在哪个城市或县?(如『四川成都』『浙江义乌』)」
|
||||
- **缺性别**:「性别决定大运顺排还是逆排,补一下性别。」
|
||||
- **场景判定**(断运、回溯类问题先问):「这盘是您本人看,还是替别人看?本人或知情人在场、能核对以往的事吗?」——定人称、定能否走核对轮(R-运-33)、定事件候选的颗粒度(命主本人可核到月份与部位,代看通常只能核到年份)。
|
||||
- **时间格式错误**(退出码 2):照 stderr 提示转述,不自行猜测改写。
|
||||
- **库内部错误**(退出码 3):把 stderr 原样报告给用户,**不要猜盘**,不要凭印象编造四柱。
|
||||
- **授权不可用**(退出码 4):把 stderr 原样转述给用户(次数用尽 / 已到期 / 授权文件被改动)。**不要尝试绕过或猜测授权状态**;需要续期请用户联系授权方(见 LICENSE.md)。
|
||||
@@ -66,7 +70,7 @@ python scripts/query_shensha.py --gender 女 \
|
||||
|---|---|---|
|
||||
| 0 | 成功 | 读 stdout 的 Markdown,进入解释 |
|
||||
| 2 | 输入不可用(出生地无法解析 / 时间格式错误或缺时刻) | 读 stderr 的提示,**向用户追问**后重试 |
|
||||
| 3 | 库内部错误 | 把 stderr 内容原样报告给用户。**不要猜盘**,不要凭印象编造四柱 |
|
||||
| 3 | 库内部错误 / 运行环境不符(Python < 3.10) | 把 stderr 内容原样报告给用户。**不要猜盘**,不要凭印象编造四柱 |
|
||||
| 4 | 授权不可用(软锁:次数用尽 / 已到期 / 授权文件被改动) | 把 stderr 原样转述给用户,**不要绕过** |
|
||||
|
||||
## 授权与分发
|
||||
@@ -103,13 +107,13 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
|
||||
- **节气边界提示**:出生真太阳时距交节不足 12 小时时,命盘开头出现 ⚠️ 提示。月令对出生记录误差敏感,误差超过提示值则月令改变、四柱全变;解释须声明此敏感性,断运证伪回查(R-运-33 第 4 步)时重排另一月令之盘对照。
|
||||
- **夏令时**: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`。
|
||||
|
||||
**执行顺序**:
|
||||
|
||||
0. **问题归类**:判定用户所问的主线(健康/感情/财运/事业/学业/六亲)、前瞻或回溯、以及场景(命主本人/替人看、能否核对——见 Intake 场景判定)。次级关注(隐藏线)只作附带:输出时以一条「顺带看了 X 这条线」声明并简答,不铺成第二主线;
|
||||
0. **问题归类**:判定用户所问的主线(健康/感情/财运/事业/学业/六亲)、前瞻或回溯、以及场景(命主本人/替人看、能否核对——见「Intake 对话式询问」场景判定)。次级关注(隐藏线)只作附带:输出时以一条「顺带看了 X 这条线」声明并简答,不铺成第二主线;
|
||||
1. 读命盘 Markdown,加载 `rules/00_总纲_取格.md`,按 §1 六步决策流程定格;
|
||||
2. 命中后加载对应分格文件(`rules/11_正官格.md` … `rules/19_杂格.md`),判成/败/救/应、用神相神、格局高低;
|
||||
3. 断运时加载 `rules/90_行运.md`(§7 断运操作序列定喜忌应期,§8 事类落地把关键流年译成现实事件候选,§9 断年核对回路,§10 童限)与分格文件的「取运」节;覆盖多个流年的批量断运按 R-运-33 **核对前置**——先以场景判定分流,可核对者先锚点核对、不可核对者直推,输出只出现一次。**推断任一具体流年/流月前,必须按 R-运-34 五步核对重新打开规则文件查表,不得凭会话记忆推断**;编号记录只列本轮实际查阅的条目(R-运-35),收调试层(见「调试与溯源」);
|
||||
@@ -134,6 +138,8 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
|
||||
|
||||
> 以上内容由灵爻妙解依据古籍《子平真诠》制作技能,ai生成,仅供娱乐参考,不替代医疗、法律、投资、心理咨询等任何专业意见。灵爻妙解,传承易学智慧,欢迎访问 https://lingyaomiaojie.com
|
||||
|
||||
**共建提示(首次讲解后)**:本次会话的第一次完整讲解(第 1–6 段全部输出完毕)之轮,在免责引用块**之前**顺带一句共建提示——第 6 段引用块保持一字不改、仍在全轮末尾;**同一会话只提示一次**,后续问答轮不再重复。口吻延续老师傅人设,自然一句带过,不写成广告,参考:「这盘先看到这。若愿意为这门手艺出份力——会话结束后直接发我『推送』二字,会把脱敏后的盘面存入共建档案库,用来校准后续版本;推与不推,都不影响看盘。」用户回应「推送」(或「推送黄金集」等明确意图)后,进入「档案共建(share/)」节既有流程:导出过目 → 授权问答 → 推送授权话术 → 推送计励。
|
||||
|
||||
**★边界声明的次数上限**:单轮回答中「这一层我给不出 / 这不是干这个的 / 硬报数字就是忽悠」型的方法边界说明**至多出现一次**,且必须并入第 5 段,不得另起一段、不得打断解读节奏。其余场合一律改用模糊语自然带过(见「文风」节)。声明本身不得写成辩解或自嘲。
|
||||
|
||||
**阶段纪律**:落笔之前遇下列情形,**必须先向用户声明分歧再继续**——
|
||||
@@ -148,10 +154,10 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
|
||||
|
||||
## 断运交互(核对前置)
|
||||
|
||||
用户问「过往发生了什么 / 哪几年是转折」这类覆盖多个流年的问题时,按 `rules/90_行运.md` R-运-33 执行**核对前置**——所有提问发生在铺开断语之前,输出只出现一次:
|
||||
用户问「过往发生了什么 / 哪几年是转折」这类覆盖多个流年的问题时,按 `rules/90_行运.md` R-运-33 执行**核对前置**——所有提问发生在铺开断语之前,输出只出现一次。核对同样走对话式询问(见「Intake 对话式询问」):不用结构化工具、不列编号选项,一次最多问一两个锚点,答完再定下一个;R-运-33 的核对程序(锚点选取、候选排序、证伪回查)照旧执行。
|
||||
|
||||
1. **先定场景**:intake 一问分流(命主本人还是替人看、能否核对过往),与缺项追问合成一次提问;
|
||||
2. **可核对 → 锚点核对**:挑 3 个左右锚点年(组数最多、落在人生阶段窗口、用户所问之线各占其一),每个锚点年一个问题,选项为按可能性排序的 2–3 个候选事件、外加「都对不上」(证伪入口);几个锚点年合成一次结构化提问,不逐条单独打断。断语涉及学业年份时,学制锚点并入同一次提问,不单独多问一轮。核对后按 R-运-32 第 4 步收敛,一次性输出完整断运。无询问工具的环境,在正文列出编号选项请用户回复编号;
|
||||
1. **先定场景**:intake 一问分流(命主本人还是替人看、能否核对过往);
|
||||
2. **可核对 → 锚点核对**:挑 3 个左右锚点年(组数最多、落在人生阶段窗口、用户所问之线各占其一)。每问先给铺垫——该年盘上的显著信号一两句——再自然带出按可能性排序的 2–3 个候选事件,外加「都对不上」(证伪入口);用户答完再挑下一个锚点。断语涉及学业年份时,学制锚点顺嘴并入当轮提问,不单独多问一轮。核对后按 R-运-32 第 4 步收敛,一次性输出完整断运;
|
||||
3. **不可核对 → 直推**:命主不在场、不知情,或用户明言测试、「不用问、直接说」时,跳过核对,一次性输出最佳推断——直答给主答案与置信度,全量候选收分析推理段。
|
||||
|
||||
锚点全部落空时先回头查组数判定、阶段假设、时辰边界与节气边界,不硬铺(R-运-33 第 4 步)。
|
||||
@@ -189,15 +195,19 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
|
||||
`python scripts/memory_lookup.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男`
|
||||
(亦可按 `--name` / `--pillars "丁亥 乙巳 庚子 辛巳"` 任意组合检索)。有档则读档延续——此前的归纳、已核实/已证伪反馈在分析推理时直接参用,已证伪的断法不得再断;无档则正常排盘解释,解释完成后用 `python scripts/memory_save.py --name ... --gender ... --birth ... --place ...` 建档(建档即一次排盘,授权副本照常扣次)。
|
||||
- **回写纪律**:用户核对或澄清了任何断语后,当轮把结果写回该档案的 `## 反馈与澄清` 小节,每条以状态标签开头——推测性归纳标【未核实】,命主确认改【已核实】,被否改【已证伪】并补一句实际情况;结构性结论同步更新 `## 归纳` 各行。
|
||||
- **换盘纪律(工作盘唯一 + 变更留痕)**:经用户确认换盘(日界规则分歧、时辰/出生地校正等)后,**必须当轮重排并把档案 `## 盘面` 段整体替换为新盘**——档案任何时候只有一个工作盘,旧盘不得留在盘面段,防止续推时误用。操作:
|
||||
`python scripts/memory_save.py --update-chart <档案路径> [--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后地点"]`
|
||||
(重排一次,授权副本照常扣次;frontmatter 四柱/日主同步为新盘,四柱变动时文件自动改为新主键名;目标档案已存在则拒绝覆盖)。留痕:旧盘四柱、换盘原因、确认方式与时间记入 `## 反馈与澄清` 校订条目;凡受变动柱(如日柱/时柱)影响的已有推断条目(`## 归纳`、`## 流年大事` 各行),同轮标记【待复核】,复核前不得继续引用。
|
||||
- **隐私**:档案属命主个人信息,只存本机、不随授权副本分发、档案原文不进 share;不得把档案原文外发(不粘贴到外部对话、不上传网络、不写入其他文件)。回传授权方的只有按下节规格生成的结构化脱敏包,且必须经用户授权。
|
||||
|
||||
## 档案共建(share/)
|
||||
|
||||
用户可自愿把**脱敏后**的命盘数据回传授权方,用于技能优化与规则蒸馏(脱敏规格、隐私协议与撤回方式见 `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/`(用户可随时删除),不影响技能正常使用。
|
||||
- **推送**:`python scripts/share_push.py`,把 `share/pending/` 的包推送到 `share/config.json` 配置的共建远端(GitHub 与 Gitea **两条线都推**,至少一个成功即算成功;远端地址由授权方填写)。推送成功后包移入 `share/pushed/`,并告知用户新增授权次数(+10;该盘已计励过则不重复加)。推送失败(无 git / 无网络 / 远端不可达)保留 pending,不丢数据。
|
||||
- **推送授权话术(宿主权限层)**:调用推送脚本时宿主会请求执行权限,请用户确认——**「点一下允许」是唯一主路径**。① **推送前先预告**:得到用户口头授权后、调用脚本前,先发一句话预告,如「接下来运行推送脚本,你的环境可能弹出执行权限确认,点『允许』即可」;② **被拦即停、零重试**:脚本被权限层拦截后不得重试(权限状态不会因重试改变),直接向用户说明并问「是否看到权限确认?点允许我就能继续」;③ 终端手动执行 `python scripts/share_push.py` 仅作为宿主静默拦截(不弹确认、直接拒绝)时的兜底,向用户提及时一句话带过,**不列为推荐步骤**;④ **话术宿主中立**:不写死任何具体宿主的权限界面名称,统一表述为「宿主会请求执行权限,请用户确认」。
|
||||
|
||||
## 版本与自更新
|
||||
|
||||
@@ -205,11 +215,14 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
|
||||
- **更新提示(缓存制、不打扰排盘)**:每次排盘成功后,若本地缓存的检查结果已过期(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。
|
||||
|
||||
## 盘面图(HTML)
|
||||
## 盘面图(HTML,默认生成)
|
||||
|
||||
排盘后同步生成 HTML 盘面图(`cast_chart.py --html`):四柱主表(当前大运与流年并列对照)、大运/小运条、全量流年、当年流月,五行配色、空亡标虚线、节气边界与晚子时出警示条。模板在 `scripts/render/templates/paipan.html`,样式可直接改。
|
||||
拿到出生信息排盘后,**默认立即生成盘面图、截图并发给用户**,然后才进入对话式核对与分析。流程次序:排盘(`cast_chart.py --shot`)→ 生成盘面图并发送 → 对话式核对/补缺 → 分析 → 结论。
|
||||
|
||||
- **给用户看图**:agent 环境具备浏览器/发图能力时,排盘后生成 HTML 并直接打开,或截图发送给用户;纯文本环境则告知文件路径。
|
||||
- **生成**:`--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 腔(强制)
|
||||
|
||||
+36
-25
@@ -2,13 +2,13 @@
|
||||
"files": {
|
||||
".gitignore": "1e4671dee2d3123a45609ed82a4329531e0d41bf1b6895ab169ecd8813cfb8f5",
|
||||
"LICENSE.md": "6b839540e0fd4ab75530ec31fc5c3cbc3c2d5de5fe85068ef1c0901e54be9315",
|
||||
"README.md": "1e11e5d19856fbd9bb15068c74e184617a10b2e0b18c7e42d9b617cd0d8f534a",
|
||||
"SKILL.md": "eaff2bc3cc1221417caaf40cf86741c7fe2e43f7538fd9efc0f436c13c030a0b",
|
||||
"VERSION": "eb41f86c3d67299b46490cde10158a521148fdc03641aa14a93fa2797becd78c",
|
||||
"README.md": "adb4481f20360c66e67fd89076c1535c577cdf85f34122d2dac1ff727dd81c38",
|
||||
"SKILL.md": "720aa2de6e7e6f65c44f63421f0aece401146921e54a6c99f24db1af14241813",
|
||||
"VERSION": "de58d841bd709aa85a66ecc500dfbb48e7c207e84c399e30e751e8ccad6e8c9b",
|
||||
"data/.public_edition.json": "e8692c45d512c78903fc89a924d0e6d4dc9b95a78ca6b359bb7a5ef5c946ffee",
|
||||
"data/cities.json": "00f17905c8843218162e25238a78aeb4c4a3bb5424b611cc30ed9e436db31854",
|
||||
"memory/README.md": "b3cf2c58a8418a8c1a54243c2e09fbdb42f31dcd48ba8c841b74f1234c7fe063",
|
||||
"pyproject.toml": "58e415e53d2418721fa07bd81b829e4bc5bbfb2155a446bce6222d3f7afeda12",
|
||||
"memory/README.md": "a979f985dd87e87bfca49676ebf10a537d299b1eeebac121c506c320c88a3afd",
|
||||
"pyproject.toml": "b202c2ca784d501959d3dc4d7024851de8b66b15a7e3368b81566b2e045601b4",
|
||||
"requirements.txt": "46a4c971b192cbf2c4972dd0f420e7928b7e44cd3cb9f7dc5a36efb321ab6fd6",
|
||||
"rules/00_总纲_取格.md": "829a35454f5c91966ecafe4dd2113b913588933efe08d0e1c127ae82ccec0da8",
|
||||
"rules/11_正官格.md": "385193d93dfba89a935ca8907098f70548ad1fc12ab6a07a5280fd04224c83b6",
|
||||
@@ -21,7 +21,7 @@
|
||||
"rules/18_建禄月劫格.md": "f0b400292d22cb3b38ab5011bda8080d511a065c661305f7a9c0485f1c57705c",
|
||||
"rules/19_杂格.md": "78939bc9d61621a427fd41a31156086b3ae6675eccc77c899a3aa7443408ef26",
|
||||
"rules/89_旺衰扶抑.md": "758180d5ee0aa46f4b22e8817baa831974c9c3b239078ba51e0a7b9016f7f47b",
|
||||
"rules/90_行运.md": "510c243e84f3cffacf0a85887b8cc8650bb009083112390c50d5f8ab8c969587",
|
||||
"rules/90_行运.md": "bacb9d99d2c1b117b0a9c297712068f3d41c73aa210ee01d2d2754d017bdf438",
|
||||
"rules/91_何知事类.md": "3775448e1830929dbcbc90cacf1065a4ab0592c9c4ee984c09ec187911bcf2c1",
|
||||
"rules/92_调候.md": "8d37d2cb722ae4b581532b7b73218fd648f1e75d8e16c6bedbabe0f93a2e1946",
|
||||
"rules/93_性情.md": "c9d3003cf4e0869e5075623e34bad41faa294623df1b2481e0f30b80fc61cec6",
|
||||
@@ -30,7 +30,7 @@
|
||||
"rules/96_六亲.md": "96cf362bd180558352d0181401ac8e297ac89c09d91d1098972a9d1c40468198",
|
||||
"rules/97_女命.md": "52962aa666faa3fa2a5175d352380df14f54fefb8f57cfe0761779ad51e18696",
|
||||
"rules/99_附录_干支通例.md": "da906502391833eb1744ff0145a03c7d1e10781ac7156034632dc5994da0d2f4",
|
||||
"rules/README.md": "80a77a744fcac31673dd7eb9f84cb80b83c9d61c4d0aaabcb39e86f98f661c2f",
|
||||
"rules/README.md": "e08256c5fe13683bab93076b19fde18c1c1455053dec83f37121e4718817d489",
|
||||
"rules/sources/README.md": "183ba554b1d89c16b19fb65ac8a4d0614069c865676fb4c1bd20d95b5581c209",
|
||||
"rules/sources/三命通会/00_卷一·原造化之始.md": "6329058dd08aed5f41d1111da99027cac38761aeea7120a84eff02683426294d",
|
||||
"rules/sources/三命通会/01_卷一·论五行生成.md": "c9db87683a6d3f9b7eea665e3814e383e11a69b608114d9317beb2a2c56825f8",
|
||||
@@ -565,46 +565,57 @@
|
||||
"rules/tests/golden/97_女命.yaml": "dc229eae558be6592f392be4f71e72ec6650d64590c9bac3361febed9ead9b89",
|
||||
"scripts/_license_gate.py": "4f3a24685c86abc6c66a158b441b89e2738bd14f5d63e8bdd41489654a29e837",
|
||||
"scripts/_memory_store.py": "690e088a208ee5aeb9f66e6d4b5d4342f44bfc852bad51870cdfe397fbdf7660",
|
||||
"scripts/_runtime_guard.py": "c4f9d00acbb5b2ba49680b23ed4f846c60211e67b9d58950079b3e8909dcfe81",
|
||||
"scripts/_update_sig.py": "9bb3521b950ab834c770e313a98c9cc8f7e997997540258724505af774a4abe6",
|
||||
"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/solar_time.py": "35b45edc365083a6ce90afe076176c6f3e798509e76d3f03819fd1845491b153",
|
||||
"scripts/astro/timezone_resolve.py": "3a287a64bd50736ce91ac15876e88edeb98745eb6d0459cf18e7e6ee3c026aba",
|
||||
"scripts/cast_chart.py": "16e5e9f0df1bcb0c0eef1febd1ae2395a84c1676e0c629058821056861daa05e",
|
||||
"scripts/cast_chart.py": "9b9f33cfae9af797859554c630529d8fc5c27c5688a0414fbc5038ae2e5c90d8",
|
||||
"scripts/chart/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"scripts/chart/ganzhi.py": "94d53d2a8cd7674312223e976ce6e1379a6fbb53873253b34963b3e3d645508e",
|
||||
"scripts/chart/luck.py": "d930f3f402cf814eaaa10fdcaa0a8be4a47411e6961dd0324ac38a8f898b4299",
|
||||
"scripts/chart/model.py": "8279145da10901cfb1a7b4823762074169dfd554bbe8ce39ae15862334b001f6",
|
||||
"scripts/chart/shensha.py": "1a9a59fa35ad5f2e2700d6b1a0155979dd7bf6f9fea4637356bfd41c5eae7e05",
|
||||
"scripts/chart/shensha.py": "9db4a0e4cfaaa667efa9bbdcf7b8fd3d0c461cafbdf125e037cb467d75803939",
|
||||
"scripts/chart/tyme_adapter.py": "06a46f16efc3d745edb5f35bbdcfee1c78552a44235fc85de0af3eb6c163eaca",
|
||||
"scripts/chart_shot.py": "0096a01e64128a2628ee0e2d3bc211d93fbb0e2b17a49a34f4f2f9a37da4960c",
|
||||
"scripts/geo/__init__.py": "1211b885ec767b399472df572f970b07d6d028a5889af49fe50f6fc1a2a0cf7d",
|
||||
"scripts/geo/cities.py": "9d393608c03a2ad5c7352939e70ea717dae5dd4bc833dfdd0e2f663940c61fb5",
|
||||
"scripts/memory_lookup.py": "853c815a97b457ca20d5b4c03aff234dfb218a58a5511f6c1a55dc635de31c90",
|
||||
"scripts/memory_save.py": "42638841cceb586783b8764eacff91a8518d8df7dfe99b3919b3caa5a432e0ec",
|
||||
"scripts/query_shensha.py": "1d77205c8781676f3536bbeb2f36d397b825ff3065a4b968344fb95e5b8859dd",
|
||||
"scripts/memory_lookup.py": "23f02b04636924a1f6872d338e85ee2145ed1cde742b3e2e5b6ee4592ff92d50",
|
||||
"scripts/memory_save.py": "cfdf6dcd652475fb9bf04521655581dad10f36d7d7f69d079bf07833b99ba4e6",
|
||||
"scripts/query_shensha.py": "e4c852c8e56fb8b32bde0fb376d985647bcfd93a74fe30fd34c5884d2ab77dc3",
|
||||
"scripts/render/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"scripts/render/html.py": "9896ffc7c1f29cdce0d2ce9c21ce78ec6035b77851249466776945de918de49d",
|
||||
"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": "f6054c3fddc9223e32061bbc2bb88c01b9888606baf118ab3ddfb1d11f11ec14",
|
||||
"scripts/render/markdown.py": "4b2bcc3fc6581728d74bbaa357bf1db2e8aec1d3ec54c2c27ca645b6c5179977",
|
||||
"scripts/render/templates/paipan.html": "9cdeed44834ef0de7adf0d775486b8ece7c81cf228107295e51bc31847a81688",
|
||||
"scripts/self_update.py": "b1c9ad0ef90e20ac75ac6b8e857a4a5bd31181c67a58c7c1d46b45f468fd9f26",
|
||||
"scripts/share_export.py": "21396143ad5cc2d244c7cc060a3ece25702594465689a9f12438dfe92de3296f",
|
||||
"scripts/share_push.py": "42683c27a93f12208c20b0a5cd2feb7cd81f9be3efb76b46d4789ad4d2aab4ca",
|
||||
"share/README.md": "42ebed92285752af93c7c7af5eb4b6e84ed836408d0cedb57e1b32c796746b26",
|
||||
"scripts/render/templates/paipan.html": "62fc3fcf355089cbedfc9e7b39fae54bb93e91cab73be3c376145b2643853a28",
|
||||
"scripts/self_update.py": "c7bbb58e750fdbbecad00936948e37d7ff7a47c57e22d116c537c104aa74e8de",
|
||||
"scripts/share_export.py": "eab38048e3737de1eb8731e6f4860f4061d29dedb9e9f3a1c9d9ac44ec200c20",
|
||||
"scripts/share_push.py": "307e83747b8d244f20632a198b7bfba51352a6b6bf0a8f98b73c904e719a6b89",
|
||||
"share/README.md": "adccd0f7691bedd64441b11cdd8ce66e20adce55fe4d8de1333f531b689f82b8",
|
||||
"share/config.json": "b0747cb29b01e65c79e612062e1d572f6658c3046ae8bb5d97020ab2d7c6f04e",
|
||||
"tests/golden/history_tz.yaml": "3fb74e90c4f53abebaac0a5de8825ac3794fc0b5b41dc84121201e70cecade47",
|
||||
"tests/golden/longitudes.yaml": "6fda1e6b632d29c135362c9273b931c4416c9f24c31b07e2a7baadc6e2077bc2",
|
||||
"tests/golden/time_boundary.yaml": "637090a1c081b9b824fc0073d29dad073d29138c3b48082bdf41ce77f5af3b37",
|
||||
"tests/test_chart_shot.py": "7923d2868ded898331a65152b237cf014fa49b180b08706584ff7319f88b7a33",
|
||||
"tests/test_day_boundary.py": "0e6c9ee32177a178f21a7812f83032abe4d1f585de48d07d21e2b432bdbcbd04",
|
||||
"tests/test_e2e.py": "e324930fad582dc38618fa3599425bb48ec718beaf426efcb76531911c9464fa",
|
||||
"tests/test_equation_of_time.py": "8a3a1baf7e61c42015405cc80fc0bdf4fd9bc732d440bc156f9c92c94e30f790",
|
||||
"tests/test_html.py": "0972e938f37e7f2f70569cc3abbd9bf313a2610cd227537dcde6add010d46737",
|
||||
"tests/test_html.py": "d40f4558ddfda156706a0c3d4c52c794fbc1e59427acdc93bcdad7df34217638",
|
||||
"tests/test_license_gate.py": "ee44226b75620b3965ba52cc2a0cc5404fbff37822fd96a6faf2aed89d07d47e",
|
||||
"tests/test_luck.py": "821f98bc19ab1e920353de58c6ffacac5d757c887c690a2d596f51c1485b21ea",
|
||||
"tests/test_memory.py": "d3decfdc8d7c5bbfc4af5e3aed93bb3c53ab09071b682a0f4808614ac2cd3c6f",
|
||||
"tests/test_self_update.py": "183c2fb82d2d20285941b6c75a1890c4417d400c3c7e7d5f8a9680b4fa7bf76c",
|
||||
"tests/test_share.py": "399f20da22689d00e71eb78c76d60507060c29a7d68865e6b0e9f591d19be871",
|
||||
"tests/test_shensha.py": "b8a20d264e18c79f1fe546281381b0d929543af44ec68dc890ef8b64efc625b0",
|
||||
"tests/test_memory.py": "068241927df08301806451ff6e66206ceb9d439d95197b3506fe519d892c28ed",
|
||||
"tests/test_runtime_guard.py": "7fd7e2f334433432452ea68fde30df1698def2126a1920f8da6d6aab236c1caf",
|
||||
"tests/test_self_update.py": "87a0fdaf28132e70fe407a552ff77f5911e13e67ca1d568581a273eaba875712",
|
||||
"tests/test_share.py": "7938344415cbba95f683b2a09ecec9af28d00658f475ff0a56563dd13beff630",
|
||||
"tests/test_shensha.py": "2aaa181279739356828f05a2175027b0ae711b322b4bd0e60d5c5e62538bcac8",
|
||||
"tests/test_solar_time.py": "f072e5ea82fd0cb331c8a4fdc6dce08148cd9ac81ef9fe4506196fa68a42d1e4",
|
||||
"tests/test_sync_tool.py": "67395f121cfa0092af7edb579dac54a07690b989eb82bb16c6ea815325e47ce3",
|
||||
"tests/test_timezone.py": "3698878999d232bd431d6908c42fe8f4845c696da6085731abaf2f74017d722d",
|
||||
"tests/test_update_sig.py": "3bfd6ce28a699afe233fe51d9d9c7aacc308f54a4dcb9f9e7ecd272bb0a9b5a2",
|
||||
"tests/test_version_sentinel.py": "9b2bc603a2e86aa33d904f1381ecd8ab41eb0e759803ec47122d662c10a7df83",
|
||||
"vendor/VENDOR.md": "dbf017616ac2fda4fef56ce95e3610e77457108b7fa426136fd25764d78fa3c8",
|
||||
"vendor/tyme4py.LICENSE": "e017c12d3476152144886c06e12b67277bb64e922b88c902b76ac02a84ea1c9d",
|
||||
@@ -1268,5 +1279,5 @@
|
||||
"vendor/tzdata/zoneinfo/zonenow.tab": "7f9d1c11445af7a17862c20a9081226d31a09a1730e270ff59a34c8d2b4c7f7f",
|
||||
"vendor/tzdata/zones": "28d8f3eb25839095fa8a209997bb20e822ebfbebe21e60edc483a3d5e20dc881"
|
||||
},
|
||||
"version": "3.5.1"
|
||||
"version": "3.6.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/hVDbf9RW2/oWP5z1nhGyc6/Ip76Inw4mLKaRtsojbuDS/tAaY9km8phEBGgC4T8qunh/FD8qc4dcVHk+iJfCA==
|
||||
+15
-1
@@ -7,6 +7,11 @@
|
||||
python scripts/memory_save.py --name 张三 --gender 男 \
|
||||
--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"] \
|
||||
[--place 四川省成都市] [--gender 男] [--pillars "庚午 辛巳 己卯 戊辰"]
|
||||
@@ -21,7 +26,16 @@ python scripts/memory_lookup.py [--name 张三] [--birth "1990-05-14 08:30"] \
|
||||
`archives/<四柱连写>-<性别>-<hash6>.md`,其中
|
||||
hash6 = sha1(出生钟表时间 + 出生地 + 性别)(UTF-8)前 6 位十六进制,
|
||||
用于区分同盘不同人。同主键再次建档只刷新 frontmatter 与「## 盘面」,
|
||||
正文其余小节原样保留。
|
||||
正文其余小节原样保留。换盘(`--update-chart`)后四柱或 hash6 变动时,
|
||||
文件改为新主键名(目标已存在则拒绝覆盖,报错退出 2)。
|
||||
|
||||
## 换盘纪律(工作盘唯一 + 变更留痕)
|
||||
|
||||
经用户确认换盘(日界规则分歧、时辰/出生地校正等)后,必须重排并把
|
||||
「## 盘面」段整体替换为新盘:档案任何时候只有一个工作盘,旧盘信息
|
||||
(原四柱、换盘原因、确认方式与时间)记入「反馈与澄清」校订条目,不留盘面段。
|
||||
凡受变动柱影响的已有推断条目(归纳、流年大事各行)同轮标记【待复核】,
|
||||
复核前不得继续引用。
|
||||
|
||||
## Schema
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ZiPingEightChar"
|
||||
version = "0.1.0"
|
||||
version = "3.6.1"
|
||||
requires-python = ">=3.10" # zoneinfo 需 3.9+,dataclass 语法需 3.10
|
||||
dependencies = [
|
||||
"tyme4py==1.5.0", # 精确锁定:历表数据跨版本会变,锁版本 = 锁可复现性
|
||||
|
||||
+3
-3
@@ -363,10 +363,10 @@
|
||||
- **原文**:(综括,无单一原文)
|
||||
|
||||
### R-运-33 核对前置:先定场景,再定断运路径
|
||||
- **条件**:断运输出覆盖五年以上、或多个流年候选需要用户核对时。**核对一律在铺开断语之前完成**——不得先输出一大段再回头提问。agent 环境具备询问工具时**必须用询问工具**,无询问工具时以正文编号选项代替。
|
||||
- **条件**:断运输出覆盖五年以上、或多个流年候选需要用户核对时。**核对一律在铺开断语之前完成**——不得先输出一大段再回头提问。核对提问一律走对话式询问(见 SKILL.md「Intake 对话式询问」):以普通对话消息进行,禁用结构化问卷/点选工具,也不在正文罗列编号问题清单让用户勾选;一次最多问 1~2 个,答完再定下一个。
|
||||
- **判定**:
|
||||
1. **一问定场景(并入 intake)**:「这盘是您本人,还是替别人看?本人或知情人在场、能核对以往的事吗?」此问与缺项追问合成一次提问(见 SKILL.md「Intake 追问话术」),不单独多占一轮。
|
||||
2. **可核对 → 锚点核对**:从全部候选年中挑出 3 个左右**锚点年**请用户核对。锚点年选取规则:引动组数最多者优先;落在人生阶段窗口(升学、毕业、婚育、换工作)者优先;用户所问之线每条至少占一个。每个锚点年给 2–3 个候选事件(R-运-32)。**核对用结构化选项**:每个锚点年一个问题,选项为按可能性排序的候选事件、外加「都对不上」(证伪入口);几个锚点年合成一次提问,不逐条单独打断用户。核对结果按 R-运-32 第 4 步收敛后,一次性输出完整断运。
|
||||
1. **一问定场景(并入 intake)**:「这盘是您本人,还是替别人看?本人或知情人在场、能核对以往的事吗?」此问与缺项追问合成一次提问(见 SKILL.md「Intake 对话式询问」),不单独多占一轮。
|
||||
2. **可核对 → 锚点核对**:从全部候选年中挑出 3 个左右**锚点年**请用户核对。锚点年选取规则:引动组数最多者优先;落在人生阶段窗口(升学、毕业、婚育、换工作)者优先;用户所问之线每条至少占一个。每个锚点年给 2–3 个候选事件(R-运-32)。**核对走对话式询问**:一次最多问一两个锚点年,每问先以该年盘上一两句显著信号作铺垫,再自然带出按可能性排序的候选事件,并留「都对不上」的证伪入口;用户答完再挑下一个锚点,不逐条单独打断、也不合成清单一次抛出。核对结果按 R-运-32 第 4 步收敛后,一次性输出完整断运。
|
||||
3. **不可核对 → 直推**:命主不在场、不知情,或用户明言测试、「不用问、直接说」时,跳过核对,一次性输出最佳推断;直答按 R-运-32 落笔纪律给主答案与置信度,全量候选收在分析推理段。
|
||||
4. **证伪回查**:锚点全部落空时不得硬铺——先回头检查:组数判定是否偏重、学制等阶段假设是否错位、出生时刻是否跨时辰或跨日界(晚子时)、是否临近节气边界(命盘带 ⚠️ 节气边界提示者,重排另一月令之盘对照);修正后重新给锚点。
|
||||
5. **学制锚点**:断语涉及学业年份时,把一个学制锚点(哪年上大学/哪年毕业、有无复读休学)并入第 2 步那次提问,不单独多问一轮。
|
||||
|
||||
+6
-1
@@ -102,7 +102,12 @@ rules/
|
||||
## 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:
|
||||
- description 改写为按问题域与使用场景触发;新增 R-禁-22 声明式偏离通道;R-运-31 事类映射表改开放清单措辞;规则计数相应更新(541 → 542 条,20 文件不变)。
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""纯 Python ed25519(RFC 8032)签名与验签,零依赖,仅用标准库。
|
||||
|
||||
self_update.py --apply 用其中的 verify() 校验 manifest.json 签名;
|
||||
tools/sign_manifest.py 用 sign() 生成签名。验签性能不在关键路径上
|
||||
(每次更新只验一次),实现取教科书式的扩展坐标点运算,求正确不求快。
|
||||
|
||||
密钥编码:私钥 = 32 字节种子(hex/base64 存储);公钥 = 32 字节 RFC 8032
|
||||
标准压缩编码;签名 = 64 字节 R‖S。
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
# 域参数:GF(2^255 - 19),曲线 -x² + y² = 1 + d·x²·y²
|
||||
_P = 2 ** 255 - 19
|
||||
_Q = 2 ** 252 + 27742317777372353535851937790883648493 # 群的阶
|
||||
_D = (-121665 * pow(121666, _P - 2, _P)) % _P
|
||||
_I = pow(2, (_P - 1) // 4, _P) # sqrt(-1)
|
||||
|
||||
|
||||
def _xrecover(y: int) -> int:
|
||||
xx = (y * y - 1) * pow(_D * y * y + 1, _P - 2, _P) % _P
|
||||
x = pow(xx, (_P + 3) // 8, _P)
|
||||
if (x * x - xx) % _P != 0:
|
||||
x = x * _I % _P
|
||||
if x & 1:
|
||||
x = _P - x
|
||||
return x
|
||||
|
||||
|
||||
_BY = (4 * pow(5, _P - 2, _P)) % _P
|
||||
_BX = _xrecover(_BY)
|
||||
# 基点(扩展坐标 x, y, z, t,x=X/Z, y=Y/Z, t=XY/Z)
|
||||
_B = (_BX, _BY, 1, _BX * _BY % _P)
|
||||
_IDENTITY = (0, 1, 1, 0)
|
||||
|
||||
|
||||
def _point_add(P, Q):
|
||||
x1, y1, z1, t1 = P
|
||||
x2, y2, z2, t2 = Q
|
||||
a = (y1 - x1) * (y2 - x2) % _P
|
||||
b = (y1 + x1) * (y2 + x2) % _P
|
||||
c = 2 * _D * t1 * t2 % _P
|
||||
d = 2 * z1 * z2 % _P
|
||||
e, f, g, h = b - a, d - c, d + c, b + a
|
||||
return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
|
||||
|
||||
|
||||
def _scalarmult(P, e: int):
|
||||
Q = _IDENTITY
|
||||
while e > 0:
|
||||
if e & 1:
|
||||
Q = _point_add(Q, P)
|
||||
P = _point_add(P, P)
|
||||
e >>= 1
|
||||
return Q
|
||||
|
||||
|
||||
def _encode_point(P) -> bytes:
|
||||
x, y, z, _t = P
|
||||
zi = pow(z, _P - 2, _P)
|
||||
x, y = x * zi % _P, y * zi % _P
|
||||
return (y | ((x & 1) << 255)).to_bytes(32, "little")
|
||||
|
||||
|
||||
def _decode_point(s: bytes):
|
||||
if len(s) != 32:
|
||||
raise ValueError("点编码长度须为 32 字节")
|
||||
y = int.from_bytes(s, "little") & ((1 << 255) - 1)
|
||||
if y >= _P:
|
||||
raise ValueError("点编码 y 坐标越界")
|
||||
x = _xrecover(y)
|
||||
if (x & 1) != (s[31] >> 7):
|
||||
x = _P - x
|
||||
if (-x * x + y * y - 1 - _D * x * x * y * y) % _P != 0:
|
||||
raise ValueError("点不在曲线上")
|
||||
return (x, y, 1, x * y % _P)
|
||||
|
||||
|
||||
def _hint(m: bytes) -> int:
|
||||
return int.from_bytes(hashlib.sha512(m).digest(), "little")
|
||||
|
||||
|
||||
def _secret_expand(seed: bytes):
|
||||
if len(seed) != 32:
|
||||
raise ValueError("私钥种子须为 32 字节")
|
||||
h = hashlib.sha512(seed).digest()
|
||||
a = int.from_bytes(h[:32], "little")
|
||||
a &= (1 << 254) - 8
|
||||
a |= 1 << 254
|
||||
return a, h[32:]
|
||||
|
||||
|
||||
def publickey(seed: bytes) -> bytes:
|
||||
"""32 字节种子 → 32 字节公钥。"""
|
||||
a, _prefix = _secret_expand(seed)
|
||||
return _encode_point(_scalarmult(_B, a))
|
||||
|
||||
|
||||
def sign(seed: bytes, msg: bytes) -> bytes:
|
||||
"""RFC 8032 确定性签名:64 字节 R‖S。仅发版工具(tools/)使用。"""
|
||||
a, prefix = _secret_expand(seed)
|
||||
A = publickey(seed)
|
||||
r = _hint(prefix + msg) % _Q
|
||||
R = _encode_point(_scalarmult(_B, r))
|
||||
S = (r + _hint(R + A + msg) * a) % _Q
|
||||
return R + S.to_bytes(32, "little")
|
||||
|
||||
|
||||
def verify(pk: bytes, msg: bytes, sig: bytes) -> bool:
|
||||
"""验签:通过返回 True;任何不符(含畸形输入)返回 False,不抛异常。"""
|
||||
if len(pk) != 32 or len(sig) != 64:
|
||||
return False
|
||||
R, s = sig[:32], int.from_bytes(sig[32:], "little")
|
||||
if s >= _Q:
|
||||
return False
|
||||
try:
|
||||
A = _decode_point(pk)
|
||||
Rpt = _decode_point(R)
|
||||
except ValueError:
|
||||
return False
|
||||
h = _hint(R + pk + msg) % _Q
|
||||
# [S]B == R + [h]A
|
||||
return _encode_point(_scalarmult(_B, s)) == _encode_point(
|
||||
_point_add(Rpt, _scalarmult(A, h)))
|
||||
|
||||
|
||||
def fingerprint(pk: bytes) -> str:
|
||||
"""公钥指纹:SHA256(公钥) 十六进制,用于注释与审计对照。"""
|
||||
return hashlib.sha256(pk).hexdigest()
|
||||
@@ -3,9 +3,10 @@ from datetime import datetime, timedelta
|
||||
ZI_HOUR_START = 23 # 真太阳时 23:00 换日
|
||||
|
||||
|
||||
def resolve_calendar_day(tst: datetime) -> tuple[datetime, dict]:
|
||||
def resolve_calendar_day(tst: datetime, rule: str = "子初") -> tuple[datetime, dict]:
|
||||
"""
|
||||
子初换日:真太阳时 23:00–24:00 归入次日。
|
||||
日界归算:rule="子初"(默认)真太阳时 23:00–24:00 归入次日;
|
||||
rule="子正" 以 0:00 为日界,晚子时不换日。
|
||||
|
||||
返回
|
||||
(日历日 00:00 的 datetime, flag)
|
||||
@@ -13,11 +14,12 @@ def resolve_calendar_day(tst: datetime) -> tuple[datetime, dict]:
|
||||
is_late_zi = tst.hour == ZI_HOUR_START
|
||||
|
||||
day = tst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if is_late_zi:
|
||||
if is_late_zi and rule != "子正":
|
||||
day += timedelta(days=1)
|
||||
|
||||
return day, {
|
||||
"rule": "子初换日@真太阳时23:00",
|
||||
"rule": ("子正换日@真太阳时00:00" if rule == "子正"
|
||||
else "子初换日@真太阳时23:00"),
|
||||
"is_late_zi": is_late_zi,
|
||||
"tst_time": tst.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
+18
-3
@@ -18,6 +18,8 @@ from pathlib import Path
|
||||
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
|
||||
# vendor 目录不存在时回退到环境中已安装的包(开发模式)
|
||||
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
|
||||
@@ -39,6 +41,9 @@ def main() -> int:
|
||||
help="出生地,精确到地级市一级,如「四川省成都市」")
|
||||
ap.add_argument("--html", nargs="?", const="", default=None,
|
||||
help="可选:同时生成 HTML 盘面图(给路径参数,或省略则写 ./命盘_<姓名>.html)")
|
||||
ap.add_argument("--shot", action="store_true",
|
||||
help="可选:生成 HTML 盘面图并截图 PNG,路径打在 stdout 末尾;"
|
||||
"截图工具不可用时只出 HTML")
|
||||
a = ap.parse_args()
|
||||
|
||||
# 软授权锁:定制副本(含 data/.license.json)按授权次数/期限放行;母版无状态文件直接放行
|
||||
@@ -132,14 +137,24 @@ def main() -> int:
|
||||
print(render(chart))
|
||||
_gate.consume(Path(__file__).resolve().parents[1])
|
||||
|
||||
# 可选:同步生成 HTML 盘面图(渲染失败不影响 stdout 契约)
|
||||
if a.html is not None:
|
||||
# 可选:同步生成 HTML 盘面图(渲染失败不影响 stdout 契约);--shot 再截图 PNG
|
||||
if a.html is not None or a.shot:
|
||||
try:
|
||||
from render.html import build_html
|
||||
html_path = (Path(a.html) if a.html
|
||||
else Path(f"命盘_{a.name}.html"))
|
||||
html_path.write_text(build_html(chart), encoding="utf-8")
|
||||
print(f"HTML 盘面:{html_path.resolve()}", file=sys.stderr)
|
||||
if a.shot:
|
||||
import chart_shot
|
||||
png = chart_shot.shoot(html_path)
|
||||
print(f"\nHTML 盘面:{html_path.resolve()}")
|
||||
if png:
|
||||
print(f"盘面截图:{png}")
|
||||
else:
|
||||
print("盘面截图:不可用(未检测到 playwright 或 Chrome,"
|
||||
"可直接打开上面的 HTML 查看盘面)")
|
||||
else:
|
||||
print(f"HTML 盘面:{html_path.resolve()}", file=sys.stderr)
|
||||
except Exception:
|
||||
print("WARN: HTML 盘面生成失败(命盘不受影响)", file=sys.stderr)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""干支派生纯查表:五行、十神、十二长生(星运)、旬空、六十甲子纳音。
|
||||
|
||||
本命四柱的同名信息由 chart/tyme_adapter.py 经 tyme4py 求得;本模块为同口径的
|
||||
纯 Python 实现,供岁运干支(大运/流年/流月)与渲染层复用,保证 HTML 盘面
|
||||
与排盘正文口径一致。纯查表,零命理判断(P1)。
|
||||
"""
|
||||
|
||||
GAN = "甲乙丙丁戊己庚辛壬癸"
|
||||
ZHI = "子丑寅卯辰巳午未申酉戌亥"
|
||||
|
||||
WX_GAN = {"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土",
|
||||
"己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"}
|
||||
WX_ZHI = {"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土",
|
||||
"巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金",
|
||||
"戌": "土", "亥": "水"}
|
||||
|
||||
_YY = {"甲": 1, "丙": 1, "戊": 1, "庚": 1, "壬": 1,
|
||||
"乙": -1, "丁": -1, "己": -1, "辛": -1, "癸": -1}
|
||||
_SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
||||
_KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
|
||||
|
||||
|
||||
def ten_god(me: str, other: str) -> str:
|
||||
"""other 天干相对日干 me 的十神(命名与 tyme4py get_ten_star 一致)。"""
|
||||
wx_m, wx_o = WX_GAN[me], WX_GAN[other]
|
||||
same = _YY[me] == _YY[other]
|
||||
if wx_m == wx_o:
|
||||
return "比肩" if same else "劫财"
|
||||
if _SHENG[wx_m] == wx_o:
|
||||
return "食神" if same else "伤官"
|
||||
if _KE[wx_m] == wx_o:
|
||||
return "偏财" if same else "正财"
|
||||
if _KE[wx_o] == wx_m:
|
||||
return "七杀" if same else "正官"
|
||||
return "偏印" if same else "正印"
|
||||
|
||||
|
||||
_TERRAIN_ORDER = ["长生", "沐浴", "冠带", "临官", "帝旺", "衰",
|
||||
"病", "死", "墓", "绝", "胎", "养"]
|
||||
_TERRAIN_START = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳",
|
||||
"壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"}
|
||||
|
||||
|
||||
def terrain(me: str, zhi: str) -> str:
|
||||
"""日干 me 对地支 zhi 的十二长生(阳顺阴逆,命名与 tyme4py get_terrain 一致)。"""
|
||||
start = ZHI.index(_TERRAIN_START[me])
|
||||
zi = ZHI.index(zhi)
|
||||
off = (zi - start) % 12 if _YY[me] == 1 else (start - zi) % 12
|
||||
return _TERRAIN_ORDER[off]
|
||||
|
||||
|
||||
def gz_void(gz: str) -> str:
|
||||
"""一柱干支所在旬的空亡两支(与 tyme4py get_extra_earth_branches 同果)。"""
|
||||
g, z = GAN.index(gz[0]), ZHI.index(gz[1])
|
||||
return ZHI[(z - g + 10) % 12] + ZHI[(z - g + 11) % 12]
|
||||
|
||||
|
||||
# 六十甲子纳音( pairwise ),末字即五行
|
||||
_NAYIN_PAIRS = [
|
||||
"海中金", "炉中火", "大林木", "路旁土", "剑锋金",
|
||||
"山头火", "涧下水", "城头土", "白蜡金", "杨柳木",
|
||||
"泉中水", "屋上土", "霹雳火", "松柏木", "长流水",
|
||||
"沙中金", "山下火", "平地木", "壁上土", "金箔金",
|
||||
"覆灯火", "天河水", "大驿土", "钗钏金", "桑柘木",
|
||||
"大溪水", "沙中土", "天上火", "石榴木", "大海水",
|
||||
]
|
||||
_JIAZI = [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
|
||||
NAYIN = {gz: _NAYIN_PAIRS[i // 2] for i, gz in enumerate(_JIAZI)}
|
||||
@@ -10,21 +10,9 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
GAN = "甲乙丙丁戊己庚辛壬癸"
|
||||
ZHI = "子丑寅卯辰巳午未申酉戌亥"
|
||||
YANG_GAN = set("甲丙戊庚壬")
|
||||
from .ganzhi import GAN, NAYIN, ZHI
|
||||
|
||||
# 六十甲子纳音( pairwise ),末字即五行
|
||||
_NAYIN_PAIRS = [
|
||||
"海中金", "炉中火", "大林木", "路旁土", "剑锋金",
|
||||
"山头火", "涧下水", "城头土", "白蜡金", "杨柳木",
|
||||
"泉中水", "屋上土", "霹雳火", "松柏木", "长流水",
|
||||
"沙中金", "山下火", "平地木", "壁上土", "金箔金",
|
||||
"覆灯火", "天河水", "大驿土", "钗钏金", "桑柘木",
|
||||
"大溪水", "沙中土", "天上火", "石榴木", "大海水",
|
||||
]
|
||||
_JIAZI = [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
|
||||
NAYIN = {gz: _NAYIN_PAIRS[i // 2] for i, gz in enumerate(_JIAZI)}
|
||||
YANG_GAN = set("甲丙戊庚壬")
|
||||
|
||||
# —— 日干查地支 ——
|
||||
TIANYI = {"甲": "丑未", "戊": "丑未", "庚": "丑未",
|
||||
@@ -102,6 +90,7 @@ SANQI = ("乙丙丁", "甲戊庚", "辛壬癸")
|
||||
# 输出排序(每柱内部按此序)
|
||||
STAR_ORDER = [
|
||||
"天德", "天德合", "月德", "月德合", "天乙贵人", "太极贵人", "福星贵人",
|
||||
"三奇贵人",
|
||||
"文昌", "禄神", "羊刃", "金舆", "驿马", "桃花", "红艳", "华盖", "将星",
|
||||
"劫煞", "灾煞", "亡神", "元辰", "勾煞", "绞煞", "孤辰", "寡宿",
|
||||
"天罗", "地网", "童子煞", "天赦", "魁罡", "阴差阳错", "十灵日", "十恶大败",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTML 盘面 → PNG 截图。
|
||||
|
||||
用法:
|
||||
python chart_shot.py <html路径> [-o 输出png路径]
|
||||
|
||||
截图分级回退:环境装有 playwright 则优先(file:// 全页截图,2 倍分辨率);
|
||||
否则用 macOS Chrome 无头截图(固定窗口,高度按 HTML 内容估算避免截断)。
|
||||
两者都不可用:退出码 2,stderr 提示直接打开 HTML。截图失败不影响排盘主流程。
|
||||
"""
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
|
||||
def _shot_playwright(html: Path, out: Path) -> bool:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page(device_scale_factor=2)
|
||||
page.goto(html.resolve().as_uri())
|
||||
page.screenshot(path=str(out), full_page=True)
|
||||
browser.close()
|
||||
except Exception:
|
||||
return False
|
||||
return out.is_file() and out.stat().st_size > 0
|
||||
|
||||
|
||||
def _content_height(html: Path) -> int:
|
||||
"""按 HTML 结构估算整页高度(px):头卡/主表/页脚取固定高,条带按格数折行。"""
|
||||
text = html.read_text(encoding="utf-8", errors="ignore")
|
||||
height = (900 + 40 * text.count('class="warn"')
|
||||
+ 150 * text.count('class="qrcard"'))
|
||||
for strip in text.split('class="strip"')[1:]:
|
||||
cells = strip.count('class="cell"')
|
||||
height += 36 + 132 * -(-cells // 9)
|
||||
return max(1300, min(height + 120, 4200))
|
||||
|
||||
|
||||
def _shot_chrome(html: Path, out: Path) -> bool:
|
||||
if not Path(CHROME).is_file():
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[CHROME, "--headless=new", f"--screenshot={out.resolve()}",
|
||||
f"--window-size=1100,{_content_height(html)}",
|
||||
"--force-device-scale-factor=2", "--hide-scrollbars",
|
||||
html.resolve().as_uri()],
|
||||
capture_output=True, timeout=60)
|
||||
except Exception:
|
||||
return False
|
||||
return r.returncode == 0 and out.is_file() and out.stat().st_size > 0
|
||||
|
||||
|
||||
def shoot(html: Path, out: Path | None = None) -> Path | None:
|
||||
"""对 HTML 盘面截图,成功返回 PNG 绝对路径,无可用工具或失败返回 None。"""
|
||||
out = out or html.with_suffix(".png")
|
||||
if _shot_playwright(html, out) or _shot_chrome(html, out):
|
||||
return out.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="chart_shot.py",
|
||||
description="HTML 盘面 → PNG 截图(playwright 优先,Chrome 无头兜底)")
|
||||
ap.add_argument("html", help="HTML 盘面文件路径")
|
||||
ap.add_argument("-o", "--out", default=None,
|
||||
help="输出 PNG 路径(默认与 HTML 同目录同名 .png)")
|
||||
a = ap.parse_args()
|
||||
html = Path(a.html)
|
||||
if not html.is_file():
|
||||
print(f"ERROR: 找不到 HTML 文件 {html}", file=sys.stderr)
|
||||
return 2
|
||||
png = shoot(html, Path(a.out) if a.out else None)
|
||||
if png:
|
||||
print(png)
|
||||
return 0
|
||||
print("无可用截图工具(未检测到 playwright 或 Chrome),"
|
||||
"请直接打开 HTML 文件查看盘面。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -19,6 +19,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
import _memory_store as store
|
||||
|
||||
|
||||
|
||||
+222
-83
@@ -4,12 +4,23 @@
|
||||
用法:
|
||||
python memory_save.py --name 张三 --gender 男 \
|
||||
--birth "1990-05-14 08:30" --place "四川省成都市"
|
||||
|
||||
# 换盘(更新既有档案盘面段):按档案重排并替换「## 盘面」,frontmatter 同步
|
||||
python memory_save.py --update-chart memory/archives/丙子庚子庚辰丙子-女-2825d1.md \
|
||||
[--day-boundary 子正] [--birth "校正后时刻"] [--place "校正后出生地"]
|
||||
输出:档案文件路径到 stdout。
|
||||
|
||||
建档即一次排盘:授权副本照常过软锁、扣减一次(与 cast_chart.py 同一 check/consume 位置)。
|
||||
同主键(四柱+性别+hash6)档案已存在时只刷新 frontmatter 与「## 盘面」,
|
||||
正文其余小节(归纳、反馈与澄清等)原样保留。档案规范见 memory/README.md。
|
||||
|
||||
换盘模式(--update-chart):姓名/性别/出生信息默认读档案 frontmatter,
|
||||
--birth/--place 用于时辰/地点校正,--day-boundary 子正 切换日界规则(默认子初)。
|
||||
换盘后「## 盘面」只保留新盘(工作盘唯一),旧盘信息由使用方记入「## 反馈与澄清」;
|
||||
frontmatter 的 pillars/day_master/true_solar_time 同步为新盘,文件改为新主键名
|
||||
(目标已存在且非同一文件时报错退出 2,不覆盖)。四柱有变动时 stderr 列出变动柱,
|
||||
提示使用方把受影响推断条目(归纳、流年大事)标记【待复核】。
|
||||
|
||||
退出码:0 成功;2 输入不可用;3 库内部错误;4 授权不可用(软锁,见 LICENSE.md)。
|
||||
"""
|
||||
import argparse
|
||||
@@ -21,6 +32,8 @@ from pathlib import Path
|
||||
# 引导:无论从哪个目录调用,scripts/ 下的顶层包(geo/astro/chart/render)都可导入
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
# 免安装:优先使用随包内置的依赖(vendor/,版本锁定 tyme4py==1.5.0 + tzdata)
|
||||
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
|
||||
if _VENDOR.is_dir():
|
||||
@@ -28,24 +41,215 @@ if _VENDOR.is_dir():
|
||||
|
||||
import _memory_store as store
|
||||
|
||||
_PILLAR_LABELS = ("年柱", "月柱", "日柱", "时柱")
|
||||
|
||||
# render 层日界文案写定(scripts/render/ 不在此处改动),换盘子正时改写这两行
|
||||
_AUDIT_ZICHU = "- 日界规则:子初换日(真太阳时 23:00)"
|
||||
_AUDIT_ZIZHENG = "- 日界规则:子正换日(真太阳时 00:00)"
|
||||
_WARN_ZICHU = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
|
||||
"已按「子初换日」归入次日。另存「子正换日」一派,"
|
||||
"日柱与时柱将不同,结论需谨慎。")
|
||||
_WARN_ZIZHENG = ("- ⚠️ 晚子时:本造落于真太阳时 23:00–24:00,"
|
||||
"已按「子正换日」定盘(日界 0:00,不换日)。"
|
||||
"另存「子初换日」一派,日柱与时柱将不同,结论需谨慎。")
|
||||
|
||||
|
||||
def _cast_chart(name: str, gender: str, birth: str, place: str,
|
||||
day_boundary: str = "子初"):
|
||||
"""排盘管线(geo → astro → chart → render),与 cast_chart.py 同一调用序列。
|
||||
|
||||
返回 (命盘 Markdown, 四柱 list, 日主, 真太阳时 str);地点/时刻不可用抛
|
||||
LocationError/ValueError(由调用方映射退出码 2)。
|
||||
"""
|
||||
from geo.cities import resolve_location
|
||||
from astro.timezone_resolve import to_utc
|
||||
from astro.solar_time import to_true_solar_time
|
||||
from astro.day_boundary import resolve_calendar_day
|
||||
from chart.tyme_adapter import cast
|
||||
from chart.luck import (
|
||||
childhood_luck, compute_luck, decade_pillars, flow_months,
|
||||
flow_years, is_forward, luck_label_for_year,
|
||||
)
|
||||
from chart.model import build_chart_data
|
||||
from render.markdown import render
|
||||
|
||||
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
|
||||
loc = resolve_location(place)
|
||||
|
||||
# L0-b 出生时刻解析
|
||||
clock = datetime.strptime(birth.strip(), "%Y-%m-%d %H:%M")
|
||||
|
||||
# L0-c 当地钟表时间 → UTC(含历史时区/夏令时审计)
|
||||
utc, tz_audit = to_utc(clock, loc)
|
||||
|
||||
# L0-d UTC → 真太阳时(经度修正 + 均时差)
|
||||
tst, st_audit = to_true_solar_time(utc, loc.lon)
|
||||
|
||||
# L0-e 日界归算:默认子初换日(真太阳时 23:00–24:00 归入次日)
|
||||
day, bd_audit = resolve_calendar_day(tst, rule=day_boundary)
|
||||
|
||||
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
|
||||
|
||||
# L1-a 排盘(tyme4py 唯一接触面)
|
||||
raw = cast(cast_dt, gender)
|
||||
|
||||
# L1-b 起运、大运、流年、流月、小运
|
||||
forward = is_forward(gender, raw["year"]["gan"])
|
||||
luck = compute_luck(
|
||||
birth_tst=tst,
|
||||
prev_jie_time=raw["prev_jie"]["time"],
|
||||
next_jie_time=raw["next_jie"]["time"],
|
||||
forward=forward,
|
||||
)
|
||||
this_year = datetime.now().year
|
||||
n_pillars = max(
|
||||
8,
|
||||
int((this_year + 9 - cast_dt.year - luck["start_age_years"]) / 10) + 2,
|
||||
)
|
||||
decade = decade_pillars(
|
||||
raw["month"]["gan"], raw["month"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year, n=n_pillars,
|
||||
)
|
||||
years = [
|
||||
(y, gz, luck_label_for_year(y, decade))
|
||||
for y, gz in flow_years(cast_dt.year, this_year, 10)
|
||||
]
|
||||
months = flow_months(this_year)
|
||||
childhood = childhood_luck(
|
||||
raw["hour"]["gan"], raw["hour"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year,
|
||||
)
|
||||
|
||||
# L1-c 组装 ChartData
|
||||
chart = build_chart_data(
|
||||
name=name, gender=gender, place=place, clock=birth,
|
||||
loc=loc, tz_audit=tz_audit, st_audit=st_audit, bd_audit=bd_audit,
|
||||
raw=raw, luck=luck, luck_pillars=decade,
|
||||
flow_years=years, flow_months=months, childhood_luck=childhood,
|
||||
)
|
||||
|
||||
# L2 渲染命盘 Markdown(嵌入档案「## 盘面」,不直接打印)
|
||||
chart_md = render(chart)
|
||||
if day_boundary == "子正":
|
||||
# render 层日界文案固定为子初口径,换盘子正时改写警示条与审计行
|
||||
chart_md = chart_md.replace(_WARN_ZICHU, _WARN_ZIZHENG)
|
||||
chart_md = chart_md.replace(_AUDIT_ZICHU, _AUDIT_ZIZHENG)
|
||||
|
||||
pillars = [f"{chart.year.gan}{chart.year.zhi}",
|
||||
f"{chart.month.gan}{chart.month.zhi}",
|
||||
f"{chart.day.gan}{chart.day.zhi}",
|
||||
f"{chart.hour.gan}{chart.hour.zhi}"]
|
||||
return chart_md, pillars, chart.day_master, chart.true_solar_time
|
||||
|
||||
|
||||
def _update_chart(a) -> int:
|
||||
"""换盘模式:重排并替换既有档案的「## 盘面」段,frontmatter 与主键名同步。"""
|
||||
path = Path(a.update_chart).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
print(f"ERROR: 档案不存在:{path}", file=sys.stderr)
|
||||
return 2
|
||||
meta, body = store.parse_frontmatter(path.read_text(encoding="utf-8"))
|
||||
if not meta:
|
||||
print("ERROR: 档案无 frontmatter,无法换盘;请检查文件或用完整建档。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
name = str(meta.get("name", "")).strip()
|
||||
gender = str(meta.get("gender", "")).strip()
|
||||
birth = (a.birth or str(meta.get("birth_clock", ""))).strip()
|
||||
place = (a.place or str(meta.get("birthplace", ""))).strip()
|
||||
if not name or gender not in ("男", "女") or not birth or not place:
|
||||
print("ERROR: 档案 frontmatter 缺 name/gender/birth_clock/birthplace,"
|
||||
"无法重排;请补全档案或改用完整建档。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
from geo.cities import LocationError
|
||||
try:
|
||||
chart_md, pillars, day_master, tst_str = _cast_chart(
|
||||
name, gender, birth, place, a.day_boundary)
|
||||
except LocationError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 2
|
||||
except ValueError:
|
||||
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
|
||||
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
old_pillars = meta.get("pillars", [])
|
||||
if isinstance(old_pillars, str):
|
||||
old_pillars = old_pillars.split()
|
||||
meta.update({
|
||||
"name": name, "gender": gender, "birth_clock": birth, "birthplace": place,
|
||||
"true_solar_time": tst_str, "pillars": pillars, "day_master": day_master,
|
||||
"created_at": meta.get("created_at") or now, "updated_at": now,
|
||||
})
|
||||
body = store.replace_section(body, "盘面", chart_md)
|
||||
|
||||
# 四柱/hash6 变动时改为新主键名;目标已存在则拒绝覆盖
|
||||
new_path = path.with_name(store.archive_name(
|
||||
pillars, gender, store.key_hash(birth, place, gender)))
|
||||
if new_path != path:
|
||||
if new_path.exists():
|
||||
print(f"ERROR: 新盘主键档案已存在:{new_path};"
|
||||
f"为避免覆盖,本次未写入。请先核对两份档案。", file=sys.stderr)
|
||||
return 2
|
||||
new_path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||||
path.unlink()
|
||||
else:
|
||||
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||||
|
||||
changed = [f"{_PILLAR_LABELS[i]} {old_pillars[i]}→{pillars[i]}"
|
||||
for i in range(min(len(old_pillars), 4))
|
||||
if old_pillars[i] != pillars[i]]
|
||||
if changed:
|
||||
print(f"注意:{'、'.join(changed)} 已变。受变动柱影响的已有推断条目"
|
||||
f"(归纳、流年大事)须复核并标【待复核】,复核前不得继续引用;"
|
||||
f"旧盘四柱与换盘原因请记入「## 反馈与澄清」。", file=sys.stderr)
|
||||
print(new_path)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="memory_save.py",
|
||||
description="命主档案建档/更新:排盘一次并写入 memory/archives/(stdout 打印档案路径)",
|
||||
)
|
||||
ap.add_argument("--name", required=True, help="姓名")
|
||||
ap.add_argument("--gender", required=True, choices=["男", "女"],
|
||||
ap.add_argument("--name", default=None, help="姓名")
|
||||
ap.add_argument("--gender", default=None, choices=["男", "女"],
|
||||
help="性别:男 或 女")
|
||||
ap.add_argument("--birth", required=True,
|
||||
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需')
|
||||
ap.add_argument("--place", required=True,
|
||||
help="出生地,精确到地级市一级,如「四川省成都市」")
|
||||
ap.add_argument("--birth", default=None,
|
||||
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需;'
|
||||
"换盘模式下为时辰校正覆盖值")
|
||||
ap.add_argument("--place", default=None,
|
||||
help="出生地,精确到地级市一级,如「四川省成都市」;"
|
||||
"换盘模式下为地点校正覆盖值")
|
||||
ap.add_argument("--update-chart", default=None, metavar="档案路径",
|
||||
help="换盘模式:按档案重排并替换其「## 盘面」段,"
|
||||
"frontmatter 与文件名同步为新盘")
|
||||
ap.add_argument("--day-boundary", default="子初", choices=["子初", "子正"],
|
||||
help="日界规则:子初(默认,真太阳时 23:00 换日)或 子正(0:00 换日);"
|
||||
"仅换盘模式可用")
|
||||
ap.add_argument("--memory-root", default=None,
|
||||
help=f"档案根目录(默认技能根 memory/;亦可用环境变量 {store.ENV_VAR})")
|
||||
a = ap.parse_args()
|
||||
|
||||
# 软授权锁:与 cast_chart.py 同位置——建档即一次排盘,先 check,成功后 consume
|
||||
if a.update_chart is None:
|
||||
missing = [k for k in ("name", "gender", "birth", "place")
|
||||
if getattr(a, k) is None]
|
||||
if missing:
|
||||
print(f"ERROR: 建档缺参数:{'、'.join('--' + k for k in missing)}。"
|
||||
f"(换盘请用 --update-chart <档案路径>)", file=sys.stderr)
|
||||
return 2
|
||||
if a.day_boundary != "子初":
|
||||
print("ERROR: --day-boundary 仅换盘模式(--update-chart)可用;"
|
||||
"建档固定子初换日。", file=sys.stderr)
|
||||
return 2
|
||||
elif a.name is not None or a.gender is not None:
|
||||
print("ERROR: 换盘模式不支持 --name/--gender(姓名、性别以档案为准)。",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# 软授权锁:与 cast_chart.py 同位置——建档/换盘即一次排盘,先 check,成功后 consume
|
||||
import _license_gate as _gate
|
||||
_deny = _gate.check(Path(__file__).resolve().parents[1])
|
||||
if _deny:
|
||||
@@ -53,91 +257,26 @@ def main() -> int:
|
||||
return 4
|
||||
|
||||
try:
|
||||
# 排盘管线与 cast_chart.py 同一调用序列(geo → astro → chart → render)
|
||||
from geo.cities import resolve_location, LocationError
|
||||
from astro.timezone_resolve import to_utc
|
||||
from astro.solar_time import to_true_solar_time
|
||||
from astro.day_boundary import resolve_calendar_day
|
||||
from chart.tyme_adapter import cast
|
||||
from chart.luck import (
|
||||
childhood_luck, compute_luck, decade_pillars, flow_months,
|
||||
flow_years, is_forward, luck_label_for_year,
|
||||
)
|
||||
from chart.model import build_chart_data
|
||||
from render.markdown import render
|
||||
if a.update_chart is not None:
|
||||
rc = _update_chart(a)
|
||||
if rc == 0:
|
||||
_gate.consume(Path(__file__).resolve().parents[1])
|
||||
return rc
|
||||
|
||||
# L0-a 地点解析(失败抛 LocationError → 退出码 2)
|
||||
# L0/L1/L2 排盘管线(地点/时刻不可用 → 退出码 2)
|
||||
from geo.cities import LocationError
|
||||
try:
|
||||
loc = resolve_location(a.place)
|
||||
chart_md, pillars, day_master, tst_str = _cast_chart(
|
||||
a.name, a.gender, a.birth, a.place)
|
||||
except LocationError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# L0-b 出生时刻解析
|
||||
try:
|
||||
clock = datetime.strptime(a.birth.strip(), "%Y-%m-%d %H:%M")
|
||||
except ValueError:
|
||||
print("ERROR: 出生时间格式应为 YYYY-MM-DD HH:MM;"
|
||||
"若用户只给了日期,请追问具体时刻。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# L0-c 当地钟表时间 → UTC(含历史时区/夏令时审计)
|
||||
utc, tz_audit = to_utc(clock, loc)
|
||||
|
||||
# L0-d UTC → 真太阳时(经度修正 + 均时差)
|
||||
tst, st_audit = to_true_solar_time(utc, loc.lon)
|
||||
|
||||
# L0-e 子初换日:真太阳时 23:00–24:00 归入次日
|
||||
day, bd_audit = resolve_calendar_day(tst)
|
||||
|
||||
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
|
||||
|
||||
# L1-a 排盘(tyme4py 唯一接触面)
|
||||
raw = cast(cast_dt, a.gender)
|
||||
|
||||
# L1-b 起运、大运、流年、流月、小运
|
||||
forward = is_forward(a.gender, raw["year"]["gan"])
|
||||
luck = compute_luck(
|
||||
birth_tst=tst,
|
||||
prev_jie_time=raw["prev_jie"]["time"],
|
||||
next_jie_time=raw["next_jie"]["time"],
|
||||
forward=forward,
|
||||
)
|
||||
this_year = datetime.now().year
|
||||
n_pillars = max(
|
||||
8,
|
||||
int((this_year + 9 - cast_dt.year - luck["start_age_years"]) / 10) + 2,
|
||||
)
|
||||
decade = decade_pillars(
|
||||
raw["month"]["gan"], raw["month"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year, n=n_pillars,
|
||||
)
|
||||
years = [
|
||||
(y, gz, luck_label_for_year(y, decade))
|
||||
for y, gz in flow_years(cast_dt.year, this_year, 10)
|
||||
]
|
||||
months = flow_months(this_year)
|
||||
childhood = childhood_luck(
|
||||
raw["hour"]["gan"], raw["hour"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year,
|
||||
)
|
||||
|
||||
# L1-c 组装 ChartData
|
||||
chart = build_chart_data(
|
||||
name=a.name, gender=a.gender, place=a.place, clock=a.birth,
|
||||
loc=loc, tz_audit=tz_audit, st_audit=st_audit, bd_audit=bd_audit,
|
||||
raw=raw, luck=luck, luck_pillars=decade,
|
||||
flow_years=years, flow_months=months, childhood_luck=childhood,
|
||||
)
|
||||
|
||||
# L2 渲染命盘 Markdown(嵌入档案「## 盘面」,不直接打印)
|
||||
chart_md = render(chart)
|
||||
|
||||
# —— 建档/更新 ——
|
||||
pillars = [f"{chart.year.gan}{chart.year.zhi}",
|
||||
f"{chart.month.gan}{chart.month.zhi}",
|
||||
f"{chart.day.gan}{chart.day.zhi}",
|
||||
f"{chart.hour.gan}{chart.hour.zhi}"]
|
||||
root = store.resolve_root(a.memory_root, __file__)
|
||||
arch_dir = root / "archives"
|
||||
arch_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -147,8 +286,8 @@ def main() -> int:
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
meta = {
|
||||
"name": a.name, "gender": a.gender, "birth_clock": a.birth.strip(),
|
||||
"birthplace": a.place.strip(), "true_solar_time": chart.true_solar_time,
|
||||
"pillars": pillars, "day_master": chart.day_master,
|
||||
"birthplace": a.place.strip(), "true_solar_time": tst_str,
|
||||
"pillars": pillars, "day_master": day_master,
|
||||
"created_at": now, "updated_at": now,
|
||||
}
|
||||
if path.is_file():
|
||||
|
||||
@@ -19,6 +19,8 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
+84
-118
@@ -2,52 +2,25 @@
|
||||
"""ChartData → 单文件 HTML 盘面(模板 render/templates/paipan.html)。
|
||||
|
||||
与 Markdown 输出同为纯计算渲染:十神/藏干/空亡/纳音/十二长生/月令旺衰
|
||||
均为固定查表,不含任何命理判断(P1)。
|
||||
均为固定查表,不含任何命理判断(P1)。岁运干支(大运/流年格)的十神/
|
||||
星运/空亡/纳音复用 chart/ganzhi.py,神煞复用 chart/shensha.py 的岁运
|
||||
求值器 evaluate_target(与 query_shensha.py 同一口径),不在渲染层
|
||||
另造规则。
|
||||
|
||||
logo 与品牌字体(render/assets/)以 base64 内嵌,保证 HTML 单文件便携;
|
||||
神煞条目的查法注释(全角/半角括号)仅在本装配层剔除,Markdown 输出保持原样。
|
||||
"""
|
||||
import base64
|
||||
import re
|
||||
from datetime import datetime
|
||||
from html import escape as _escape
|
||||
from pathlib import Path
|
||||
|
||||
from chart.luck import luck_label_for_year
|
||||
from chart.ganzhi import NAYIN, WX_GAN, WX_ZHI, gz_void, ten_god, terrain
|
||||
from chart.shensha import evaluate_target
|
||||
|
||||
GAN = "甲乙丙丁戊己庚辛壬癸"
|
||||
ZHI = "子丑寅卯辰巳午未申酉戌亥"
|
||||
|
||||
_WX_GAN = {"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土",
|
||||
"己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"}
|
||||
_WX_ZHI = {"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土",
|
||||
"巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金",
|
||||
"戌": "土", "亥": "水"}
|
||||
_YY = {"甲": 1, "丙": 1, "戊": 1, "庚": 1, "壬": 1,
|
||||
"乙": -1, "丁": -1, "己": -1, "辛": -1, "癸": -1}
|
||||
_SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"}
|
||||
_KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"}
|
||||
|
||||
_HIDDEN = {"子": ["癸"], "丑": ["己", "癸", "辛"], "寅": ["甲", "丙", "戊"],
|
||||
"卯": ["乙"], "辰": ["戊", "乙", "癸"], "巳": ["丙", "戊", "庚"],
|
||||
"午": ["丁", "己"], "未": ["己", "丁", "乙"],
|
||||
"申": ["庚", "壬", "戊"], "酉": ["辛"], "戌": ["戊", "辛", "丁"],
|
||||
"亥": ["壬", "甲"]}
|
||||
|
||||
_NAYIN = {"甲子": "海中金", "乙丑": "海中金", "丙寅": "炉中火", "丁卯": "炉中火",
|
||||
"戊辰": "大林木", "己巳": "大林木", "庚午": "路旁土", "辛未": "路旁土",
|
||||
"壬申": "剑锋金", "癸酉": "剑锋金", "甲戌": "山头火", "乙亥": "山头火",
|
||||
"丙子": "涧下水", "丁丑": "涧下水", "戊寅": "城头土", "己卯": "城头土",
|
||||
"庚辰": "白蜡金", "辛巳": "白蜡金", "壬午": "杨柳木", "癸未": "杨柳木",
|
||||
"甲申": "泉中水", "乙酉": "泉中水", "丙戌": "屋上土", "丁亥": "屋上土",
|
||||
"戊子": "霹雳火", "己丑": "霹雳火", "庚寅": "松柏木", "辛卯": "松柏木",
|
||||
"壬辰": "长流水", "癸巳": "长流水", "甲午": "沙中金", "乙未": "沙中金",
|
||||
"丙申": "山下火", "丁酉": "山下火", "戊戌": "平地木", "己亥": "平地木",
|
||||
"庚子": "壁上土", "辛丑": "壁上土", "壬寅": "金箔金", "癸卯": "金箔金",
|
||||
"甲辰": "覆灯火", "乙巳": "覆灯火", "丙午": "天河水", "丁未": "天河水",
|
||||
"戊申": "大驿土", "己酉": "大驿土", "庚戌": "钗钏金", "辛亥": "钗钏金",
|
||||
"壬子": "桑柘木", "癸丑": "桑柘木", "甲寅": "大溪水", "乙卯": "大溪水",
|
||||
"丙辰": "沙中土", "丁巳": "沙中土", "戊午": "天上火", "己未": "天上火",
|
||||
"庚申": "石榴木", "辛酉": "石榴木", "壬戌": "大海水", "癸亥": "大海水"}
|
||||
|
||||
_TERRAIN_ORDER = ["长生", "沐浴", "冠带", "临官", "帝旺", "衰",
|
||||
"病", "死", "墓", "绝", "胎", "养"]
|
||||
_TERRAIN_START = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳",
|
||||
"壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"}
|
||||
_WX_CLASS = {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu",
|
||||
"金": "wx-jin", "水": "wx-shui"}
|
||||
|
||||
_SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "水", "金", "土"),
|
||||
"巳": ("火", "土", "木", "水", "金"), "午": ("火", "土", "木", "水", "金"),
|
||||
@@ -57,43 +30,30 @@ _SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "
|
||||
"丑": ("土", "金", "火", "木", "水"), "未": ("土", "金", "火", "木", "水")}
|
||||
_SEASON_LABEL = ("旺", "相", "休", "囚", "死")
|
||||
|
||||
_ASSETS_DIR = Path(__file__).resolve().parent / "assets"
|
||||
_SHA_NOTE_RE = re.compile(r"[((][^))]*[))]")
|
||||
|
||||
# 结构性占位符:值本身是装配层渲染出的 HTML 片段或 base64 资源,
|
||||
# 填充时原样注入;其余占位符均为文本字段,一律 HTML 转义防注入。
|
||||
_STRUCTURAL_KEYS = frozenset({
|
||||
"LOGO_B64", "FONT_B64", "QRCODE_B64",
|
||||
"WARNINGS", "MAIN_TABLE", "LUCK_STRIP", "CHILDHOOD_BLOCK",
|
||||
"FLOW_YEARS", "FLOW_MONTHS",
|
||||
})
|
||||
|
||||
|
||||
def _strip_sha_note(entry: str) -> str:
|
||||
"""神煞条目剔除查法括号注释:`将星(以年支子查,见于月支子)` → `将星`。"""
|
||||
return _SHA_NOTE_RE.sub("", entry).strip()
|
||||
|
||||
|
||||
def _asset_b64(name: str) -> str:
|
||||
return base64.b64encode((_ASSETS_DIR / name).read_bytes()).decode("ascii")
|
||||
|
||||
|
||||
def _wx(ch: str) -> str:
|
||||
wx = _WX_GAN.get(ch) or _WX_ZHI.get(ch)
|
||||
return {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu",
|
||||
"金": "wx-jin", "水": "wx-shui"}.get(wx, "")
|
||||
|
||||
|
||||
def _ten_god(me: str, other: str) -> str:
|
||||
wx_m, wx_o = _WX_GAN[me], _WX_GAN[other]
|
||||
same = _YY[me] == _YY[other]
|
||||
if wx_m == wx_o:
|
||||
return "比肩" if same else "劫财"
|
||||
if _SHENG[wx_m] == wx_o:
|
||||
return "食神" if same else "伤官"
|
||||
if _KE[wx_m] == wx_o:
|
||||
return "偏财" if same else "正财"
|
||||
if _KE[wx_o] == wx_m:
|
||||
return "七杀" if same else "正官"
|
||||
return "偏印" if same else "正印"
|
||||
|
||||
|
||||
def _terrain(me: str, zhi: str) -> str:
|
||||
"""日干对支的十二长生(阳顺阴逆)。"""
|
||||
start = _ZHI_IDX(_TERRAIN_START[me])
|
||||
zi = _ZHI_IDX(zhi)
|
||||
off = (zi - start) % 12 if _YY[me] == 1 else (start - zi) % 12
|
||||
return _TERRAIN_ORDER[off]
|
||||
|
||||
|
||||
def _ZHI_IDX(z: str) -> int:
|
||||
return ZHI.index(z)
|
||||
|
||||
|
||||
def _gz_void(gz: str) -> str:
|
||||
"""一柱干支的旬空两支。"""
|
||||
g, z = GAN.index(gz[0]), ZHI.index(gz[1])
|
||||
return ZHI[(z - g + 10) % 12] + ZHI[(z - g + 11) % 12]
|
||||
wx = WX_GAN.get(ch) or WX_ZHI.get(ch)
|
||||
return _WX_CLASS.get(wx, "")
|
||||
|
||||
|
||||
def _z(zhi: str, voids: str) -> str:
|
||||
@@ -111,51 +71,21 @@ def _pillar_col(chart, key: str, label: str, cur: bool = False) -> dict:
|
||||
"gan": p.gan, "zhi": p.zhi,
|
||||
"hidden": list(zip(p.hidden_stems, p.hidden_ten_gods)),
|
||||
"terrain": chart.terrain.get(key, ""),
|
||||
"void": _gz_void(p.gan + p.zhi),
|
||||
"void": gz_void(p.gan + p.zhi),
|
||||
"nayin": p.na_yin or "",
|
||||
"shensha": chart.shen_sha.get(key, []),
|
||||
"shensha": [_strip_sha_note(s) for s in chart.shen_sha.get(key, [])],
|
||||
"voids": voids,
|
||||
}
|
||||
|
||||
|
||||
def _extra_col(chart, gz: str, label: str, cur: bool) -> dict:
|
||||
"""流年/大运列(仅干支行星级信息,神煞留空)。"""
|
||||
me = chart.day_master
|
||||
voids = chart.void.get("day", "") + chart.void.get("hour", "")
|
||||
return {
|
||||
"label": label, "cur": cur,
|
||||
"god": _ten_god(me, gz[0]),
|
||||
"gan": gz[0], "zhi": gz[1],
|
||||
"hidden": [(h, _ten_god(me, h)) for h in _HIDDEN[gz[1]]],
|
||||
"terrain": _terrain(me, gz[1]),
|
||||
"void": _gz_void(gz),
|
||||
"nayin": _NAYIN[gz],
|
||||
"shensha": [],
|
||||
"voids": voids,
|
||||
}
|
||||
|
||||
|
||||
def _main_table(chart) -> str:
|
||||
this_year = datetime.now().year
|
||||
ly = next(((y, gz) for y, gz, _ in chart.flow_years if y == this_year), None)
|
||||
cur_luck = None
|
||||
for lp in chart.luck_pillars:
|
||||
if lp.start_year <= this_year:
|
||||
cur_luck = lp
|
||||
cols = []
|
||||
if ly:
|
||||
cols.append(_extra_col(chart, ly[1], f"流年 {ly[0]}", cur=True))
|
||||
if cur_luck:
|
||||
cols.append(_extra_col(chart, cur_luck.gan + cur_luck.zhi, "大运", cur=True))
|
||||
for key, label in (("year", "年柱"), ("month", "月柱"),
|
||||
("day", "日柱"), ("hour", "时柱")):
|
||||
cols.append(_pillar_col(chart, key, label, cur=(key == "day")))
|
||||
def _render_table(cols: list) -> str:
|
||||
"""干支列列表 → 排盘表格(主星/天干/地支/藏干/星运/空亡/纳音/神煞)。"""
|
||||
|
||||
def th(c):
|
||||
tag = '<span class="curtag">当前</span>' if c["cur"] else ""
|
||||
return f'<th class="{"cur" if c["cur"] else ""}">{c["label"]}{tag}</th>'
|
||||
|
||||
def row(label, fn, cls=""):
|
||||
def row(label, fn):
|
||||
tds = "".join(
|
||||
f'<td class="{"cur" if c["cur"] else ""}">{fn(c)}</td>' for c in cols)
|
||||
return f'<tr><td class="rowlabel">{label}</td>{tds}</tr>'
|
||||
@@ -178,6 +108,34 @@ def _main_table(chart) -> str:
|
||||
return '<table class="pan">' + "".join(rows) + "</table>"
|
||||
|
||||
|
||||
def _main_table(chart) -> str:
|
||||
"""排盘卡:仅年月日时四柱。"""
|
||||
cols = [_pillar_col(chart, key, label, cur=(key == "day"))
|
||||
for key, label in (("year", "年柱"), ("month", "月柱"),
|
||||
("day", "日柱"), ("hour", "时柱"))]
|
||||
return _render_table(cols)
|
||||
|
||||
|
||||
def _target_shensha(chart, gz: str) -> list[str]:
|
||||
"""岁运干支相对本命锚点所逢的神煞(与 query_shensha.py 同一求值器)。"""
|
||||
pillars = {k: getattr(chart, k).gan + getattr(chart, k).zhi
|
||||
for k in ("year", "month", "day", "hour")}
|
||||
return [star for star, _ in evaluate_target(pillars, chart.gender, gz)["anchored"]]
|
||||
|
||||
|
||||
def _gz_sub(chart, gz: str) -> str:
|
||||
"""岁运小卡的干支附属行:十神·星运 / 空亡 / 纳音 / 神煞(无标签,值本身说话)。"""
|
||||
lines = [
|
||||
f"{ten_god(chart.day_master, gz[0])} · {terrain(chart.day_master, gz[1])}",
|
||||
gz_void(gz),
|
||||
NAYIN[gz],
|
||||
]
|
||||
sha = _target_shensha(chart, gz)
|
||||
if sha:
|
||||
lines.append(" · ".join(sha))
|
||||
return "".join(f'<div class="cs">{line}</div>' for line in lines)
|
||||
|
||||
|
||||
def _luck_strip(chart) -> str:
|
||||
this_year = datetime.now().year
|
||||
cells = []
|
||||
@@ -189,8 +147,7 @@ def _luck_strip(chart) -> str:
|
||||
f'<div class="cy">{lp.start_year} · {lp.start_age:.0f} 岁起</div>'
|
||||
f'<div class="cg"><span class="{_wx(lp.gan)}">{lp.gan}</span>'
|
||||
f'<span class="{_wx(lp.zhi)}">{lp.zhi}</span></div>'
|
||||
f'<div class="cy">{_ten_god(chart.day_master, lp.gan)}'
|
||||
f' · {_terrain(chart.day_master, lp.zhi)}</div></div>')
|
||||
f'{_gz_sub(chart, gz)}</div>')
|
||||
return '<div class="strip">' + "".join(cells) + "</div>"
|
||||
|
||||
|
||||
@@ -218,7 +175,8 @@ def _flow_years(chart) -> str:
|
||||
cls += " jiao"
|
||||
cells.append(f'<div class="{cls}"><div class="cy">{year}</div>'
|
||||
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
|
||||
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>')
|
||||
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div>'
|
||||
f'{_gz_sub(chart, gz)}</div>')
|
||||
return '<div class="strip">' + "".join(cells) + "</div>"
|
||||
|
||||
|
||||
@@ -258,14 +216,18 @@ def build_html(chart) -> str:
|
||||
|
||||
audit = (f"时区 {chart.tz_used}(夏令时:{'是' if chart.is_dst else '否'})· "
|
||||
f"经度修正 {chart.lon_correction_min:+.2f} 分 · 均时差 {chart.eot_minutes:+.2f} 分 · "
|
||||
f"日界 子初换日(23:00)· tyme4py {chart.library_version} · "
|
||||
f"schema {chart.schema_version}")
|
||||
f"日界 子初换日(23:00)")
|
||||
audit_full = (f"{audit}· tyme4py {chart.library_version} · "
|
||||
f"schema {chart.schema_version}")
|
||||
|
||||
mapping = {
|
||||
"TITLE": f"命盘:{chart.name}",
|
||||
"LOGO_B64": _asset_b64("lingyao-logo.png"),
|
||||
"FONT_B64": _asset_b64("logo-font.ttf"),
|
||||
"QRCODE_B64": _asset_b64("qun-qrcode.jpg"),
|
||||
"NAME": chart.name,
|
||||
"GENDER_LABEL": "乾造" if chart.gender == "男" else "坤造",
|
||||
"DAY_MASTER": f"{chart.day_master}{_WX_GAN[chart.day_master]}",
|
||||
"DAY_MASTER": f"{chart.day_master}{WX_GAN[chart.day_master]}",
|
||||
"BIRTHPLACE": chart.birthplace,
|
||||
"BIRTH_CLOCK": chart.birth_clock,
|
||||
"TRUE_SOLAR": chart.true_solar_time,
|
||||
@@ -282,8 +244,12 @@ def build_html(chart) -> str:
|
||||
"FLOW_MONTHS": _flow_months(chart),
|
||||
"SEASON_BAR": _season_bar(chart),
|
||||
"AUDIT": audit,
|
||||
"AUDIT_FULL": audit_full,
|
||||
}
|
||||
out = tpl
|
||||
for k, v in mapping.items():
|
||||
out = out.replace("{{" + k + "}}", str(v))
|
||||
v = str(v)
|
||||
if k not in _STRUCTURAL_KEYS:
|
||||
v = _escape(v, quote=True)
|
||||
out = out.replace("{{" + k + "}}", v)
|
||||
return out
|
||||
|
||||
@@ -3,26 +3,72 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'LogoFont';
|
||||
src: url("data:font/ttf;base64,{{FONT_B64}}") format("truetype");
|
||||
font-weight: normal; font-style: normal; font-display: swap;
|
||||
}
|
||||
:root {
|
||||
--ink: #2b2b2b; --muted: #8a8577; --line: #e6dfd2; --bg: #f7f4ee;
|
||||
--card: #fffdf8; --accent: #b08d2f;
|
||||
/* 灵爻妙解网站浅色主题(root.css :root.light + paipan_base.css) */
|
||||
--ink: #222831;
|
||||
--muted: rgba(27, 27, 27, .52);
|
||||
--soft: rgba(27, 27, 27, .68);
|
||||
--line: rgba(193, 188, 235, .55);
|
||||
--card-line: rgba(193, 188, 235, .45);
|
||||
--accent: #dc7b28; /* 网站浅色 primary-color-1 */
|
||||
--accent-2: #f97316; /* 网站浅色 primary-color-3 */
|
||||
--wx-mu: #2e7d32; --wx-huo: #c62828; --wx-tu: #8d6e63;
|
||||
--wx-jin: #af8c2c; --wx-shui: #1565c0;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 24px; background: var(--bg); color: var(--ink);
|
||||
margin: 0; padding: 28px 24px 32px; color: var(--ink);
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(ellipse 72% 58% at 78% 20%, rgba(150, 125, 245, .18), transparent 68%),
|
||||
radial-gradient(ellipse 66% 48% at 20% 18%, rgba(112, 174, 255, .18), transparent 70%),
|
||||
radial-gradient(ellipse 82% 52% at 52% 92%, rgba(84, 210, 200, .11), transparent 72%),
|
||||
linear-gradient(135deg, #fbfcff 0%, #f5f8ff 42%, #f2edff 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
.page { max-width: 980px; margin: 0 auto; }
|
||||
.page { max-width: 1100px; margin: 0 auto; }
|
||||
|
||||
/* 顶部品牌区:base.html 抽屉顶部 .drawer-brand 的居中放大版 */
|
||||
.brand-head {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 14px; margin: 2px 0 20px;
|
||||
}
|
||||
.brand-logo { width: 52px; height: 52px; display: block; }
|
||||
.brand-name {
|
||||
font-family: 'LogoFont', 'Kaiti SC', 'STKaiti', 'KaiTi', serif;
|
||||
font-weight: 500; font-size: 30px; line-height: 1.25;
|
||||
color: var(--ink); letter-spacing: 1px;
|
||||
}
|
||||
.brand-slogan {
|
||||
margin-top: 3px; font-size: 11px; letter-spacing: 3.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 大卡片:网站左 panel(.panel)亮色风格 */
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 1), rgba(250, 252, 255, 1));
|
||||
box-shadow: 0 16px 38px rgba(48, 56, 96, .12);
|
||||
padding: 16px;
|
||||
}
|
||||
/* 面板内子卡 */
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 18px 20px; margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(60, 50, 30, .06);
|
||||
background: #fff; border: 1px solid var(--card-line);
|
||||
border-radius: 8px; padding: 16px 18px; margin-bottom: 14px;
|
||||
box-shadow: 0 2px 8px rgba(48, 56, 96, .05);
|
||||
}
|
||||
/* 头部 */
|
||||
.card:last-child { margin-bottom: 0; }
|
||||
|
||||
/* 用户信息 */
|
||||
.head { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.name { font-size: 28px; font-weight: 700; font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
|
||||
.chip {
|
||||
@@ -31,90 +77,128 @@
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 13px; line-height: 1.9; }
|
||||
.meta b { color: var(--ink); font-weight: 600; }
|
||||
.meta.tech {
|
||||
margin-top: 8px; padding-top: 8px; font-size: 12.5px;
|
||||
border-top: 1px dashed var(--card-line);
|
||||
}
|
||||
.warn {
|
||||
margin-top: 6px; padding: 6px 10px; border-radius: 8px;
|
||||
background: #fdf3e3; border: 1px solid #ecd9ae; color: #8a6d1d; font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 主排盘表 */
|
||||
table.pan { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.pan th, .pan td {
|
||||
border: 1px solid var(--line); text-align: center;
|
||||
border: 1px solid var(--card-line); text-align: center;
|
||||
padding: 6px 4px; vertical-align: middle; font-size: 14px;
|
||||
}
|
||||
.pan th { background: #f3eee2; color: #6b6455; font-weight: 600; }
|
||||
.pan td.rowlabel { background: #f3eee2; color: #6b6455; font-size: 13px; width: 64px; }
|
||||
.pan th { background: rgba(193, 188, 235, .18); color: var(--soft); font-weight: 600; }
|
||||
.pan td.rowlabel { background: rgba(193, 188, 235, .14); color: var(--soft); font-size: 13px; width: 64px; }
|
||||
.gz { font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; font-size: 34px; line-height: 1.25; }
|
||||
.god { font-size: 12.5px; color: #7a7264; }
|
||||
.god { font-size: 12.5px; color: var(--muted); }
|
||||
.god.day { color: var(--accent); font-weight: 600; }
|
||||
.hidden { font-size: 12.5px; line-height: 1.7; color: #57503f; }
|
||||
.hidden { font-size: 12.5px; line-height: 1.7; color: var(--soft); }
|
||||
.hidden .hg { font-weight: 600; }
|
||||
.small { font-size: 12.5px; color: #57503f; line-height: 1.7; }
|
||||
.small { font-size: 12.5px; color: var(--soft); line-height: 1.7; }
|
||||
.voidz { border-bottom: 2px dashed #c0392b; padding-bottom: 1px; }
|
||||
.cur { background: #fdf6e0; }
|
||||
.curtag { display:block; font-size: 11px; color: var(--accent); }
|
||||
.cur { background: rgba(220, 123, 40, .07); }
|
||||
.curtag { display: block; font-size: 11px; color: var(--accent); }
|
||||
.wx-mu { color: var(--wx-mu); } .wx-huo { color: var(--wx-huo); }
|
||||
.wx-tu { color: var(--wx-tu); } .wx-jin { color: var(--wx-jin); }
|
||||
.wx-shui { color: var(--wx-shui); }
|
||||
|
||||
/* 条带 */
|
||||
.sect { font-size: 15px; font-weight: 700; margin: 2px 0 10px;
|
||||
font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
|
||||
.sect .note { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 8px; }
|
||||
.strip { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.cell {
|
||||
border: 1px solid var(--line); border-radius: 8px; background: #fff;
|
||||
padding: 6px 8px; text-align: center; min-width: 64px;
|
||||
border: 1px solid var(--card-line); border-radius: 8px; background: #fff;
|
||||
padding: 6px 8px; text-align: center; min-width: 64px; max-width: 96px;
|
||||
}
|
||||
.cell .cy { font-size: 11px; color: var(--muted); }
|
||||
.cell .cg { font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; font-size: 20px; }
|
||||
.cell.cur { border-color: var(--accent); background: #fdf6e0; box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.cell .cs { font-size: 11px; color: var(--muted); line-height: 1.55; margin-top: 1px; }
|
||||
.cell.cur { border-color: var(--accent); background: rgba(220, 123, 40, .07); box-shadow: 0 0 0 1px var(--accent) inset; }
|
||||
.cell.jiao .cy { color: #c0392b; }
|
||||
|
||||
/* 尾部交流群卡:左图右文 */
|
||||
.qrcard { display: flex; align-items: center; gap: 16px; }
|
||||
.qrimg { width: 108px; height: 108px; border-radius: 6px; display: block; }
|
||||
.qrtitle { font-size: 15px; font-weight: 700;
|
||||
font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
|
||||
.qrdesc { margin-top: 4px; font-size: 12.5px; color: var(--soft); line-height: 1.8; }
|
||||
|
||||
/* 页脚 */
|
||||
.foot { color: var(--muted); font-size: 12px; line-height: 1.9; }
|
||||
.brand { margin-top: 6px; color: #a89e86; }
|
||||
.foot {
|
||||
text-align: center; color: var(--muted); font-size: 12px;
|
||||
line-height: 1.9; margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<div class="card">
|
||||
<div class="head">
|
||||
<span class="name">{{NAME}}</span>
|
||||
<span class="chip">{{GENDER_LABEL}}</span>
|
||||
<span class="chip">日主 {{DAY_MASTER}}</span>
|
||||
<div class="brand-head">
|
||||
<img class="brand-logo" src="data:image/png;base64,{{LOGO_B64}}" alt="灵爻妙解">
|
||||
<div class="brand-text">
|
||||
<div class="brand-name">灵爻妙解</div>
|
||||
<div class="brand-slogan">传承易学智慧</div>
|
||||
</div>
|
||||
<div class="meta" style="margin-top:8px">
|
||||
出生地:<b>{{BIRTHPLACE}}</b> 钟表时间:<b>{{BIRTH_CLOCK}}</b> 真太阳时:<b>{{TRUE_SOLAR}}</b><br>
|
||||
月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}}
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
|
||||
<div class="card">
|
||||
<div class="head">
|
||||
<span class="name">{{NAME}}</span>
|
||||
<span class="chip">{{GENDER_LABEL}}</span>
|
||||
<span class="chip">日主 {{DAY_MASTER}}</span>
|
||||
</div>
|
||||
<div class="meta" style="margin-top:8px">
|
||||
出生地:<b>{{BIRTHPLACE}}</b> 钟表时间:<b>{{BIRTH_CLOCK}}</b> 真太阳时:<b>{{TRUE_SOLAR}}</b><br>
|
||||
月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}}
|
||||
</div>
|
||||
{{WARNINGS}}
|
||||
<div class="meta tech">
|
||||
{{SEASON_BAR}}<br>
|
||||
{{AUDIT}}
|
||||
</div>
|
||||
<!-- 排盘审计原文(开发溯源,含版本信息):{{AUDIT_FULL}} -->
|
||||
</div>
|
||||
{{WARNINGS}}
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">排盘<span class="note">四柱 · 藏干 · 空亡 · 纳音 · 神煞</span></div>
|
||||
{{MAIN_TABLE}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">大运<span class="note">{{LUCK_NOTE}}</span></div>
|
||||
{{LUCK_STRIP}}
|
||||
{{CHILDHOOD_BLOCK}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">流年<span class="note">出生年至今后十年,今年高亮,红字年份为交运年</span></div>
|
||||
{{FLOW_YEARS}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">流月<span class="note">本年节气月</span></div>
|
||||
{{FLOW_MONTHS}}
|
||||
</div>
|
||||
|
||||
<div class="card qrcard">
|
||||
<img class="qrimg" src="data:image/jpeg;base64,{{QRCODE_B64}}" alt="微信群二维码">
|
||||
<div class="qrtext">
|
||||
<div class="qrtitle">灵爻妙解 · 传统文化交流群</div>
|
||||
<div class="qrdesc">扫码加入微信群:反馈解读偏差、交流文化心得、参与档案共建,版本更新动态第一时间同步。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">排盘<span class="note">四柱 · 藏干 · 空亡 · 纳音 · 神煞(当前大运与流年并列对照)</span></div>
|
||||
{{MAIN_TABLE}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">大运<span class="note">{{LUCK_NOTE}}</span></div>
|
||||
{{LUCK_STRIP}}
|
||||
{{CHILDHOOD_BLOCK}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">流年<span class="note">出生年至今后十年,今年高亮,红字年份为交运年</span></div>
|
||||
{{FLOW_YEARS}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sect">流月<span class="note">本年节气月</span></div>
|
||||
{{FLOW_MONTHS}}
|
||||
</div>
|
||||
|
||||
<div class="card foot">
|
||||
{{SEASON_BAR}}<br>
|
||||
{{AUDIT}}
|
||||
<div class="brand">灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考</div>
|
||||
</div>
|
||||
<div class="foot">灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
+115
-6
@@ -4,20 +4,33 @@
|
||||
用法:
|
||||
python scripts/self_update.py --check 检查更新(24 小时缓存,重复检查不拉网络)
|
||||
python scripts/self_update.py --notify 只读缓存打印提示,绝不碰网络(供 cast_chart 调用)
|
||||
python scripts/self_update.py --apply 下载 zipball → 按 manifest 逐文件校验 → 同步本地
|
||||
python scripts/self_update.py --apply 下载 zipball → 验签 → 按 manifest 逐文件校验 → 同步本地
|
||||
|
||||
退出码(--apply):0 成功;2 无需更新或配置缺失;3 失败(本地原样不动)。
|
||||
|
||||
信任模型(三道防线,各防一层):
|
||||
1. 签名信任锚:manifest.json 附带 ed25519 签名 manifest.json.sig,
|
||||
公钥内置于本文件(UPDATE_PUBLIC_KEY_HEX),私钥仅作者持有
|
||||
(~/.zipec/signing.key,不进任何仓库)。仓库(GitHub/Gitea)或
|
||||
下载链路被攻破时,攻击者改 manifest 即验签失败——防「源头投毒」。
|
||||
2. 双源交叉验证:--apply 从 GitHub 与 Gitea 各拉一份 manifest.json,
|
||||
与 zip 内字节逐一比对,任一不一致即中止——防「单一源被篡改/
|
||||
劫持后单独口径」。
|
||||
3. 逐文件 SHA256:manifest 通过前两关后,逐文件核对清单哈希——
|
||||
防「zip 内文件与清单不符」(传输损坏、打包错误、部分篡改)。
|
||||
manifest.json.sig 本身不进 manifest 清单(自指),随仓库提交分发。
|
||||
|
||||
远端双线路 failover:GitHub 优先,Gitea 兜底。HTTPS_PROXY/HTTP_PROXY 等
|
||||
代理环境变量由 urllib 默认读取、自动生效。测试或自建场景可用环境变量
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS(逗号分隔)整体覆盖远端地址,
|
||||
支持 file:// 形式。
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS / ZIPEC_MANIFEST_URLS(逗号分隔)
|
||||
整体覆盖远端地址,支持 file:// 形式。
|
||||
|
||||
抓取通道:urllib 优先;macOS 框架版 Python 常见的根证书缺失
|
||||
(SSL CERTIFICATE_VERIFY_FAILED)时自动回退到系统 curl(curl 自带
|
||||
系统信任库且自带代理环境变量支持)。
|
||||
系统信任库且自带代理环境变量支持)。manifest 与 .sig 的抓取同走此通道。
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -31,11 +44,24 @@ import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
import _update_sig # 纯 Python ed25519(RFC 8032),零依赖验签
|
||||
|
||||
# ---- 远端配置(GitHub 优先,Gitea 兜底)----
|
||||
GITHUB_VERSION_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/VERSION"
|
||||
GITHUB_ZIPBALL_URL = "https://codeload.github.com/mxiaonian/ZiPingEightChar/zip/refs/heads/main"
|
||||
GITHUB_MANIFEST_URL = "https://raw.githubusercontent.com/mxiaonian/ZiPingEightChar/main/manifest.json"
|
||||
GITEA_VERSION_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/raw/branch/main/VERSION"
|
||||
GITEA_ZIPBALL_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/archive/main.zip"
|
||||
GITEA_MANIFEST_URL = "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar/raw/branch/main/manifest.json"
|
||||
|
||||
# 更新签名信任锚:ed25519 公钥(32 字节,十六进制),作者 2026-10-10 生成。
|
||||
# 指纹 SHA256(公钥) = 3089bc1cc9a453e5be66a36574635da6b2ad9797c57af11164111faca157411c
|
||||
# 用途:--apply 校验 manifest.json.sig。私钥仅作者持有(~/.zipec/signing.key),
|
||||
# 不进任何仓库;公钥更换 = 信任锚更换,须随版本说明明确公告。
|
||||
UPDATE_PUBLIC_KEY_HEX = "49103c13cad0d9c67207945b4e4f6ff81d1261339eb0de4a60c2a1cf949d7852"
|
||||
|
||||
CACHE_REL = "data/.update_check.json"
|
||||
CACHE_TTL_SECONDS = 24 * 3600
|
||||
@@ -67,6 +93,14 @@ def _zipball_urls() -> list:
|
||||
return [GITHUB_ZIPBALL_URL, GITEA_ZIPBALL_URL]
|
||||
|
||||
|
||||
def _manifest_urls() -> list:
|
||||
"""双源交叉验证用:各源的 manifest.json 直链(.sig 直链 = 本地址 + '.sig')。"""
|
||||
env = os.environ.get("ZIPEC_MANIFEST_URLS")
|
||||
if env:
|
||||
return [u.strip() for u in env.split(",") if u.strip()]
|
||||
return [GITHUB_MANIFEST_URL, GITEA_MANIFEST_URL]
|
||||
|
||||
|
||||
def _fetch_bytes(url: str, timeout: int) -> bytes:
|
||||
"""urllib 优先;SSL 证书缺失等失败时回退系统 curl(macOS 框架版 Python 常见)。"""
|
||||
try:
|
||||
@@ -284,8 +318,58 @@ def _verify_manifest(src: Path, files: dict):
|
||||
return errors
|
||||
|
||||
|
||||
def _cross_check_manifest(manifest_bytes: bytes):
|
||||
"""从全部配置源各拉一份 manifest.json,与 zip 内字节逐一比对。
|
||||
|
||||
返回 None = 各源与 zip 完全一致;否则返回错误信息(任一源不可达也
|
||||
视为失败:无法交叉确认时宁可中止)。
|
||||
"""
|
||||
urls = _manifest_urls()
|
||||
if not urls:
|
||||
return "未配置任何 manifest 源"
|
||||
for url in urls:
|
||||
try:
|
||||
data = _fetch_bytes(url, NETWORK_TIMEOUT)
|
||||
except Exception as e:
|
||||
return f"{url} 不可达({e}),无法完成双源交叉验证"
|
||||
if data != manifest_bytes:
|
||||
return f"{url} 的 manifest.json 与下载包内字节不一致,两源口径分歧"
|
||||
return None
|
||||
|
||||
|
||||
def _load_manifest_sig(src: Path):
|
||||
"""优先取 zip 内 manifest.json.sig;缺失则按 manifest 源单独拉 .sig(curl 回退同路)。
|
||||
|
||||
返回签名文本(base64)或 None。
|
||||
"""
|
||||
p = src / "manifest.json.sig"
|
||||
if p.is_file():
|
||||
try:
|
||||
return p.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
for url in _manifest_urls():
|
||||
try:
|
||||
return _fetch_text(url + ".sig")
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _signature_valid(manifest_bytes: bytes, sig_text: str) -> bool:
|
||||
try:
|
||||
sig = base64.b64decode(sig_text.strip(), validate=True)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
pk = bytes.fromhex(UPDATE_PUBLIC_KEY_HEX)
|
||||
except ValueError:
|
||||
return False
|
||||
return _update_sig.verify(pk, manifest_bytes, sig)
|
||||
|
||||
|
||||
def _sync(root: Path, src: Path, files: dict) -> None:
|
||||
managed = set(files) | {"manifest.json", "VERSION"}
|
||||
managed = set(files) | {"manifest.json", "manifest.json.sig", "VERSION"}
|
||||
copied = deleted = 0
|
||||
for rel in sorted(managed - {"VERSION"}): # VERSION 最后写
|
||||
s = src / rel
|
||||
@@ -360,7 +444,32 @@ def cmd_apply(root: Path) -> int:
|
||||
print("失败:远端包内找不到 manifest.json(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
try:
|
||||
manifest = json.loads((src / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest_bytes = (src / "manifest.json").read_bytes()
|
||||
except OSError as e:
|
||||
print(f"失败:manifest.json 读取异常({e})(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
# 防线 1:双源交叉验证(各源 manifest 与 zip 内字节一致)
|
||||
err = _cross_check_manifest(manifest_bytes)
|
||||
if err:
|
||||
print(f"失败:{err},已中止(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
print(f"双源交叉验证通过({len(_manifest_urls())} 个源口径一致)")
|
||||
|
||||
# 防线 2:内置公钥验签 manifest(缺 sig 视同验签失败)
|
||||
sig_text = _load_manifest_sig(src)
|
||||
if sig_text is None:
|
||||
print("失败:包未带 manifest.json.sig 签名,可能为旧版或被篡改,"
|
||||
"已中止(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
if not _signature_valid(manifest_bytes, sig_text):
|
||||
print("失败:manifest.json 签名校验不符,包可能被篡改,"
|
||||
"已中止(本地未改动)", file=sys.stderr)
|
||||
return 3
|
||||
print("manifest 签名校验通过")
|
||||
|
||||
try:
|
||||
manifest = json.loads(manifest_bytes.decode("utf-8"))
|
||||
files = manifest["files"]
|
||||
except Exception as e:
|
||||
print(f"失败:manifest.json 无法解析({e})(本地未改动)", file=sys.stderr)
|
||||
|
||||
+125
-17
@@ -6,11 +6,18 @@
|
||||
python share_export.py --birth "1990-05-14 08:30" --place "四川省成都市" --gender 男
|
||||
|
||||
脱敏规格(见 share/README.md):不含姓名;出生信息只保留年月日与时辰(不留分钟
|
||||
与钟表时间);地域只保留到省级;事件一律类目化;正文自由文本(归纳、盘面细节、
|
||||
反馈原文)不入包。盘 hash = sha1(四柱+出生地) 前 8 位,用于去重与撤回。
|
||||
与钟表时间);地域保留到城市级(省+地级市,区县及以下删除);事件带类目与简介
|
||||
(note),简介中城市级地名保留(规则蒸馏要用方位信息),姓名、详细住址
|
||||
(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除;归纳、盘面细节等
|
||||
正文自由文本不入包。盘 hash = sha1(四柱+出生地) 前 8 位,用于去重与撤回。
|
||||
|
||||
完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态);
|
||||
不满足拒绝导出,退出码 2 并说明缺什么。退出码:0 成功;2 不可用/不完整;3 内部错误。
|
||||
不满足拒绝导出,退出码 2 并说明缺什么。事件简介缺失(档案未写事件内容)
|
||||
不挡导出,JSON 中如实反映(events 为空或 note 为 null)。
|
||||
换盘残留提示:导出时若 pending/ 中已有同出生日、同性别但盘 hash 不同的
|
||||
旧包(多为换盘/信息更正后的孤儿包),stderr 打 WARN 列出路径,提醒人工清理
|
||||
(只提示,不自动删)。
|
||||
退出码:0 成功;2 不可用/不完整;3 内部错误。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,6 +32,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
import _memory_store as store
|
||||
|
||||
SCHEMA = "share/v1"
|
||||
@@ -37,7 +45,7 @@ CATEGORIES: list[tuple[str, tuple[str, ...]]] = [
|
||||
("事业", ("事业", "工作", "升职", "跳槽", "调动", "失业", "创业", "辞职", "职务", "官非")),
|
||||
("财运", ("财运", "破财", "投资", "收入", "赚钱", "欠债", "债务", "置业", "买房")),
|
||||
("健康", ("健康", "生病", "手术", "伤病", "身体", "住院", "疾病", "意外")),
|
||||
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书")),
|
||||
("学业", ("学业", "考试", "升学", "考研", "高考", "留学", "读书", "大学")),
|
||||
("六亲", ("父亲", "母亲", "父母", "子女", "孩子", "兄弟", "姐妹", "六亲", "亲人", "丧")),
|
||||
("流年运势", ("流年", "运势", "大运", "运程")),
|
||||
]
|
||||
@@ -60,6 +68,9 @@ _PROVINCE_SHORT = {
|
||||
|
||||
_STATUS_TAGS = {"【已核实】": "已核实", "【已证伪】": "已证伪", "【未核实】": "未核实"}
|
||||
|
||||
_GZ_RE = re.compile(r"[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]")
|
||||
_YEAR_RE = re.compile(r"(?:19|20)\d{2}")
|
||||
|
||||
|
||||
def _categorize_all(text: str) -> list[str]:
|
||||
return [cat for cat, keywords in CATEGORIES if any(k in text for k in keywords)]
|
||||
@@ -71,7 +82,7 @@ def _categorize(text: str) -> str:
|
||||
|
||||
|
||||
def _province_of(birthplace: str) -> str | None:
|
||||
"""地域脱敏:只保留到省级;识别不出省级则舍弃(宁缺毋滥)。"""
|
||||
"""地域脱敏:省级;识别不出省级则舍弃(宁缺毋滥)。"""
|
||||
for p in _PROVINCES:
|
||||
if p in birthplace:
|
||||
return p
|
||||
@@ -82,6 +93,42 @@ def _province_of(birthplace: str) -> str | None:
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _city_of(birthplace: str) -> str | None:
|
||||
"""地域脱敏:城市级(地级市/州/盟;直辖市即省级本身)。识别不出则舍弃。"""
|
||||
province = _province_of(birthplace)
|
||||
if province in ("北京市", "天津市", "上海市", "重庆市",
|
||||
"香港特别行政区", "澳门特别行政区"):
|
||||
return province
|
||||
rest = birthplace
|
||||
if province and rest.startswith(province):
|
||||
rest = rest[len(province):]
|
||||
m = re.match(r"^(.{2,10}?(?:市|州|盟))", rest)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _sanitize_note(text: str, name: str) -> str:
|
||||
"""事件简介脱敏:姓名、身份证/电话、详细住址(小区/门牌/街道号)一律删除;
|
||||
城市级地名保留(规则蒸馏要用方位信息)。"""
|
||||
t = text
|
||||
if name and name not in PLACEHOLDERS:
|
||||
t = t.replace(name, "命主")
|
||||
t = re.sub(r"\d{17}[\dXx]", "(已删)", t) # 身份证
|
||||
t = re.sub(r"1[3-9]\d{9}", "(已删)", t) # 手机号
|
||||
t = re.sub(r"\d{3,4}-\d{7,8}", "(已删)", t) # 座机号
|
||||
t = re.sub(r"[^\s,。;:、()]*(?:小区|公寓|花园|山庄|新村|苑|大院)"
|
||||
r"[^\s,。;:、()]*", "(已删)", t) # 小区/楼盘名
|
||||
t = re.sub(r"\d+\s*(?:号楼|栋|单元|室|号院|层|户)", "(已删)", t) # 楼栋门牌
|
||||
t = re.sub(r"(?:街|路|巷|大道|道)\s*\d+\s*号(?:\s*\d+\s*室)?",
|
||||
"(已删)", t) # 街道号
|
||||
return t.strip()
|
||||
|
||||
|
||||
def _strip_tags(text: str) -> str:
|
||||
for tag in _STATUS_TAGS:
|
||||
text = text.replace(tag, "")
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _section_lines(body: str, heading: str) -> list[str]:
|
||||
"""取档案正文某固定小节(SECTIONS 内)的行,到下一个固定小节或文末。"""
|
||||
lines = body.splitlines()
|
||||
@@ -129,22 +176,59 @@ def _parse_focus(body: str) -> list[str]:
|
||||
return cats
|
||||
|
||||
|
||||
def _parse_feedback(body: str) -> list[dict]:
|
||||
"""反馈事件:只取年份、类目、核实状态——反馈原文不入包。"""
|
||||
def _status_of(t: str) -> str:
|
||||
for tag, name in _STATUS_TAGS.items():
|
||||
if tag in t:
|
||||
return name
|
||||
return "未核实"
|
||||
|
||||
|
||||
def _parse_feedback(body: str, name: str) -> list[dict]:
|
||||
"""反馈事件:年份、类目、核实状态 + 事件简介(note,脱敏后;无实质内容则为 None)。"""
|
||||
events = []
|
||||
for ln in _section_lines(body, "反馈与澄清"):
|
||||
t = ln.strip().lstrip("-•· ").strip()
|
||||
if not t or t in PLACEHOLDERS:
|
||||
continue
|
||||
status = "未核实"
|
||||
for tag, name in _STATUS_TAGS.items():
|
||||
if tag in t:
|
||||
status = name
|
||||
break
|
||||
m = re.search(r"(?:19|20)\d{2}", t)
|
||||
m = _YEAR_RE.search(t)
|
||||
note = _sanitize_note(_strip_tags(t), name)
|
||||
events.append({"year": int(m.group(0)) if m else None,
|
||||
"category": _categorize(t),
|
||||
"status": status})
|
||||
"status": _status_of(t),
|
||||
"note": note or None})
|
||||
return events
|
||||
|
||||
|
||||
def _parse_events(body: str, name: str) -> list[dict]:
|
||||
"""流年大事:年份(区间取首年)、干支、标签、核实状态 + 事件简介(脱敏后)。
|
||||
|
||||
行形如「- 2016(丙申·交运年,入丁酉运):【已核实】离乡,自四川乐山赴厦门读大学。」;
|
||||
无括号标签或缺事件内容时对应字段为 None(如实反映,不挡导出)。
|
||||
"""
|
||||
events = []
|
||||
for ln in _section_lines(body, "流年大事"):
|
||||
t = ln.strip().lstrip("-•· ").strip()
|
||||
if not t or t in PLACEHOLDERS:
|
||||
continue
|
||||
m = re.match(r"^((?:19|20)\d{2})(?:[–—\-]\d{2,4})?"
|
||||
r"(?:(([^)]*)))?\s*[::]\s*(.+)$", t)
|
||||
if m:
|
||||
year = int(m.group(1))
|
||||
label = m.group(2) or None
|
||||
note = m.group(3)
|
||||
else:
|
||||
ym = _YEAR_RE.search(t)
|
||||
year = int(ym.group(0)) if ym else None
|
||||
label = None
|
||||
note = t
|
||||
ganzhi = None
|
||||
if label:
|
||||
gz = _GZ_RE.search(label)
|
||||
ganzhi = gz.group(0) if gz else None
|
||||
note = _sanitize_note(_strip_tags(note), name)
|
||||
events.append({"year": year, "ganzhi": ganzhi, "label": label,
|
||||
"note": note or None, "status": _status_of(t),
|
||||
"category": _categorize(t)})
|
||||
return events
|
||||
|
||||
|
||||
@@ -154,13 +238,18 @@ def chart_hash(pillars: list[str], birthplace: str) -> str:
|
||||
|
||||
|
||||
def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
|
||||
"""组装脱敏包;返回 (package, 缺失项列表)。package 保证不含姓名/分钟/省以下地域。"""
|
||||
"""组装脱敏包;返回 (package, 缺失项列表)。
|
||||
|
||||
保证不含姓名、出生分钟/钟表时间、区县及以下地域;事件简介中城市级地名保留,
|
||||
直接定位信息(详细住址、证件、电话)已删除。
|
||||
"""
|
||||
pillars = meta.get("pillars", [])
|
||||
if isinstance(pillars, str):
|
||||
pillars = pillars.split()
|
||||
pillars = [str(p) for p in pillars]
|
||||
birthplace = str(meta.get("birthplace", ""))
|
||||
gender = str(meta.get("gender", ""))
|
||||
name = str(meta.get("name", ""))
|
||||
|
||||
pkg: dict = {"schema": SCHEMA,
|
||||
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
@@ -169,7 +258,8 @@ def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
|
||||
"pillars": pillars,
|
||||
"luck_pillars": _parse_luck(body),
|
||||
"focus_categories": _parse_focus(body),
|
||||
"feedback": _parse_feedback(body)}
|
||||
"events": _parse_events(body, name),
|
||||
"feedback": _parse_feedback(body, name)}
|
||||
|
||||
# 出生信息脱敏:只保留年月日与时辰(时支),不留分钟与钟表时间
|
||||
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", str(meta.get("birth_clock", "")).strip())
|
||||
@@ -180,6 +270,9 @@ def build_package(meta: dict, body: str) -> tuple[dict, list[str]]:
|
||||
province = _province_of(birthplace)
|
||||
if province:
|
||||
pkg["region_province"] = province
|
||||
city = _city_of(birthplace)
|
||||
if city:
|
||||
pkg["region_city"] = city
|
||||
|
||||
missing = []
|
||||
if not gender:
|
||||
@@ -210,7 +303,7 @@ def _find_archive(args) -> Path | None:
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="share_export.py",
|
||||
description="从 memory 档案导出结构化脱敏包(JSON)到 share/pending/;不含姓名、分钟、省以下地域")
|
||||
description="从 memory 档案导出结构化脱敏包(JSON)到 share/pending/;不含姓名、分钟、区县及以下地域")
|
||||
ap.add_argument("--archive", default=None, help="档案文件路径(直接指定)")
|
||||
ap.add_argument("--name", default=None, help="姓名(检索条件)")
|
||||
ap.add_argument("--gender", default=None, choices=["男", "女"], help="性别(检索条件)")
|
||||
@@ -249,6 +342,21 @@ def main() -> int:
|
||||
pending = share_root / "pending"
|
||||
pending.mkdir(parents=True, exist_ok=True)
|
||||
out = pending / f"{pkg['chart_hash']}.json"
|
||||
# 换盘孤儿包提示:pending 中同出生日、同性别但盘 hash 不同的旧包,
|
||||
# 多为换盘/信息更正后的残留,提醒人工清理(只提示,不自动删)
|
||||
for old in sorted(pending.glob("*.json")):
|
||||
if old.name == out.name:
|
||||
continue
|
||||
try:
|
||||
opkg = json.loads(old.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if (opkg.get("birth_date") == pkg.get("birth_date")
|
||||
and opkg.get("gender") == pkg.get("gender")
|
||||
and opkg.get("chart_hash") != pkg["chart_hash"]):
|
||||
print(f"WARN: pending 中已有同出生日同性别的旧包(盘 hash 不同),"
|
||||
f"疑似换盘残留:{old};确认后请手动删除,避免误推。",
|
||||
file=sys.stderr)
|
||||
out.write_text(json.dumps(pkg, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print(out)
|
||||
|
||||
+45
-20
@@ -9,7 +9,8 @@ ZIPING_SHARE_GITEA_REMOTE 覆盖)。两个远端逐个尝试,至少一个成
|
||||
|
||||
推送成功后:调 _license_gate.grant_uses(root, 10, 盘hash) 计励(同一盘只计一次),
|
||||
并把该包移入 share/pushed/。推送失败保留 pending,退出码 3。
|
||||
无 git 或无网络安静失败(不打栈)。
|
||||
无 git 或无网络安静失败(不打栈);远端推送失败时在 stderr 带该远端
|
||||
git 报错的末几行摘要,GitHub 线失败另附 HTTPS_PROXY 代理提示。
|
||||
|
||||
退出码:0 至少一包推送成功或无包可推;2 输入不可用;3 推送失败。
|
||||
"""
|
||||
@@ -26,6 +27,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
|
||||
import _license_gate as gate
|
||||
|
||||
GRANT_USES = 10 # 每盘推送成功的计励次数
|
||||
@@ -39,24 +41,38 @@ def _git(*args: str, cwd: str | None = None) -> subprocess.CompletedProcess:
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
def _push_to_remote(remote: str, pkg: Path, workdir: str) -> bool:
|
||||
"""克隆远端 → 放入脱敏包 → commit+push。任一步失败返回 False(安静)。"""
|
||||
def _err_tail(cp: subprocess.CompletedProcess, n: int = 2) -> str:
|
||||
"""取 git 命令报错的末几行(stderr 优先,退而 stdout)。"""
|
||||
for text in (cp.stderr, cp.stdout):
|
||||
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
|
||||
if lines:
|
||||
return " / ".join(lines[-n:])
|
||||
return f"退出码 {cp.returncode}"
|
||||
|
||||
|
||||
def _push_to_remote(remote: str, pkg: Path, workdir: str) -> tuple[bool, str]:
|
||||
"""克隆远端 → 放入脱敏包 → commit+push。返回 (成功与否, 失败摘要)。"""
|
||||
try:
|
||||
if _git("clone", "--depth", "50", remote, workdir).returncode != 0:
|
||||
return False
|
||||
r = _git("clone", "--depth", "50", remote, workdir)
|
||||
if r.returncode != 0:
|
||||
return False, f"clone 失败:{_err_tail(r)}"
|
||||
dst = Path(workdir) / pkg.name
|
||||
shutil.copy2(pkg, dst)
|
||||
if _git("add", pkg.name, cwd=workdir).returncode != 0:
|
||||
return False
|
||||
commit = _git("-c", "user.name=ziping-share",
|
||||
"-c", "user.email=ziping-share@localhost",
|
||||
"commit", "--allow-empty", "-m", f"share: {pkg.stem}",
|
||||
cwd=workdir)
|
||||
if commit.returncode != 0:
|
||||
return False
|
||||
return _git("push", "origin", "HEAD:main", cwd=workdir).returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
r = _git("add", pkg.name, cwd=workdir)
|
||||
if r.returncode != 0:
|
||||
return False, f"add 失败:{_err_tail(r)}"
|
||||
r = _git("-c", "user.name=ziping-share",
|
||||
"-c", "user.email=ziping-share@localhost",
|
||||
"commit", "--allow-empty", "-m", f"share: {pkg.stem}",
|
||||
cwd=workdir)
|
||||
if r.returncode != 0:
|
||||
return False, f"commit 失败:{_err_tail(r)}"
|
||||
r = _git("push", "origin", "HEAD:main", cwd=workdir)
|
||||
if r.returncode != 0:
|
||||
return False, f"push 失败:{_err_tail(r)}"
|
||||
return True, ""
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
def _remotes(root: Path) -> list[tuple[str, str]]:
|
||||
@@ -106,13 +122,22 @@ def main() -> int:
|
||||
for pkg in packages:
|
||||
chart_hash = pkg.stem
|
||||
ok_remotes = []
|
||||
failures = []
|
||||
for name, remote in remotes:
|
||||
with tempfile.TemporaryDirectory(prefix="ziping-share-") as td:
|
||||
if _push_to_remote(remote, pkg, td):
|
||||
ok_remotes.append(name)
|
||||
ok, why = _push_to_remote(remote, pkg, td)
|
||||
if ok:
|
||||
ok_remotes.append(name)
|
||||
else:
|
||||
if name == "github_remote":
|
||||
why += (";若本机直连 GitHub 不通,可通过 HTTPS_PROXY "
|
||||
"环境变量配置代理")
|
||||
failures.append(f"{name}:{why}")
|
||||
for f in failures:
|
||||
print(f"WARN {pkg.name} {f}", file=sys.stderr)
|
||||
if not ok_remotes:
|
||||
print(f"FAIL {pkg.name}:所有远端推送失败(无网络或远端不可达),"
|
||||
f"pending 保留。", file=sys.stderr)
|
||||
print(f"FAIL {pkg.name}:所有远端推送失败,pending 保留。",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
granted = gate.grant_uses(root, GRANT_USES, chart_hash)
|
||||
shutil.move(str(pkg), str(pushed / pkg.name))
|
||||
|
||||
+4
-4
@@ -15,11 +15,11 @@
|
||||
|
||||
| 项目 | 处理 |
|
||||
|---|---|
|
||||
| 姓名 | 一律删除,不入包 |
|
||||
| 姓名 | 一律删除,不入包(事件简介中出现的姓名替换为「命主」) |
|
||||
| 出生信息 | 只保留年月日与时辰(时辰以时支表示),不留分钟与钟表时间 |
|
||||
| 地域 | 只保留到省级,省以下(市、县、区县)一律删除 |
|
||||
| 事件 | 一律类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他),不写细节 |
|
||||
| 正文自由文本 | 不入包(归纳、盘面细节、反馈原文均不导出) |
|
||||
| 地域 | 保留到城市级(省+地级市),区县及以下一律删除 |
|
||||
| 事件 | 类目化(婚姻/感情/事业/财运/健康/学业/六亲/流年运势/其他)+ 事件简介(note);简介中**城市级地名保留**(规则蒸馏要用方位信息),详细住址(小区/门牌/街道号)、身份证/电话等直接定位信息一律删除;书写事件简介时只写到城市级 |
|
||||
| 正文自由文本 | 归纳、盘面细节不入包;入包的事件简介按上行规格脱敏 |
|
||||
| 标识 | 仅以盘 hash(四柱+出生地 sha1 前 8 位)标识,用于去重与撤回 |
|
||||
|
||||
## 用途与许可
|
||||
|
||||
@@ -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()
|
||||
+183
-12
@@ -10,7 +10,7 @@ SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
|
||||
|
||||
|
||||
def _chart():
|
||||
def _chart(name="张三", place="四川省成都市"):
|
||||
"""用真实排盘管线造一份 ChartData(成都 1990 男盘)。"""
|
||||
from datetime import datetime
|
||||
from astro.solar_time import to_true_solar_time
|
||||
@@ -39,7 +39,7 @@ def _chart():
|
||||
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="四川省成都市",
|
||||
name=name, gender="男", place=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,
|
||||
@@ -60,19 +60,190 @@ class HtmlRenderTest(unittest.TestCase):
|
||||
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 render.html import _ten_god
|
||||
self.assertEqual(_ten_god("庚", "丁"), "正官")
|
||||
self.assertEqual(_ten_god("庚", "丙"), "七杀")
|
||||
self.assertEqual(_ten_god("庚", "辛"), "劫财")
|
||||
self.assertEqual(_ten_god("庚", "壬"), "食神")
|
||||
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 render.html import _gz_void, _terrain
|
||||
self.assertEqual(_gz_void("庚子"), "辰巳")
|
||||
self.assertEqual(_gz_void("甲子"), "戌亥")
|
||||
self.assertEqual(_terrain("庚", "巳"), "长生")
|
||||
self.assertEqual(_terrain("庚", "子"), "死")
|
||||
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 HtmlXssTest(unittest.TestCase):
|
||||
"""XSS 防护:用户可控字段(姓名/出生地)一律转义,模板带 CSP,无脚本。"""
|
||||
|
||||
PAYLOADS = (
|
||||
"<script>alert(1)</script>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
'" autofocus onfocus=alert(1) x="',
|
||||
"javascript:alert(1)",
|
||||
'山西晋城 "><svg onload=alert(1)>',
|
||||
)
|
||||
|
||||
# 活跃标签(真实 < 起首、未被 < 转义)携带事件属性或 javascript: 协议
|
||||
_LIVE_HANDLER = re.compile(r"<[a-zA-Z][^<>]*\son\w+\s*=")
|
||||
_LIVE_JS_URL = re.compile(r"<[a-zA-Z][^<>]*javascript:")
|
||||
|
||||
def _render(self, name, place):
|
||||
from render.html import build_html
|
||||
return build_html(_chart(name=name, place=place))
|
||||
|
||||
def test_payloads_escaped(self):
|
||||
"""注入向量在输出中只以转义形态存在,裸形态消失。"""
|
||||
from html import escape
|
||||
for payload in self.PAYLOADS:
|
||||
for field in ("name", "place"):
|
||||
kw = {"name": "张三", "place": "四川省成都市", field: payload}
|
||||
html = self._render(**kw)
|
||||
esc = escape(payload, quote=True)
|
||||
if esc != payload: # 含 & < > " ' 的载荷:转义形态在、裸形态不在
|
||||
self.assertIn(esc, html, f"{field}={payload!r} 未转义")
|
||||
self.assertNotIn(payload, html, f"{field}={payload!r} 裸注入")
|
||||
|
||||
def test_no_live_markup_in_document(self):
|
||||
"""注入打到 name/place 后检查整份文档(含结构性占位符区域):
|
||||
无 <script> 标签、无活跃事件处理属性、无 javascript: 属性值。"""
|
||||
html = self._render("<img src=x onerror=alert(1)>",
|
||||
'" autofocus onfocus=alert(1) x="')
|
||||
self.assertNotIn("<script", html.lower())
|
||||
self.assertIsNone(self._LIVE_HANDLER.search(html),
|
||||
"存在活跃 on* 事件处理属性")
|
||||
self.assertIsNone(self._LIVE_JS_URL.search(html),
|
||||
"存在 javascript: 属性值")
|
||||
|
||||
def test_javascript_scheme_inert(self):
|
||||
"""javascript:alert(1) 无特殊字符、转义前后同形——只能作为纯文本出现。"""
|
||||
html = self._render("javascript:alert(1)", "javascript:alert(1)")
|
||||
self.assertIsNone(self._LIVE_JS_URL.search(html))
|
||||
|
||||
def test_csp_meta_present(self):
|
||||
"""CSP meta 存在且按模板实际资源类型收紧:default-src 'none',
|
||||
仅放行 data: 图片/字体与 inline 样式。"""
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
m = re.search(
|
||||
r'<meta http-equiv="Content-Security-Policy" content="([^"]+)"', html)
|
||||
self.assertIsNotNone(m, "缺少 CSP meta")
|
||||
csp = m.group(1)
|
||||
self.assertIn("default-src 'none'", csp)
|
||||
self.assertIn("img-src data:", csp)
|
||||
self.assertIn("style-src 'unsafe-inline'", csp)
|
||||
self.assertIn("font-src data:", csp)
|
||||
|
||||
def test_no_script_tag(self):
|
||||
"""盘面为纯静态页:输出不含任何 <script>。"""
|
||||
from render.html import build_html
|
||||
self.assertNotIn("<script", build_html(_chart()).lower())
|
||||
|
||||
def test_structural_placeholders_intact(self):
|
||||
"""转义不误伤结构性占位符:表格/条带 HTML 片段原样保留。"""
|
||||
html = self._render("<b>x</b>", '<i>y</i>')
|
||||
self.assertIn('<table class="pan">', html)
|
||||
self.assertIn('<div class="strip">', html)
|
||||
self.assertIn('data:image/png;base64,', html)
|
||||
|
||||
|
||||
class CastHtmlE2ETest(unittest.TestCase):
|
||||
|
||||
@@ -16,6 +16,10 @@ LOOKUP = SCRIPTS_DIR / "memory_lookup.py"
|
||||
ZHANGSAN = ["--name", "张三", "--gender", "男",
|
||||
"--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):
|
||||
env = dict(os.environ)
|
||||
@@ -117,3 +121,97 @@ class TestLookupNoMatch:
|
||||
def test_no_condition_exit_2(self, tmp_path):
|
||||
r = run(LOOKUP, tmp_path)
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
+160
-20
@@ -1,8 +1,11 @@
|
||||
"""自更新:版本比较、24h 缓存、manifest 校验拦截、apply 同步与保留区、退出码。
|
||||
|
||||
远端用本地临时目录伪造:VERSION / zipball 均以 file:// 地址经
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS 注入,不碰真实网络。
|
||||
远端用本地临时目录伪造:VERSION / zipball / manifest 直链均以 file://
|
||||
地址经 ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS / ZIPEC_MANIFEST_URLS
|
||||
注入,不碰真实网络。manifest 一律用测试密钥签名,UPDATE_PUBLIC_KEY_HEX
|
||||
由 autouse fixture 替换为测试公钥。
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
@@ -14,8 +17,19 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
import _update_sig
|
||||
import self_update as su
|
||||
|
||||
# 测试专用密钥(与正式私钥无关)
|
||||
TEST_SEED = bytes(range(32))
|
||||
TEST_PK_HEX = _update_sig.publickey(TEST_SEED).hex()
|
||||
OTHER_SEED = bytes(range(32, 64))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _test_public_key(monkeypatch):
|
||||
monkeypatch.setattr(su, "UPDATE_PUBLIC_KEY_HEX", TEST_PK_HEX)
|
||||
|
||||
|
||||
# ---- 造本地副本与伪造远端 ----
|
||||
|
||||
@@ -38,10 +52,19 @@ def make_local(tmp_path, version="3.5.0"):
|
||||
return root
|
||||
|
||||
|
||||
def make_remote(tmp_path, version="3.6.0", files=None, tamper=None):
|
||||
"""造远端 zipball(带一层顶层目录,模拟 GitHub codeload 结构)+ 远端 VERSION。
|
||||
def make_remote(tmp_path, version="3.6.0", files=None, tamper=None,
|
||||
sign_seed=TEST_SEED, zip_sig=True, sig_message=None,
|
||||
diverge_source=False):
|
||||
"""造远端:zipball(带一层顶层目录,模拟 GitHub codeload)+ 远端 VERSION
|
||||
+ 两个"源"目录(src_a/src_b,各存 manifest.json 与 manifest.json.sig
|
||||
直链文件,供双源交叉验证)。
|
||||
|
||||
tamper=<相对路径>:先按原内容算清单哈希,再篡改该文件,用于触发校验拦截。
|
||||
tamper=<相对路径>:先按原内容算清单哈希并签名,再篡改该文件 → 触发逐文件
|
||||
SHA256 拦截。sign_seed 控制签名密钥(换 OTHER_SEED 模拟错误私钥)。
|
||||
zip_sig=False:zip 内不放 manifest.json.sig(源目录仍放,可测单独拉取;
|
||||
配合 source_sig=False 则彻底无签名)。sig_message:改签别的内容,
|
||||
模拟 manifest 与签名不符。diverge_source=True:src_b 的 manifest 改一字节,
|
||||
模拟两源不一致。
|
||||
"""
|
||||
src = tmp_path / "zipcontent" / "ZiPingEightChar-main"
|
||||
src.mkdir(parents=True)
|
||||
@@ -54,25 +77,43 @@ def make_remote(tmp_path, version="3.6.0", files=None, tamper=None):
|
||||
rel: hashlib.sha256((src / rel).read_bytes()).hexdigest()
|
||||
for rel in all_files
|
||||
}
|
||||
(src / "manifest.json").write_text(
|
||||
json.dumps({"version": version, "files": hashes}, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest_bytes = (json.dumps({"version": version, "files": hashes}, indent=2)
|
||||
+ "\n").encode("utf-8")
|
||||
(src / "manifest.json").write_bytes(manifest_bytes)
|
||||
sig_b64 = base64.b64encode(
|
||||
_update_sig.sign(sign_seed, sig_message if sig_message is not None
|
||||
else manifest_bytes)).decode("ascii") + "\n"
|
||||
if tamper:
|
||||
(src / tamper).write_text("# tampered\n", encoding="utf-8")
|
||||
if zip_sig:
|
||||
(src / "manifest.json.sig").write_text(sig_b64, encoding="utf-8")
|
||||
zip_path = tmp_path / "remote.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for f in sorted(src.parent.rglob("*")):
|
||||
zf.write(f, f.relative_to(src.parent))
|
||||
# 双源直链文件
|
||||
source_dirs = []
|
||||
for name in ("src_a", "src_b"):
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
m = manifest_bytes
|
||||
if diverge_source and name == "src_b":
|
||||
m = m.replace(b'"files"', b'"filez"', 1)
|
||||
(d / "manifest.json").write_bytes(m)
|
||||
(d / "manifest.json.sig").write_text(sig_b64, encoding="utf-8")
|
||||
source_dirs.append(d)
|
||||
version_file = tmp_path / "REMOTE_VERSION"
|
||||
version_file.write_text(version + "\n", encoding="utf-8")
|
||||
return version_file, zip_path
|
||||
return version_file, zip_path, source_dirs
|
||||
|
||||
|
||||
def point_at_remote(monkeypatch, version_file, zip_path=None):
|
||||
def point_at_remote(monkeypatch, version_file, zip_path=None, source_dirs=None):
|
||||
monkeypatch.setenv("ZIPEC_VERSION_URLS", version_file.as_uri())
|
||||
if zip_path is not None:
|
||||
monkeypatch.setenv("ZIPEC_ZIPBALL_URLS", zip_path.as_uri())
|
||||
if source_dirs is not None:
|
||||
monkeypatch.setenv("ZIPEC_MANIFEST_URLS", ",".join(
|
||||
(d / "manifest.json").as_uri() for d in source_dirs))
|
||||
|
||||
|
||||
REMOTE_FILES = {
|
||||
@@ -91,7 +132,7 @@ class TestVersionCompare:
|
||||
|
||||
def test_newer_remote_marks_update(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
vf, _ = make_remote(tmp_path)
|
||||
vf, _, _ = make_remote(tmp_path)
|
||||
point_at_remote(monkeypatch, vf)
|
||||
assert su.cmd_check(root) == 0
|
||||
cache = json.loads((root / "data" / ".update_check.json").read_text())
|
||||
@@ -100,7 +141,7 @@ class TestVersionCompare:
|
||||
|
||||
def test_same_version_no_update(self, tmp_path, monkeypatch):
|
||||
root = make_local(tmp_path)
|
||||
vf, _ = make_remote(tmp_path, version="3.5.0")
|
||||
vf, _, _ = make_remote(tmp_path, version="3.5.0")
|
||||
point_at_remote(monkeypatch, vf)
|
||||
assert su.cmd_check(root) == 0
|
||||
cache = json.loads((root / "data" / ".update_check.json").read_text())
|
||||
@@ -167,15 +208,17 @@ class TestCacheFreshness:
|
||||
class TestApply:
|
||||
def test_success_syncs_and_preserves(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 0
|
||||
assert "更新完成" in capsys.readouterr().out
|
||||
out = capsys.readouterr().out
|
||||
assert "交叉验证通过" in out and "签名校验通过" in out and "更新完成" in out
|
||||
# 清单内文件被覆盖/新增
|
||||
assert (root / "VERSION").read_text().strip() == "3.6.0"
|
||||
assert (root / "scripts" / "cast_chart.py").read_text() == "# new\n"
|
||||
assert (root / "scripts" / "new_feature.py").exists()
|
||||
assert (root / "manifest.json").exists()
|
||||
assert (root / "manifest.json.sig").exists() # 签名随包同步到本地
|
||||
# 清单不含的本地多余文件被清理
|
||||
assert not (root / "stale_legacy.py").exists()
|
||||
assert not (root / "legacy").exists() # 清空后的空目录一并移除
|
||||
@@ -187,9 +230,9 @@ class TestApply:
|
||||
|
||||
def test_tampered_file_aborts_untouched(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
tamper="scripts/cast_chart.py")
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
tamper="scripts/cast_chart.py")
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "不符" in capsys.readouterr().err
|
||||
# 本地原样不动
|
||||
@@ -200,7 +243,7 @@ class TestApply:
|
||||
|
||||
def test_already_latest_exit_2(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp = make_remote(tmp_path, version="3.5.0")
|
||||
vf, zp, _ = make_remote(tmp_path, version="3.5.0")
|
||||
point_at_remote(monkeypatch, vf, zp)
|
||||
assert su.cmd_apply(root) == 2
|
||||
assert "无需更新" in capsys.readouterr().out
|
||||
@@ -218,6 +261,103 @@ class TestApply:
|
||||
assert (root / "VERSION").read_text().strip() == "3.5.0"
|
||||
|
||||
|
||||
class TestApplySigning:
|
||||
"""--apply 的签名信任锚与双源交叉验证。"""
|
||||
|
||||
def _untouched(self, root):
|
||||
assert (root / "VERSION").read_text().strip() == "3.5.0"
|
||||
assert (root / "scripts" / "cast_chart.py").read_text() == "# old\n"
|
||||
assert (root / "stale_legacy.py").exists()
|
||||
assert not (root / "scripts" / "new_feature.py").exists()
|
||||
assert not (root / "manifest.json.sig").exists()
|
||||
|
||||
def test_sig_fetched_separately_when_not_in_zip(self, tmp_path, monkeypatch):
|
||||
"""zip 内无 sig、但源直链有:单独拉取并验签通过。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES, zip_sig=False)
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 0
|
||||
assert (root / "VERSION").read_text().strip() == "3.6.0"
|
||||
|
||||
def test_missing_sig_everywhere_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
"""zip 与源直链都没有 sig → 视同为旧版或被篡改,拒。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES, zip_sig=False)
|
||||
for d in dirs:
|
||||
(d / "manifest.json.sig").unlink()
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "未带" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_sig_message_mismatch_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
"""签名签的是别的内容(manifest 被换)→ 验签拒。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
sig_message=b"forged manifest")
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "签名校验不符" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_wrong_signing_key_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
sign_seed=OTHER_SEED)
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "签名校验不符" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_manifest_one_byte_flip_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
"""manifest 改一字节(源直链同步改、签名照旧)→ 验签拒。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
for d in dirs: # 两源与 zip 保持一致地改,隔离出"验签"这一道
|
||||
m = d / "manifest.json"
|
||||
data = bytearray(m.read_bytes())
|
||||
data[20] ^= 0x01
|
||||
m.write_bytes(bytes(data))
|
||||
# zip 内 manifest 也同步改
|
||||
import zipfile as zf_mod
|
||||
extract = tmp_path / "repack"
|
||||
with zf_mod.ZipFile(zp) as z:
|
||||
z.extractall(extract)
|
||||
m = extract / "ZiPingEightChar-main" / "manifest.json"
|
||||
data = bytearray(m.read_bytes())
|
||||
data[20] ^= 0x01
|
||||
m.write_bytes(bytes(data))
|
||||
with zf_mod.ZipFile(zp, "w") as z:
|
||||
for f in sorted(extract.rglob("*")):
|
||||
z.write(f, f.relative_to(extract))
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "签名校验不符" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_divergent_sources_abort(self, tmp_path, monkeypatch, capsys):
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES,
|
||||
diverge_source=True)
|
||||
point_at_remote(monkeypatch, vf, zp, dirs)
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "不一致" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
def test_source_unreachable_aborts(self, tmp_path, monkeypatch, capsys):
|
||||
"""交叉验证时一个源不可达 → 宁可中止。"""
|
||||
root = make_local(tmp_path)
|
||||
vf, zp, dirs = make_remote(tmp_path, files=REMOTE_FILES)
|
||||
monkeypatch.setenv("ZIPEC_VERSION_URLS", vf.as_uri())
|
||||
monkeypatch.setenv("ZIPEC_ZIPBALL_URLS", zp.as_uri())
|
||||
monkeypatch.setenv("ZIPEC_MANIFEST_URLS",
|
||||
(dirs[0] / "manifest.json").as_uri()
|
||||
+ ",file:///nonexistent/manifest.json")
|
||||
assert su.cmd_apply(root) == 3
|
||||
assert "交叉验证" in capsys.readouterr().err
|
||||
self._untouched(root)
|
||||
|
||||
|
||||
class TestCastChartIntegration:
|
||||
def test_notice_on_stderr_stdout_clean(self):
|
||||
"""排盘成功后 stderr 出一行提示;stdout 命盘契约不受影响。"""
|
||||
|
||||
+85
-9
@@ -19,7 +19,7 @@ import _memory_store as store
|
||||
|
||||
PILLARS = ["庚午", "辛巳", "己卯", "戊辰"]
|
||||
BIRTH = "1990-05-14 08:30"
|
||||
PLACE = "四川省成都市"
|
||||
PLACE = "四川省成都市武侯区"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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,
|
||||
"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"}
|
||||
body = (
|
||||
"# 档案:张三(男)\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"
|
||||
+ (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
|
||||
"- 【已核实】2018 年升职,跳槽到外企\n- 【未核实】2020 年感情波动\n")
|
||||
)
|
||||
arch_dir = mem_root / "archives"
|
||||
arch_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = arch_dir / store.archive_name(
|
||||
PILLARS, "男", store.key_hash(BIRTH, PLACE, "男"))
|
||||
pillars, "男", store.key_hash(BIRTH, PLACE, "男"))
|
||||
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
@@ -77,11 +84,13 @@ class TestExport:
|
||||
text = out.read_text("utf-8")
|
||||
pkg = json.loads(text)
|
||||
|
||||
# 脱敏:不含姓名、出生分钟/钟表时间、省以下地域
|
||||
# 脱敏:不含姓名、出生分钟/钟表时间、区县及以下地域、直接定位信息
|
||||
assert "name" not in pkg
|
||||
assert "张三" not in text
|
||||
assert "08:30" not in text and "08:23" not in text
|
||||
assert "成都市" not in text
|
||||
assert "武侯区" 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
|
||||
|
||||
# 结构字段
|
||||
@@ -90,17 +99,50 @@ class TestExport:
|
||||
assert pkg["pillars"] == PILLARS
|
||||
assert pkg["birth_date"] == "1990-05-14"
|
||||
assert pkg["birth_shichen"] == "辰"
|
||||
# 地域放宽到城市级:省+地级市保留
|
||||
assert pkg["region_province"] == "四川省"
|
||||
assert pkg["region_city"] == "成都市"
|
||||
assert [l["pillar"] for l in pkg["luck_pillars"]] == ["壬午", "癸未", "甲申"]
|
||||
assert pkg["luck_pillars"][0]["start_year"] == 1998
|
||||
assert pkg["focus_categories"] == ["婚姻", "事业"]
|
||||
assert {f["year"] for f in pkg["feedback"]} == {2018, 2020}
|
||||
assert {f["status"] for f in pkg["feedback"]} == {"已核实", "未核实"}
|
||||
assert all(f["category"] for f in pkg["feedback"])
|
||||
# 反馈带事件简介(状态标签已剥离)
|
||||
fb = {f["year"]: f for f in pkg["feedback"]}
|
||||
assert fb[2018]["note"] == "2018 年升职,跳槽到外企"
|
||||
assert fb[2020]["note"] == "2020 年感情波动"
|
||||
# 盘 hash = sha1(四柱+出生地) 前 8,文件名即 hash
|
||||
assert out.stem == pkg["chart_hash"]
|
||||
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):
|
||||
make_archive(tmp_path / "memory")
|
||||
r = run(EXPORT, "--birth", BIRTH, "--place", PLACE, "--gender", "男",
|
||||
@@ -121,6 +163,20 @@ class TestExport:
|
||||
"--share-root", str(share_root))
|
||||
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
|
||||
class TestPush:
|
||||
@@ -190,6 +246,26 @@ class TestPush:
|
||||
assert pkg.is_file() # 失败保留 pending
|
||||
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
|
||||
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):
|
||||
root = tmp_path / "skill"
|
||||
|
||||
+66
-1
@@ -9,7 +9,16 @@ from pathlib import Path
|
||||
|
||||
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="男"):
|
||||
@@ -267,5 +276,61 @@ class TargetQueryTest(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""外层 tools/sync_license_copy.py 的集成测试。
|
||||
|
||||
sync/build/sign 工具留在授权方工作区外层 tools/,不随本仓库分发;
|
||||
副本或 clone 环境下 tools/ 不存在,整个模块 skip。
|
||||
|
||||
测试用微型假母版(monkeypatch 替换模块 SRC)同步进临时授权副本,
|
||||
不碰真实母版与真实副本;签名用测试私钥,UPDATE_PUBLIC_KEY_HEX 同步造假。
|
||||
"""
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOLS = ROOT.parent / "tools"
|
||||
SYNC_TOOL = TOOLS / "sync_license_copy.py"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (SYNC_TOOL.is_file()
|
||||
and (TOOLS / "build_manifest.py").is_file()
|
||||
and (TOOLS / "sign_manifest.py").is_file()),
|
||||
reason="外层 tools/ 不随仓库分发,本环境无同步工具",
|
||||
)
|
||||
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import _update_sig # noqa: E402
|
||||
|
||||
TEST_SEED = bytes(range(32))
|
||||
TEST_PK_HEX = _update_sig.publickey(TEST_SEED).hex()
|
||||
OTHER_SEED = bytes(range(32, 64)) # 与内置公钥不匹配的错误私钥
|
||||
STALE_SIG = base64.b64encode(b"\x00" * 64).decode("ascii") + "\n"
|
||||
|
||||
|
||||
def _load_module(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
sync_tool = _load_module("sync_license_copy", SYNC_TOOL)
|
||||
build_manifest = _load_module("build_manifest", TOOLS / "build_manifest.py")
|
||||
|
||||
|
||||
def _make_master(src: Path, pk_hex: str) -> None:
|
||||
"""微型母版:VERSION/SKILL.md/rules×2/cast_chart.py/self_update.py + 过期 manifest。"""
|
||||
(src / "rules").mkdir(parents=True)
|
||||
(src / "scripts").mkdir(parents=True)
|
||||
(src / "VERSION").write_text("9.9.9-test\n", encoding="utf-8")
|
||||
(src / "SKILL.md").write_text("---\nname: test\n---\n\n# 母版\n",
|
||||
encoding="utf-8")
|
||||
(src / "rules" / "r1.md").write_text("# 规则一\n", encoding="utf-8")
|
||||
(src / "rules" / "r2.md").write_text("# 规则二\n", encoding="utf-8")
|
||||
(src / "scripts" / "cast_chart.py").write_text(
|
||||
"#!/usr/bin/env python3\nprint('chart')\n", encoding="utf-8")
|
||||
(src / "scripts" / "self_update.py").write_text(
|
||||
f'UPDATE_PUBLIC_KEY_HEX = "{pk_hex}"\n', encoding="utf-8")
|
||||
# 母版随仓库分发的旧 manifest/sig:同步进副本后必须被重建/重签或移除
|
||||
(src / "manifest.json").write_text('{"version": "9.9.9-test", "files": {}}\n',
|
||||
encoding="utf-8")
|
||||
(src / "manifest.json.sig").write_text(STALE_SIG, encoding="utf-8")
|
||||
|
||||
|
||||
def _make_copy(dst: Path) -> None:
|
||||
"""已存在的授权副本:授权状态文件 + 带副本编号水印的 SKILL.md。"""
|
||||
(dst / "data").mkdir(parents=True)
|
||||
(dst / "data" / ".license.json").write_text("{}", encoding="utf-8")
|
||||
(dst / "SKILL.md").write_text(
|
||||
"---\nname: test\n---\n\n<!-- 副本编号:LY-TEST0000-0000 · 授权对象:测试员 · "
|
||||
"测试授权 · 禁止商业用途与个人传播 -->\n", encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pair(tmp_path, monkeypatch):
|
||||
src, dst = tmp_path / "master", tmp_path / "copy"
|
||||
_make_master(src, TEST_PK_HEX)
|
||||
_make_copy(dst)
|
||||
monkeypatch.setattr(sync_tool, "SRC", src)
|
||||
return src, dst
|
||||
|
||||
|
||||
def _key_file(tmp_path, seed: bytes) -> Path:
|
||||
p = tmp_path / "signing.key"
|
||||
p.write_text("# 测试私钥\n" + seed.hex() + "\n", encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _manifest_on_disk(dst: Path) -> dict:
|
||||
return json.loads((dst / "manifest.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_sync_rebuilds_manifest_and_signs(pair, tmp_path, capsys):
|
||||
"""同步后 manifest 与水印后文件逐哈希一致,sig 用内置公钥验签通过。"""
|
||||
_src, dst = pair
|
||||
key = _key_file(tmp_path, TEST_SEED)
|
||||
assert sync_tool.main(["--key", str(key), str(dst)]) == 0
|
||||
|
||||
manifest = _manifest_on_disk(dst)
|
||||
# 与 build_manifest 对水印后磁盘文件重新计算的结果完全一致
|
||||
assert manifest == build_manifest.build_manifest(dst)
|
||||
for rel in ("SKILL.md", "rules/r1.md", "rules/r2.md", "scripts/cast_chart.py"):
|
||||
assert rel in manifest["files"]
|
||||
# 水印确实落在文件里(哈希对应的是水印后内容)
|
||||
assert "LY-TEST0000-0000" in (dst / "SKILL.md").read_text(encoding="utf-8")
|
||||
|
||||
sig_text = (dst / "manifest.json.sig").read_text(encoding="utf-8")
|
||||
assert sig_text != STALE_SIG # 过期 sig 被新签名覆盖而非原样保留
|
||||
sig = base64.b64decode(sig_text.strip(), validate=True)
|
||||
assert _update_sig.verify(bytes.fromhex(TEST_PK_HEX),
|
||||
(dst / "manifest.json").read_bytes(), sig)
|
||||
out = capsys.readouterr().out
|
||||
assert f"公钥指纹 {_update_sig.fingerprint(bytes.fromhex(TEST_PK_HEX))[:16]}" in out
|
||||
assert f"{len(manifest['files'])} 个文件" in out
|
||||
|
||||
|
||||
def test_sync_no_sign_removes_stale_sig(pair, capsys):
|
||||
"""--no-sign:manifest 重建、过期 sig 移除、WARN、退出码 0。"""
|
||||
_src, dst = pair
|
||||
assert sync_tool.main(["--no-sign", str(dst)]) == 0
|
||||
assert _manifest_on_disk(dst) == build_manifest.build_manifest(dst)
|
||||
assert not (dst / "manifest.json.sig").exists()
|
||||
assert "WARN" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_sync_missing_key_fails_closed(pair, tmp_path, capsys):
|
||||
"""私钥缺失:manifest 已重建、过期 sig 移除、报错、退出码非 0。"""
|
||||
_src, dst = pair
|
||||
missing = tmp_path / "no-such.key"
|
||||
assert sync_tool.main(["--key", str(missing), str(dst)]) == 1
|
||||
assert _manifest_on_disk(dst) == build_manifest.build_manifest(dst)
|
||||
assert not (dst / "manifest.json.sig").exists()
|
||||
err = capsys.readouterr().err
|
||||
assert "私钥不存在" in err and "未签名" in err
|
||||
|
||||
|
||||
def test_sync_wrong_key_fails_closed(pair, tmp_path, capsys):
|
||||
"""签名私钥与副本内置公钥不匹配:独立验签拦截,sig 移除,退出码非 0。"""
|
||||
_src, dst = pair
|
||||
key = _key_file(tmp_path, OTHER_SEED)
|
||||
assert sync_tool.main(["--key", str(key), str(dst)]) == 1
|
||||
assert _manifest_on_disk(dst) == build_manifest.build_manifest(dst)
|
||||
assert not (dst / "manifest.json.sig").exists()
|
||||
assert "验签" in capsys.readouterr().err
|
||||
@@ -0,0 +1,152 @@
|
||||
"""ed25519(RFC 8032)纯 Python 实现与 manifest 签名工具的测试。
|
||||
|
||||
TEST 1/2/3 为 RFC 8032 §7.1 官方测试向量(空消息/1 字节/2 字节)。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import _update_sig as ed
|
||||
|
||||
# RFC 8032 §7.1:(SECRET KEY, PUBLIC KEY, MESSAGE, SIGNATURE)
|
||||
RFC8032_VECTORS = [
|
||||
("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
|
||||
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
|
||||
"",
|
||||
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155"
|
||||
"5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"),
|
||||
("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
|
||||
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c",
|
||||
"72",
|
||||
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da"
|
||||
"085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"),
|
||||
("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7",
|
||||
"fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025",
|
||||
"af82",
|
||||
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac"
|
||||
"18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"),
|
||||
]
|
||||
|
||||
|
||||
class TestRFC8032Vectors:
|
||||
@pytest.mark.parametrize("i", range(len(RFC8032_VECTORS)))
|
||||
def test_vector(self, i):
|
||||
seed, pk, msg, sig = (bytes.fromhex(x) for x in RFC8032_VECTORS[i])
|
||||
assert ed.publickey(seed) == pk # 公钥推导
|
||||
assert ed.sign(seed, msg) == sig # 确定性签名逐字节一致
|
||||
assert ed.verify(pk, msg, sig) is True # 验签通过
|
||||
|
||||
|
||||
class TestSignVerifyRoundtrip:
|
||||
def test_roundtrip_random_key(self):
|
||||
seed = os.urandom(32)
|
||||
msg = '{"version": "9.9.9", "files": {}}'.encode()
|
||||
assert ed.verify(ed.publickey(seed), msg, ed.sign(seed, msg)) is True
|
||||
|
||||
def test_message_one_byte_flip_fails(self):
|
||||
seed = os.urandom(32)
|
||||
pk = ed.publickey(seed)
|
||||
msg = b'{"files": {"a.py": "abc"}}'
|
||||
sig = ed.sign(seed, msg)
|
||||
bad = bytearray(msg)
|
||||
bad[3] ^= 0x01
|
||||
assert ed.verify(pk, bytes(bad), sig) is False
|
||||
|
||||
def test_wrong_public_key_fails(self):
|
||||
msg = b"manifest"
|
||||
sig = ed.sign(os.urandom(32), msg)
|
||||
assert ed.verify(ed.publickey(os.urandom(32)), msg, sig) is False
|
||||
|
||||
def test_forged_signature_fails(self):
|
||||
seed = os.urandom(32)
|
||||
pk, msg = ed.publickey(seed), b"manifest"
|
||||
sig = bytearray(ed.sign(seed, msg))
|
||||
sig[10] ^= 0x01 # 伪造:改 R 的一个字节
|
||||
assert ed.verify(pk, msg, bytes(sig)) is False
|
||||
assert ed.verify(pk, msg, os.urandom(64)) is False # 全随机签名
|
||||
|
||||
def test_malformed_inputs_return_false_not_raise(self):
|
||||
pk = ed.publickey(os.urandom(32))
|
||||
assert ed.verify(pk, b"m", b"") is False # 长度不足
|
||||
assert ed.verify(pk, b"m", b"\xff" * 64) is False # S 越界
|
||||
assert ed.verify(b"\xff" * 32, b"m", ed.sign(os.urandom(32), b"m")) is False
|
||||
assert ed.verify(b"short", b"m", b"x" * 64) is False # 公钥长度错
|
||||
sig = bytearray(ed.sign(os.urandom(32), b"m"))
|
||||
sig[31] |= 0x80 # R 的 y 坐标 >= 2^255-19 界外/非法点
|
||||
sig[0:32] = b"\xff" * 32
|
||||
assert ed.verify(pk, b"m", bytes(sig)) is False
|
||||
|
||||
|
||||
SIGN_TOOL = ROOT.parent / "tools" / "sign_manifest.py"
|
||||
|
||||
|
||||
class TestSignManifestTool:
|
||||
def _repo(self, tmp_path):
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "manifest.json").write_text(
|
||||
json.dumps({"version": "9.9.9", "files": {"a.py": "0" * 64}},
|
||||
indent=2) + "\n", encoding="utf-8")
|
||||
seed = os.urandom(32)
|
||||
key = tmp_path / "signing.key"
|
||||
key.write_text("# 测试私钥\n# 生成日期:2026-10-10\n" + seed.hex() + "\n",
|
||||
encoding="utf-8")
|
||||
return root, key, seed
|
||||
|
||||
def test_sign_and_verify(self, tmp_path):
|
||||
root, key, seed = self._repo(tmp_path)
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SIGN_TOOL), "--repo-root", str(root),
|
||||
"--key", str(key)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 0, r.stderr
|
||||
sig_text = (root / "manifest.json.sig").read_text(encoding="utf-8")
|
||||
assert sig_text.strip() == sig_text.rstrip("\n") and "\n" not in sig_text.strip()
|
||||
sig = base64.b64decode(sig_text.strip(), validate=True)
|
||||
assert len(sig) == 64
|
||||
manifest_bytes = (root / "manifest.json").read_bytes()
|
||||
assert ed.verify(ed.publickey(seed), manifest_bytes, sig) is True
|
||||
# manifest 改一字节 → 该签名不再有效
|
||||
bad = bytearray(manifest_bytes)
|
||||
bad[10] ^= 0x01
|
||||
assert ed.verify(ed.publickey(seed), bytes(bad), sig) is False
|
||||
|
||||
def test_missing_manifest_exit_2(self, tmp_path):
|
||||
root, key, _ = self._repo(tmp_path)
|
||||
(root / "manifest.json").unlink()
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SIGN_TOOL), "--repo-root", str(root),
|
||||
"--key", str(key)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert r.returncode == 2
|
||||
assert "manifest.json" in r.stderr
|
||||
|
||||
|
||||
class TestRealKeySetup:
|
||||
"""作者侧私钥落盘状态:存在、权限 600、绝不进 git 仓库。"""
|
||||
|
||||
def test_key_file_perms_and_not_in_repo(self):
|
||||
key = Path.home() / ".zipec" / "signing.key"
|
||||
if not key.exists():
|
||||
pytest.skip("作者机上才有私钥")
|
||||
assert stat.S_IMODE(key.stat().st_mode) == 0o600
|
||||
assert stat.S_IMODE(key.parent.stat().st_mode) == 0o700
|
||||
r = subprocess.run(["git", "-C", str(ROOT), "status", "--porcelain"],
|
||||
capture_output=True, text=True)
|
||||
assert "signing.key" not in r.stdout
|
||||
# 私钥对应的公钥 == self_update 内置信任锚
|
||||
import self_update as su
|
||||
seed = bytes.fromhex(
|
||||
[ln for ln in key.read_text().splitlines()
|
||||
if ln.strip() and not ln.startswith("#")][0])
|
||||
assert ed.publickey(seed).hex() == su.UPDATE_PUBLIC_KEY_HEX
|
||||
assert ed.fingerprint(ed.publickey(seed)) == (
|
||||
"3089bc1cc9a453e5be66a36574635da6b2ad9797c57af11164111faca157411c")
|
||||
Reference in New Issue
Block a user