22from bs4 import BeautifulSoup
33from concurrent .futures import ThreadPoolExecutor
44from flask import g , has_request_context
5+ import base64
6+ import time
57
68logger = logging .getLogger ('SchoolGradesServer.Fetcher' )
79
@@ -42,6 +44,7 @@ def _log(level: str, user_id: str, msg: str, req_id: str = None):
4244class GradeFetcher :
4345 BASE = "https://shcloud2.k12ea.gov.tw/CLHSTYC"
4446 LOGIN_PAGE = f"{ BASE } /Auth/Auth/CloudLogin"
47+ CAPTCHA_ENDPOINT = f"{ BASE } /Auth/Auth/GetCaptcha"
4548 DO_CHECK = f"{ BASE } /Auth/Auth/DoCloudLoginCheck"
4649 GRADES_PAGE = f"{ BASE } /ICampus/StudentInfo/Index?page=%E6%88%90%E7%B8%BE%E6%9F%A5%E8%A9%A2"
4750 API_BASE = f"{ BASE } /ICampus"
@@ -62,16 +65,83 @@ def _get_hidden_token(self, html: str) -> str:
6265 raise RuntimeError ("找不到 __RequestVerificationToken hidden input" )
6366 return el ["value" ]
6467
65- def login_and_get_tokens (self , username , password ):
68+ def _extract_hidden_input (self , html : str , field_name : str , default : str = "" ) -> str :
69+ soup = BeautifulSoup (html , "html.parser" )
70+ el = soup .select_one (f'input[name="{ field_name } "]' )
71+ if not el :
72+ return default
73+ return (el .get ("value" ) or default ).strip ()
74+
75+ def _build_captcha_url (self ) -> str :
76+ # 學校系統使用 t query 參數避免快取
77+ ts = int (time .time () * 1000 )
78+ return f"{ self .CAPTCHA_ENDPOINT } ?t={ ts } "
79+
80+ def prepare_login_captcha (self ):
81+ """Prepare school login captcha and context for subsequent login POST."""
82+ try :
83+ s = self .session_factory ()
84+ r = s .get (self .LOGIN_PAGE )
85+ r .raise_for_status ()
86+
87+ html = r .text
88+ login_token = self ._get_hidden_token (html )
89+ shcaptcha_gen_code = self ._extract_hidden_input (html , "ShCaptchaGenCode" , "10" )
90+ captcha_url = self ._build_captcha_url ()
91+
92+ image_resp = s .get (
93+ captcha_url ,
94+ headers = {
95+ "Referer" : self .LOGIN_PAGE ,
96+ "X-Requested-With" : "XMLHttpRequest" ,
97+ }
98+ )
99+ image_resp .raise_for_status ()
100+ content_type = image_resp .headers .get ("Content-Type" , "image/png" )
101+
102+ if "image" not in content_type .lower ():
103+ return False , "學校驗證碼回應格式異常" , None
104+
105+ image_b64 = base64 .b64encode (image_resp .content ).decode ("ascii" )
106+
107+ context = {
108+ "login_token" : login_token ,
109+ "shcaptcha_gen_code" : shcaptcha_gen_code ,
110+ "cookies" : {c .name : c .value for c in s .cookies if c .name and c .value is not None },
111+ }
112+
113+ payload = {
114+ "image_data_url" : f"data:{ content_type } ;base64,{ image_b64 } " ,
115+ "context" : context ,
116+ }
117+ return True , "OK" , payload
118+ except Exception as e :
119+ _log ('error' , '' , f"Prepare captcha exception: { e } " )
120+ return False , f"取得學校驗證碼失敗: { str (e )} " , None
121+
122+ def login_and_get_tokens (self , username , password , captcha_code = None , login_context = None ):
66123 """Login via requests session, return (success, message, cookies_dict, student_no, token)."""
67124 try :
68125 _log ('info' , username , "Attempting login (requests mode)" )
69126 s = self .session_factory ()
70127
71- # 1) GET login page to obtain cookies + hidden token
72- r = s .get (self .LOGIN_PAGE )
73- r .raise_for_status ()
74- login_token = self ._get_hidden_token (r .text )
128+ login_token = None
129+ shcaptcha_gen_code = "10"
130+
131+ # 優先使用前端先取得的驗證碼上下文,確保驗證碼與登入請求同步
132+ if login_context :
133+ login_token = login_context .get ("login_token" )
134+ shcaptcha_gen_code = login_context .get ("shcaptcha_gen_code" ) or "10"
135+ for k , v in (login_context .get ("cookies" ) or {}).items ():
136+ if k and v is not None :
137+ s .cookies .set (k , v )
138+
139+ # fallback:若未提供 context,沿用舊流程直接抓 login page
140+ if not login_token :
141+ r = s .get (self .LOGIN_PAGE )
142+ r .raise_for_status ()
143+ login_token = self ._get_hidden_token (r .text )
144+ shcaptcha_gen_code = self ._extract_hidden_input (r .text , "ShCaptchaGenCode" , "10" )
75145
76146 # 2) POST login
77147 headers = {
@@ -90,7 +160,8 @@ def login_and_get_tokens(self, username, password):
90160 "SchoolName" : "國立中大壢中" ,
91161 "GoogleToken" : "8" ,
92162 "isRegistration" : "false" ,
93- "ShCaptchaGenCode" : "10" ,
163+ "ShCaptchaGenCode" : shcaptcha_gen_code ,
164+ "ShCaptcha" : (captcha_code or "" ).strip (),
94165 "__RequestVerificationToken" : login_token ,
95166 }
96167
0 commit comments