Compare commits
3
Commits
55b6973e8e
...
v3.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f725df8ff6 | ||
|
|
a62f2a7d74 | ||
|
|
4797702e62 |
@@ -18,6 +18,13 @@ python scripts/cast_chart.py --name 张三 --gender 男 \
|
||||
|
||||
成功时命盘 Markdown 打印到 stdout,退出码 0。诊断信息只走 stderr。
|
||||
|
||||
可选:加 `--html` 同步生成 HTML 盘面图(`--html 路径.html` 指定输出,省略路径则写 `./命盘_<姓名>.html`;生成路径打在 stderr):
|
||||
|
||||
```bash
|
||||
python scripts/cast_chart.py --name 张三 --gender 男 \
|
||||
--birth "1990-05-14 08:30" --place "四川省成都市" --html
|
||||
```
|
||||
|
||||
另配岁运神煞查询工具(本命神煞已列于命盘 `## 神煞`,此工具查某流年/流月/大运/流日干支相对本命所逢之神煞):
|
||||
|
||||
```bash
|
||||
@@ -190,7 +197,7 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚
|
||||
|
||||
- **导出**:`python scripts/share_export.py --archive <档案路径>`(或按 `--birth/--place/--gender` 检索定位),生成结构化 JSON 脱敏包到 `share/pending/<盘hash>.json`。完整度门槛:性别/四柱/大运齐全 + 至少一条反馈事件(任意核实状态),不满足则拒绝导出(退出码 2)。**未核实推测不得入包**:归纳小节的推测性结论一律不导出;反馈事件(带核实状态标签)可入包,自由文本(反馈原文、盘面细节、归纳文字)一律不入。
|
||||
- **授权询问(强制)**:推送前必须先把脱敏包**全文展示**给用户,并询问是否授权推送——用户同意才执行推送;不同意则取消,脱敏包保留在 `share/pending/`(用户可随时删除),不影响技能正常使用。
|
||||
- **推送**:`python scripts/share_push.py`,把 `share/pending/` 的包推送到 `share/config.json` 配置的共建远端(GitHub / Gitea 逐个尝试,至少一个成功即算成功;远端地址由授权方填写)。推送成功后包移入 `share/pushed/`,并告知用户新增授权次数(+10;该盘已计励过则不重复加)。推送失败(无 git / 无网络 / 远端不可达)保留 pending,不丢数据。
|
||||
- **推送**:`python scripts/share_push.py`,把 `share/pending/` 的包推送到 `share/config.json` 配置的共建远端(GitHub 与 Gitea **两条线都推**,至少一个成功即算成功;远端地址由授权方填写)。推送成功后包移入 `share/pushed/`,并告知用户新增授权次数(+10;该盘已计励过则不重复加)。推送失败(无 git / 无网络 / 远端不可达)保留 pending,不丢数据。
|
||||
|
||||
## 版本与自更新
|
||||
|
||||
@@ -198,6 +205,13 @@ 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 盘面图(`cast_chart.py --html`):四柱主表(当前大运与流年并列对照)、大运/小运条、全量流年、当年流月,五行配色、空亡标虚线、节气边界与晚子时出警示条。模板在 `scripts/render/templates/paipan.html`,样式可直接改。
|
||||
|
||||
- **给用户看图**:agent 环境具备浏览器/发图能力时,排盘后生成 HTML 并直接打开,或截图发送给用户;纯文本环境则告知文件路径。
|
||||
- HTML 只是 Markdown 命盘的图形化重渲染,内容以 stdout 命盘为准;解释时引用的字段名仍以 Markdown 章节为准。
|
||||
|
||||
## 文风与反 AI 腔(强制)
|
||||
|
||||
命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止:
|
||||
|
||||
+11
-8
@@ -3,8 +3,8 @@
|
||||
".gitignore": "1e4671dee2d3123a45609ed82a4329531e0d41bf1b6895ab169ecd8813cfb8f5",
|
||||
"LICENSE.md": "6b839540e0fd4ab75530ec31fc5c3cbc3c2d5de5fe85068ef1c0901e54be9315",
|
||||
"README.md": "1e11e5d19856fbd9bb15068c74e184617a10b2e0b18c7e42d9b617cd0d8f534a",
|
||||
"SKILL.md": "8b493ae2236b5941c88e282d4793126487e4d81a6f3c24976020fb26e50cf67a",
|
||||
"VERSION": "76120c1f2c4311c2154c22a6b58e00e411e0ebe76afae0daf608d9196c61f114",
|
||||
"SKILL.md": "eaff2bc3cc1221417caaf40cf86741c7fe2e43f7538fd9efc0f436c13c030a0b",
|
||||
"VERSION": "eb41f86c3d67299b46490cde10158a521148fdc03641aa14a93fa2797becd78c",
|
||||
"data/.public_edition.json": "e8692c45d512c78903fc89a924d0e6d4dc9b95a78ca6b359bb7a5ef5c946ffee",
|
||||
"data/cities.json": "00f17905c8843218162e25238a78aeb4c4a3bb5424b611cc30ed9e436db31854",
|
||||
"memory/README.md": "b3cf2c58a8418a8c1a54243c2e09fbdb42f31dcd48ba8c841b74f1234c7fe063",
|
||||
@@ -570,7 +570,7 @@
|
||||
"scripts/astro/equation_of_time.py": "ee9331022b4ed35d407664e97b5ce9ce99167a06ce7cb8c9ffba5497a437da8e",
|
||||
"scripts/astro/solar_time.py": "35b45edc365083a6ce90afe076176c6f3e798509e76d3f03819fd1845491b153",
|
||||
"scripts/astro/timezone_resolve.py": "3a287a64bd50736ce91ac15876e88edeb98745eb6d0459cf18e7e6ee3c026aba",
|
||||
"scripts/cast_chart.py": "bc08b53ab50f7d9e51b4d7e84deb3c2660040c025dd03c578f7fd51aeba6f315",
|
||||
"scripts/cast_chart.py": "16e5e9f0df1bcb0c0eef1febd1ae2395a84c1676e0c629058821056861daa05e",
|
||||
"scripts/chart/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"scripts/chart/luck.py": "d930f3f402cf814eaaa10fdcaa0a8be4a47411e6961dd0324ac38a8f898b4299",
|
||||
"scripts/chart/model.py": "8279145da10901cfb1a7b4823762074169dfd554bbe8ce39ae15862334b001f6",
|
||||
@@ -582,22 +582,25 @@
|
||||
"scripts/memory_save.py": "42638841cceb586783b8764eacff91a8518d8df7dfe99b3919b3caa5a432e0ec",
|
||||
"scripts/query_shensha.py": "1d77205c8781676f3536bbeb2f36d397b825ff3065a4b968344fb95e5b8859dd",
|
||||
"scripts/render/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"scripts/render/html.py": "9896ffc7c1f29cdce0d2ce9c21ce78ec6035b77851249466776945de918de49d",
|
||||
"scripts/render/markdown.py": "4b2bcc3fc6581728d74bbaa357bf1db2e8aec1d3ec54c2c27ca645b6c5179977",
|
||||
"scripts/self_update.py": "95dc0302ca2b78d71b2893ebaced7e233dbc00944a5e3c5b06081b5c9a749f8a",
|
||||
"scripts/render/templates/paipan.html": "9cdeed44834ef0de7adf0d775486b8ece7c81cf228107295e51bc31847a81688",
|
||||
"scripts/self_update.py": "b1c9ad0ef90e20ac75ac6b8e857a4a5bd31181c67a58c7c1d46b45f468fd9f26",
|
||||
"scripts/share_export.py": "21396143ad5cc2d244c7cc060a3ece25702594465689a9f12438dfe92de3296f",
|
||||
"scripts/share_push.py": "8a367a3a4aa1bad27fbf3e9b241ebf69bf687a134045a06842e06047b58458ae",
|
||||
"scripts/share_push.py": "42683c27a93f12208c20b0a5cd2feb7cd81f9be3efb76b46d4789ad4d2aab4ca",
|
||||
"share/README.md": "42ebed92285752af93c7c7af5eb4b6e84ed836408d0cedb57e1b32c796746b26",
|
||||
"share/config.json": "22a5615dd7db5aa688e1ed8e464874c70d90d21e6aa126e214b9c7896b99c7cd",
|
||||
"share/config.json": "b0747cb29b01e65c79e612062e1d572f6658c3046ae8bb5d97020ab2d7c6f04e",
|
||||
"tests/golden/history_tz.yaml": "3fb74e90c4f53abebaac0a5de8825ac3794fc0b5b41dc84121201e70cecade47",
|
||||
"tests/golden/longitudes.yaml": "6fda1e6b632d29c135362c9273b931c4416c9f24c31b07e2a7baadc6e2077bc2",
|
||||
"tests/golden/time_boundary.yaml": "637090a1c081b9b824fc0073d29dad073d29138c3b48082bdf41ce77f5af3b37",
|
||||
"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_license_gate.py": "ee44226b75620b3965ba52cc2a0cc5404fbff37822fd96a6faf2aed89d07d47e",
|
||||
"tests/test_luck.py": "821f98bc19ab1e920353de58c6ffacac5d757c887c690a2d596f51c1485b21ea",
|
||||
"tests/test_memory.py": "d3decfdc8d7c5bbfc4af5e3aed93bb3c53ab09071b682a0f4808614ac2cd3c6f",
|
||||
"tests/test_self_update.py": "455461ba121814c8cdbe98bb2d31cd2dac630ef111eafb23118583c42f65a71c",
|
||||
"tests/test_self_update.py": "183c2fb82d2d20285941b6c75a1890c4417d400c3c7e7d5f8a9680b4fa7bf76c",
|
||||
"tests/test_share.py": "399f20da22689d00e71eb78c76d60507060c29a7d68865e6b0e9f591d19be871",
|
||||
"tests/test_shensha.py": "b8a20d264e18c79f1fe546281381b0d929543af44ec68dc890ef8b64efc625b0",
|
||||
"tests/test_solar_time.py": "f072e5ea82fd0cb331c8a4fdc6dce08148cd9ac81ef9fe4506196fa68a42d1e4",
|
||||
@@ -1265,5 +1268,5 @@
|
||||
"vendor/tzdata/zoneinfo/zonenow.tab": "7f9d1c11445af7a17862c20a9081226d31a09a1730e270ff59a34c8d2b4c7f7f",
|
||||
"vendor/tzdata/zones": "28d8f3eb25839095fa8a209997bb20e822ebfbebe21e60edc483a3d5e20dc881"
|
||||
},
|
||||
"version": "3.5.0"
|
||||
"version": "3.5.1"
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ def main() -> int:
|
||||
help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需')
|
||||
ap.add_argument("--place", required=True,
|
||||
help="出生地,精确到地级市一级,如「四川省成都市」")
|
||||
ap.add_argument("--html", nargs="?", const="", default=None,
|
||||
help="可选:同时生成 HTML 盘面图(给路径参数,或省略则写 ./命盘_<姓名>.html)")
|
||||
a = ap.parse_args()
|
||||
|
||||
# 软授权锁:定制副本(含 data/.license.json)按授权次数/期限放行;母版无状态文件直接放行
|
||||
@@ -130,6 +132,17 @@ def main() -> int:
|
||||
print(render(chart))
|
||||
_gate.consume(Path(__file__).resolve().parents[1])
|
||||
|
||||
# 可选:同步生成 HTML 盘面图(渲染失败不影响 stdout 契约)
|
||||
if a.html is not None:
|
||||
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)
|
||||
except Exception:
|
||||
print("WARN: HTML 盘面生成失败(命盘不受影响)", file=sys.stderr)
|
||||
|
||||
# 自更新提示:只读本地缓存往 stderr 打一行;缓存过期则后台线程刷新,
|
||||
# 主流程不等它(用当次缓存)。任何异常静默,绝不影响 stdout 命盘契约。
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
# scripts/render/html.py
|
||||
"""ChartData → 单文件 HTML 盘面(模板 render/templates/paipan.html)。
|
||||
|
||||
与 Markdown 输出同为纯计算渲染:十神/藏干/空亡/纳音/十二长生/月令旺衰
|
||||
均为固定查表,不含任何命理判断(P1)。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from chart.luck import luck_label_for_year
|
||||
|
||||
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 = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳",
|
||||
"壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"}
|
||||
|
||||
_SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "水", "金", "土"),
|
||||
"巳": ("火", "土", "木", "水", "金"), "午": ("火", "土", "木", "水", "金"),
|
||||
"申": ("金", "水", "土", "火", "木"), "酉": ("金", "水", "土", "火", "木"),
|
||||
"亥": ("水", "木", "金", "土", "火"), "子": ("水", "木", "金", "土", "火"),
|
||||
"辰": ("土", "金", "火", "木", "水"), "戌": ("土", "金", "火", "木", "水"),
|
||||
"丑": ("土", "金", "火", "木", "水"), "未": ("土", "金", "火", "木", "水")}
|
||||
_SEASON_LABEL = ("旺", "相", "休", "囚", "死")
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def _z(zhi: str, voids: str) -> str:
|
||||
cls = "voidz" if zhi in voids else ""
|
||||
return f'<span class="{_wx(zhi)} {cls}">{zhi}</span>'
|
||||
|
||||
|
||||
def _pillar_col(chart, key: str, label: str, cur: bool = False) -> dict:
|
||||
"""四柱之一 → 各行的 HTML 单元格内容。"""
|
||||
p = getattr(chart, key)
|
||||
voids = chart.void.get("day", "") + chart.void.get("hour", "")
|
||||
return {
|
||||
"label": label, "cur": cur,
|
||||
"god": p.gan_ten_god or ("日主" if key == "day" else ""),
|
||||
"gan": p.gan, "zhi": p.zhi,
|
||||
"hidden": list(zip(p.hidden_stems, p.hidden_ten_gods)),
|
||||
"terrain": chart.terrain.get(key, ""),
|
||||
"void": _gz_void(p.gan + p.zhi),
|
||||
"nayin": p.na_yin or "",
|
||||
"shensha": 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 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=""):
|
||||
tds = "".join(
|
||||
f'<td class="{"cur" if c["cur"] else ""}">{fn(c)}</td>' for c in cols)
|
||||
return f'<tr><td class="rowlabel">{label}</td>{tds}</tr>'
|
||||
|
||||
rows = [
|
||||
"<tr>" + '<td class="rowlabel"></td>'
|
||||
+ "".join(th(c) for c in cols) + "</tr>",
|
||||
row("主星", lambda c: f'<span class="god{" day" if c["god"] == "日主" else ""}">{c["god"]}</span>'),
|
||||
row("天干", lambda c: f'<span class="gz {_wx(c["gan"])}">{c["gan"]}</span>'),
|
||||
row("地支", lambda c: f'<span class="gz">{_z(c["zhi"], c["voids"])}</span>'),
|
||||
row("藏干", lambda c: '<div class="hidden">'
|
||||
+ "<br>".join(f'<span class="hg {_wx(h)}">{h}</span> <span class="god">{g}</span>'
|
||||
for h, g in c["hidden"]) + "</div>"),
|
||||
row("星运", lambda c: f'<span class="small">{c["terrain"]}</span>'),
|
||||
row("空亡", lambda c: f'<span class="small">{c["void"]}</span>'),
|
||||
row("纳音", lambda c: f'<span class="small">{c["nayin"]}</span>'),
|
||||
row("神煞", lambda c: '<div class="small">'
|
||||
+ ("<br>".join(c["shensha"]) if c["shensha"] else "—") + "</div>"),
|
||||
]
|
||||
return '<table class="pan">' + "".join(rows) + "</table>"
|
||||
|
||||
|
||||
def _luck_strip(chart) -> str:
|
||||
this_year = datetime.now().year
|
||||
cells = []
|
||||
for lp in chart.luck_pillars:
|
||||
gz = lp.gan + lp.zhi
|
||||
cur = lp.start_year <= this_year < lp.start_year + 10
|
||||
cells.append(
|
||||
f'<div class="cell{" cur" if cur else ""}">'
|
||||
f'<div class="cy">{lp.start_year} · {lp.start_age:.0f} 岁起</div>'
|
||||
f'<div class="cg"><span class="{_wx(lp.gan)}">{lp.gan}</span>'
|
||||
f'<span class="{_wx(lp.zhi)}">{lp.zhi}</span></div>'
|
||||
f'<div class="cy">{_ten_god(chart.day_master, lp.gan)}'
|
||||
f' · {_terrain(chart.day_master, lp.zhi)}</div></div>')
|
||||
return '<div class="strip">' + "".join(cells) + "</div>"
|
||||
|
||||
|
||||
def _childhood_block(chart) -> str:
|
||||
if not chart.childhood_luck:
|
||||
return ""
|
||||
cells = "".join(
|
||||
f'<div class="cell"><div class="cy">{age} 岁 · {year}</div>'
|
||||
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
|
||||
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>'
|
||||
for age, year, gz in chart.childhood_luck)
|
||||
return ('<div class="sect" style="margin-top:14px">小运'
|
||||
'<span class="note">童限(未交大运)各虚岁</span></div>'
|
||||
f'<div class="strip">{cells}</div>')
|
||||
|
||||
|
||||
def _flow_years(chart) -> str:
|
||||
this_year = datetime.now().year
|
||||
cells = []
|
||||
for year, gz, label in chart.flow_years:
|
||||
cls = "cell"
|
||||
if year == this_year:
|
||||
cls += " cur"
|
||||
if label.startswith("交运年"):
|
||||
cls += " jiao"
|
||||
cells.append(f'<div class="{cls}"><div class="cy">{year}</div>'
|
||||
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
|
||||
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>')
|
||||
return '<div class="strip">' + "".join(cells) + "</div>"
|
||||
|
||||
|
||||
def _flow_months(chart) -> str:
|
||||
cells = []
|
||||
for label, gz in chart.flow_months:
|
||||
cells.append(f'<div class="cell"><div class="cy">{label}</div>'
|
||||
f'<div class="cg"><span class="{_wx(gz[0])}">{gz[0]}</span>'
|
||||
f'<span class="{_wx(gz[1])}">{gz[1]}</span></div></div>')
|
||||
return '<div class="strip">' + "".join(cells) + "</div>"
|
||||
|
||||
|
||||
def _season_bar(chart) -> str:
|
||||
seq = _SEASON.get(chart.month_ling)
|
||||
if not seq:
|
||||
return ""
|
||||
parts = " ".join(f"{wx}{lab}" for wx, lab in zip(seq, _SEASON_LABEL))
|
||||
return f"月令 {chart.month_ling}:{parts}"
|
||||
|
||||
|
||||
def build_html(chart) -> str:
|
||||
"""ChartData → 完整 HTML 文档。"""
|
||||
tpl = (Path(__file__).resolve().parent
|
||||
/ "templates" / "paipan.html").read_text(encoding="utf-8")
|
||||
|
||||
warns = []
|
||||
if chart.is_late_zi:
|
||||
warns.append("⚠️ 晚子时:本造按子初换日归入次日;子正换日一派日柱、时柱不同。")
|
||||
if chart.jie_gap_warning:
|
||||
warns.append(chart.jie_gap_warning)
|
||||
warnings = "".join(f'<div class="warn">{w}</div>' for w in warns)
|
||||
|
||||
qiyun = ""
|
||||
if chart.luck_pillars:
|
||||
qiyun = (f"起运:{chart.start_age_text}"
|
||||
f"({'顺行' if chart.luck_is_forward else '逆行'})")
|
||||
|
||||
audit = (f"时区 {chart.tz_used}(夏令时:{'是' if chart.is_dst else '否'})· "
|
||||
f"经度修正 {chart.lon_correction_min:+.2f} 分 · 均时差 {chart.eot_minutes:+.2f} 分 · "
|
||||
f"日界 子初换日(23:00)· tyme4py {chart.library_version} · "
|
||||
f"schema {chart.schema_version}")
|
||||
|
||||
mapping = {
|
||||
"TITLE": f"命盘:{chart.name}",
|
||||
"NAME": chart.name,
|
||||
"GENDER_LABEL": "乾造" if chart.gender == "男" else "坤造",
|
||||
"DAY_MASTER": f"{chart.day_master}{_WX_GAN[chart.day_master]}",
|
||||
"BIRTHPLACE": chart.birthplace,
|
||||
"BIRTH_CLOCK": chart.birth_clock,
|
||||
"TRUE_SOLAR": chart.true_solar_time,
|
||||
"MONTH_LING": chart.month_ling,
|
||||
"TAI_YUAN": chart.tai_yuan,
|
||||
"MING_GONG": chart.ming_gong,
|
||||
"QIYUN": qiyun,
|
||||
"WARNINGS": warnings,
|
||||
"MAIN_TABLE": _main_table(chart),
|
||||
"LUCK_NOTE": f"{chart.start_age_text}起运,当前一步高亮",
|
||||
"LUCK_STRIP": _luck_strip(chart),
|
||||
"CHILDHOOD_BLOCK": _childhood_block(chart),
|
||||
"FLOW_YEARS": _flow_years(chart),
|
||||
"FLOW_MONTHS": _flow_months(chart),
|
||||
"SEASON_BAR": _season_bar(chart),
|
||||
"AUDIT": audit,
|
||||
}
|
||||
out = tpl
|
||||
for k, v in mapping.items():
|
||||
out = out.replace("{{" + k + "}}", str(v))
|
||||
return out
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #2b2b2b; --muted: #8a8577; --line: #e6dfd2; --bg: #f7f4ee;
|
||||
--card: #fffdf8; --accent: #b08d2f;
|
||||
--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);
|
||||
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.page { max-width: 980px; margin: 0 auto; }
|
||||
.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);
|
||||
}
|
||||
/* 头部 */
|
||||
.head { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.name { font-size: 28px; font-weight: 700; font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; }
|
||||
.chip {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 999px;
|
||||
border: 1px solid var(--accent); color: var(--accent); font-size: 13px;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 13px; line-height: 1.9; }
|
||||
.meta b { color: var(--ink); font-weight: 600; }
|
||||
.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;
|
||||
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; }
|
||||
.gz { font-family: "Kaiti SC", "STKaiti", "KaiTi", serif; font-size: 34px; line-height: 1.25; }
|
||||
.god { font-size: 12.5px; color: #7a7264; }
|
||||
.god.day { color: var(--accent); font-weight: 600; }
|
||||
.hidden { font-size: 12.5px; line-height: 1.7; color: #57503f; }
|
||||
.hidden .hg { font-weight: 600; }
|
||||
.small { font-size: 12.5px; color: #57503f; 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); }
|
||||
.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;
|
||||
}
|
||||
.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.jiao .cy { color: #c0392b; }
|
||||
/* 页脚 */
|
||||
.foot { color: var(--muted); font-size: 12px; line-height: 1.9; }
|
||||
.brand { margin-top: 6px; color: #a89e86; }
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
+23
-5
@@ -12,12 +12,17 @@
|
||||
代理环境变量由 urllib 默认读取、自动生效。测试或自建场景可用环境变量
|
||||
ZIPEC_VERSION_URLS / ZIPEC_ZIPBALL_URLS(逗号分隔)整体覆盖远端地址,
|
||||
支持 file:// 形式。
|
||||
|
||||
抓取通道:urllib 优先;macOS 框架版 Python 常见的根证书缺失
|
||||
(SSL CERTIFICATE_VERIFY_FAILED)时自动回退到系统 curl(curl 自带
|
||||
系统信任库且自带代理环境变量支持)。
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -62,15 +67,28 @@ def _zipball_urls() -> list:
|
||||
return [GITHUB_ZIPBALL_URL, GITEA_ZIPBALL_URL]
|
||||
|
||||
|
||||
def _fetch_bytes(url: str, timeout: int) -> bytes:
|
||||
"""urllib 优先;SSL 证书缺失等失败时回退系统 curl(macOS 框架版 Python 常见)。"""
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.read()
|
||||
except Exception:
|
||||
curl = shutil.which("curl")
|
||||
if not curl:
|
||||
raise
|
||||
r = subprocess.run([curl, "-fsSL", "-m", str(timeout), url],
|
||||
capture_output=True)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"curl 抓取失败({r.returncode}):{url}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def _fetch_text(url: str, timeout: int = NETWORK_TIMEOUT) -> str:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.read().decode("utf-8")
|
||||
return _fetch_bytes(url, timeout).decode("utf-8")
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, timeout: int = NETWORK_TIMEOUT) -> None:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
with open(dest, "wb") as fh:
|
||||
shutil.copyfileobj(r, fh)
|
||||
dest.write_bytes(_fetch_bytes(url, timeout))
|
||||
|
||||
|
||||
def parse_version(text: str) -> tuple:
|
||||
|
||||
@@ -105,13 +105,12 @@ def main() -> int:
|
||||
n_ok = 0
|
||||
for pkg in packages:
|
||||
chart_hash = pkg.stem
|
||||
ok_remote = None
|
||||
ok_remotes = []
|
||||
for name, remote in remotes:
|
||||
with tempfile.TemporaryDirectory(prefix="ziping-share-") as td:
|
||||
if _push_to_remote(remote, pkg, td):
|
||||
ok_remote = name
|
||||
break
|
||||
if ok_remote is None:
|
||||
ok_remotes.append(name)
|
||||
if not ok_remotes:
|
||||
print(f"FAIL {pkg.name}:所有远端推送失败(无网络或远端不可达),"
|
||||
f"pending 保留。", file=sys.stderr)
|
||||
continue
|
||||
@@ -119,7 +118,7 @@ def main() -> int:
|
||||
shutil.move(str(pkg), str(pushed / pkg.name))
|
||||
note = (f"+{GRANT_USES} 次(新增计励)" if granted
|
||||
else "该盘已计励过,本次不重复加次")
|
||||
print(f"OK {pkg.name} → {ok_remote};{note}")
|
||||
print(f"OK {pkg.name} → {'、'.join(ok_remotes)};{note}")
|
||||
n_ok += 1
|
||||
|
||||
return 0 if n_ok else 3
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"github_remote": "https://github.com/mxiaonian/ZiPingEightChar-archives.git",
|
||||
"gitea_remote": "https://git.lingyaomiaojie.com/moxiaonian/ZiPingEightChar-archives.git",
|
||||
"_note": "脱敏包接收远端。推送时优先用本机 git 凭证;公开用户的免登录推送令牌待授权方签发受限账号后另行下发(勿在此写入管理员令牌)。亦可用环境变量 ZIPING_SHARE_GITHUB_REMOTE / ZIPING_SHARE_GITEA_REMOTE 覆盖(可在 URL 中内嵌凭证)。"
|
||||
"gitea_remote": "https://archives-uploader:df6be7f4a974bf65d796cd24e850e014a7faa8fc@git.lingyaomiaojie.com/ziping-eightchar/ZiPingEightChar-archives.git",
|
||||
"_note": "脱敏包接收远端。gitea_remote 内嵌的是 archives-uploader 受限账号令牌(仅数据仓可写,泄漏也只影响数据仓);GitHub 线路依赖本机 git 凭证。环境变量 ZIPING_SHARE_GITHUB_REMOTE / ZIPING_SHARE_GITEA_REMOTE 可覆盖。"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""HTML 盘面渲染测试(render/html.py + cast_chart --html)。"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
|
||||
|
||||
|
||||
def _chart():
|
||||
"""用真实排盘管线造一份 ChartData(成都 1990 男盘)。"""
|
||||
from datetime import datetime
|
||||
from astro.solar_time import to_true_solar_time
|
||||
from astro.timezone_resolve import to_utc
|
||||
from astro.day_boundary import resolve_calendar_day
|
||||
from chart.tyme_adapter import cast
|
||||
from chart.luck import (childhood_luck, compute_luck, decade_pillars,
|
||||
flow_months, flow_years, is_forward,
|
||||
luck_label_for_year)
|
||||
from chart.model import build_chart_data
|
||||
from geo.cities import resolve_location
|
||||
|
||||
loc = resolve_location("四川省成都市")
|
||||
clock = datetime(1990, 5, 14, 8, 30)
|
||||
utc, tz_audit = to_utc(clock, loc)
|
||||
tst, st_audit = to_true_solar_time(utc, loc.lon)
|
||||
day, bd_audit = resolve_calendar_day(tst)
|
||||
cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second)
|
||||
raw = cast(cast_dt, "男")
|
||||
forward = is_forward("男", raw["year"]["gan"])
|
||||
luck = compute_luck(birth_tst=tst, prev_jie_time=raw["prev_jie"]["time"],
|
||||
next_jie_time=raw["next_jie"]["time"], forward=forward)
|
||||
this_year = datetime.now().year
|
||||
decade = decade_pillars(raw["month"]["gan"], raw["month"]["zhi"], forward,
|
||||
luck["start_age_years"], cast_dt.year)
|
||||
years = [(y, gz, luck_label_for_year(y, decade))
|
||||
for y, gz in flow_years(cast_dt.year, this_year, 10)]
|
||||
return build_chart_data(
|
||||
name="张三", gender="男", place="四川省成都市",
|
||||
clock="1990-05-14 08:30", loc=loc, tz_audit=tz_audit,
|
||||
st_audit=st_audit, bd_audit=bd_audit, raw=raw, luck=luck,
|
||||
luck_pillars=decade, flow_years=years,
|
||||
flow_months=flow_months(this_year),
|
||||
childhood_luck=childhood_luck(raw["hour"]["gan"], raw["hour"]["zhi"],
|
||||
forward, luck["start_age_years"],
|
||||
cast_dt.year),
|
||||
)
|
||||
|
||||
|
||||
class HtmlRenderTest(unittest.TestCase):
|
||||
def test_render_complete(self):
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
self.assertFalse(re.findall(r"\{\{[A-Z_]+\}\}", html), "存在未填充占位符")
|
||||
for ch in ("庚", "午", "辛", "巳", "己", "卯", "丁"):
|
||||
self.assertIn(ch, html)
|
||||
for sect in ("排盘", "大运", "流年", "流月", "空亡", "神煞", "纳音"):
|
||||
self.assertIn(sect, html)
|
||||
|
||||
def test_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("庚", "壬"), "食神")
|
||||
|
||||
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("庚", "子"), "死")
|
||||
|
||||
|
||||
class CastHtmlE2ETest(unittest.TestCase):
|
||||
def test_html_flag_writes_file(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = Path(td) / "p.html"
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(SKILL_ROOT / "scripts" / "cast_chart.py"),
|
||||
"--name", "张三", "--gender", "男", "--birth", "1990-05-14 08:30",
|
||||
"--place", "四川省成都市", "--html", str(out)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
cwd=str(SKILL_ROOT / "scripts"))
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertTrue(r.stdout.startswith("# 命盘:张三"))
|
||||
self.assertIn("HTML 盘面:", r.stderr)
|
||||
html = out.read_text(encoding="utf-8")
|
||||
self.assertIn("<table", html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -245,3 +245,36 @@ class TestCastChartIntegration:
|
||||
cache_path.unlink(missing_ok=True)
|
||||
else:
|
||||
cache_path.write_bytes(old_bytes)
|
||||
|
||||
|
||||
class TestFetchFallback:
|
||||
def test_curl_fallback_when_urllib_ssl_fails(self, tmp_path, monkeypatch):
|
||||
"""urllib 证书校验失败时回退系统 curl(macOS 框架版 Python 场景)。"""
|
||||
import urllib.request
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise urllib.request.URLError("CERTIFICATE_VERIFY_FAILED")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _boom)
|
||||
|
||||
fake_curl = tmp_path / "curl"
|
||||
fake_curl.write_text("#!/bin/sh\ncat \"$3\" | sed 's|^file://||' | xargs cat\n",
|
||||
encoding="utf-8")
|
||||
src = tmp_path / "VERSION"
|
||||
src.write_text("9.9.9\n", encoding="utf-8")
|
||||
fake_curl.write_text(f'#!/bin/sh\ncat "{src}"\n', encoding="utf-8")
|
||||
fake_curl.chmod(0o755)
|
||||
monkeypatch.setattr(su.shutil, "which", lambda name: str(fake_curl))
|
||||
|
||||
assert su._fetch_text("https://example.invalid/VERSION") == "9.9.9\n"
|
||||
|
||||
def test_no_curl_no_urllib_raises(self, monkeypatch):
|
||||
import urllib.request
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise urllib.request.URLError("SSL")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _boom)
|
||||
monkeypatch.setattr(su.shutil, "which", lambda name: None)
|
||||
with pytest.raises(Exception):
|
||||
su._fetch_text("https://example.invalid/VERSION")
|
||||
|
||||
Reference in New Issue
Block a user