22
33from __future__ import annotations
44
5+ import hashlib
6+ import json
7+ import os
58import re
9+ import shutil
10+ import tempfile
611from dataclasses import dataclass
712from pathlib import Path
813from typing import Any
1520 "https://raw.githubusercontent.com/AdvancingTitans/stock-analysis/main/"
1621 "skills/stock-analysis/SKILL.md"
1722)
23+ STOCK_ANALYSIS_RAW_BASE = STOCK_ANALYSIS_SKILL_URL .rsplit ("/" , 1 )[0 ]
24+ REFERENCE_PATHS = (
25+ "references/output_discipline.md" ,
26+ "references/data-source-strategy.md" ,
27+ "references/analysis-template.md" ,
28+ "references/methodology/m1-index-overview.md" ,
29+ "references/methodology/m2-sector-flow.md" ,
30+ "references/methodology/m3-upside.md" ,
31+ "references/methodology/m4-downside.md" ,
32+ "references/methodology/m5-style-buckets.md" ,
33+ "references/methodology/m6-resilient.md" ,
34+ "references/template/analysis-template.md" ,
35+ "references/template/module-template.md" ,
36+ "references/template/portfolio-template.md" ,
37+ )
1838BUILTIN_VERSION = "4.2.0"
1939BUILTIN_GUIDANCE = """stock-analysis 4.2.0:
2040- 固定顺序:大盘指数概览、持仓分析、六模块深度复盘、综合持仓建议与风险提示。
@@ -37,25 +57,99 @@ def _version(text: str) -> str:
3757 return match .group (1 ) if match else BUILTIN_VERSION
3858
3959
60+ def _version_tuple (value : str ) -> tuple [int , ...]:
61+ parts = re .findall (r"\d+" , value )
62+ return tuple (int (part ) for part in parts [:4 ]) or (0 ,)
63+
64+
4065def _cache_path () -> Path :
4166 return young_home () / "methodologies" / "stock-analysis" / "SKILL.md"
4267
4368
69+ def _sha256 (text : str ) -> str :
70+ return hashlib .sha256 (text .encode ("utf-8" )).hexdigest ()
71+
72+
73+ def _cached_spec (path : Path ) -> tuple [str , str ]:
74+ if not path .exists ():
75+ return BUILTIN_VERSION , BUILTIN_GUIDANCE
76+ skill_text = path .read_text (encoding = "utf-8" )
77+ manifest_path = path .parent / "manifest.json"
78+ if not manifest_path .exists ():
79+ return _version (skill_text ), skill_text
80+ try :
81+ manifest = json .loads (manifest_path .read_text (encoding = "utf-8" ))
82+ checksums = manifest .get ("sha256" ) or {}
83+ texts = {}
84+ for relative , expected in checksums .items ():
85+ candidate = path .parent / relative
86+ content = candidate .read_text (encoding = "utf-8" )
87+ if _sha256 (content ) != expected :
88+ raise ValueError ("checksum mismatch" )
89+ texts [relative ] = content
90+ except (OSError , ValueError , json .JSONDecodeError ):
91+ return BUILTIN_VERSION , BUILTIN_GUIDANCE
92+ combined = [texts .get ("SKILL.md" , skill_text )]
93+ combined .extend (texts [relative ] for relative in REFERENCE_PATHS if relative in texts )
94+ return str (manifest .get ("version" ) or _version (skill_text )), "\n \n " .join (combined )
95+
96+
97+ def _download_text (client : Any , url : str , timeout : float ) -> str :
98+ response = client .get (url , timeout = timeout )
99+ if response .status_code >= 400 or not str (response .text ).strip ():
100+ raise RuntimeError (f"HTTP { response .status_code } " )
101+ return str (response .text ).rstrip () + "\n "
102+
103+
104+ def _install_spec (path : Path , version : str , files : dict [str , str ]) -> None :
105+ parent = path .parent .parent
106+ parent .mkdir (parents = True , exist_ok = True )
107+ with tempfile .TemporaryDirectory (prefix = "stock-analysis-" , dir = parent ) as temp_name :
108+ temp_root = Path (temp_name )
109+ for relative , content in files .items ():
110+ target = temp_root / relative
111+ target .parent .mkdir (parents = True , exist_ok = True )
112+ target .write_text (content , encoding = "utf-8" )
113+ manifest = {
114+ "version" : version ,
115+ "sha256" : {relative : _sha256 (content ) for relative , content in files .items ()},
116+ }
117+ (temp_root / "manifest.json" ).write_text (
118+ json .dumps (manifest , ensure_ascii = False , indent = 2 ) + "\n " ,
119+ encoding = "utf-8" ,
120+ )
121+ destination = path .parent
122+ backup = destination .with_name (destination .name + ".previous" )
123+ if backup .exists ():
124+ shutil .rmtree (backup )
125+ if destination .exists ():
126+ os .replace (destination , backup )
127+ os .replace (temp_root , destination )
128+ if backup .exists ():
129+ shutil .rmtree (backup )
130+
131+
44132def sync_stock_analysis_methodology (* , session : Any = None , timeout : float = 5 ) -> MethodologySpec :
45133 path = _cache_path ()
46- current_text = path .read_text (encoding = "utf-8" ) if path .exists () else BUILTIN_GUIDANCE
47- current_version = _version (current_text )
134+ current_version , current_text = _cached_spec (path )
48135 client = session or requests .Session ()
49136 try :
50- response = client .get (STOCK_ANALYSIS_SKILL_URL , timeout = timeout )
51- if response .status_code >= 400 or not str (response .text ).strip ():
52- raise RuntimeError (f"HTTP { response .status_code } " )
53- remote_text = str (response .text )
137+ remote_skill = _download_text (client , STOCK_ANALYSIS_SKILL_URL , timeout )
138+ except Exception :
139+ return MethodologySpec (current_version , current_text , path , updated = False )
140+ remote_version = _version (remote_skill )
141+ if _version_tuple (remote_version ) <= _version_tuple (current_version ):
142+ return MethodologySpec (current_version , current_text , path , updated = False )
143+ files = {"SKILL.md" : remote_skill }
144+ try :
145+ for relative in REFERENCE_PATHS :
146+ files [relative ] = _download_text (
147+ client ,
148+ f"{ STOCK_ANALYSIS_RAW_BASE } /{ relative } " ,
149+ timeout ,
150+ )
151+ _install_spec (path , remote_version , files )
54152 except Exception :
55153 return MethodologySpec (current_version , current_text , path , updated = False )
56- remote_version = _version (remote_text )
57- updated = remote_text != current_text
58- if updated :
59- path .parent .mkdir (parents = True , exist_ok = True )
60- path .write_text (remote_text .rstrip () + "\n " , encoding = "utf-8" )
61- return MethodologySpec (remote_version , remote_text , path , updated = updated )
154+ combined = [files ["SKILL.md" ], * (files [relative ] for relative in REFERENCE_PATHS )]
155+ return MethodologySpec (remote_version , "\n \n " .join (combined ), path , updated = True )
0 commit comments