11import requests
22import urllib3
3- from playwright .sync_api import sync_playwright
3+ from bs4 import BeautifulSoup
4+ from concurrent .futures import ThreadPoolExecutor
45
56# Disable insecure request warnings
67urllib3 .disable_warnings (urllib3 .exceptions .InsecureRequestWarning )
78
89class GradeFetcher :
9- def __init__ (self , state_file = "state.json" , headless = True ):
10- self .state_file = state_file
11- self .headless = headless
12- self .playwright = None
13- self .browser = None
14- self .context = None
15- self .page = None
10+ BASE = "https://shcloud2.k12ea.gov.tw/CLHSTYC"
11+ LOGIN_PAGE = f"{ BASE } /Auth/Auth/CloudLogin"
12+ DO_CHECK = f"{ BASE } /Auth/Auth/DoCloudLoginCheck"
13+ GRADES_PAGE = f"{ BASE } /ICampus/StudentInfo/Index?page=%E6%88%90%E7%B8%BE%E6%9F%A5%E8%A9%A2"
14+ API_BASE = f"{ BASE } /ICampus"
1615
17- # URLs
18- self .LOGIN_URL = "https://shcloud2.k12ea.gov.tw/CLHSTYC/Auth/Auth/CloudLogin?sys=Auth"
19- self .GRADES_URL = "https://shcloud2.k12ea.gov.tw/CLHSTYC/ICampus/StudentInfo/Index?page=%E6%88%90%E7%B8%BE%E6%9F%A5%E8%A9%A2"
20- self .API_BASE = "https://shcloud2.k12ea.gov.tw/CLHSTYC/ICampus"
21-
22- def start_browser (self , use_saved_state = True ):
23- """Start the browser if not already started."""
24- if not self .browser :
25- self .playwright = sync_playwright ().start ()
26- self .browser = self .playwright .chromium .launch (headless = self .headless , slow_mo = 100 )
27- self .context = self .browser .new_context ()
28- self .page = self .context .new_page ()
16+ @staticmethod
17+ def _get_hidden_token (html : str ) -> str :
18+ """Extract __RequestVerificationToken from HTML."""
19+ soup = BeautifulSoup (html , "html.parser" )
20+ el = soup .select_one ('input[name="__RequestVerificationToken"]' )
21+ if not el or not el .get ("value" ):
22+ raise RuntimeError ("找不到 __RequestVerificationToken hidden input" )
23+ return el ["value" ]
2924
30- def login_and_get_tokens (self , username , password ):
31- """Login, extract tokens, close browser, and return credentials."""
25+ @staticmethod
26+ def login_and_get_tokens (username , password ):
27+ """Login via requests session, return (success, message, cookies_dict, student_no, token)."""
3228 try :
33- print (f"Attempting login for user: { username } (Hybrid Mode)" )
34- self .start_browser (use_saved_state = False )
35- print (f"Navigating to { self .LOGIN_URL } " )
36- self .page .goto (self .LOGIN_URL )
37-
38- # Handle popup if exists
39- popup_close_btn = self .page .locator ("button.swal2-close" )
40- if popup_close_btn .is_visible ():
41- print ("Found swal2 popup, closing it" )
42- popup_close_btn .click ()
43- elif self .page .get_by_role ("button" , name = "Close this dialog" ).is_visible ():
44- print ("Closing dialog popup via aria-label" )
45- self .page .get_by_role ("button" , name = "Close this dialog" ).click ()
46-
47- print ("Clicking '學生'" )
48- self .page .get_by_text ("學生" , exact = True ).click ()
49-
50- print ("Filling credentials" )
51- self .page .get_by_role ('textbox' , name = '請輸入帳號' ).fill (username )
52- self .page .get_by_placeholder ('請輸入密碼' ).fill (password )
53-
54- try :
55- self .page .get_by_role ('checkbox' ).click ()
56- except Exception :
57- pass
58-
59- print ("Clicking Login button..." )
60- with self .page .expect_response (lambda r : "DoCloudLoginCheck" in r .url and r .request .method == "POST" ) as response_info :
61- self .page .get_by_role ('button' , name = '登入' ).click ()
62-
63- api_response = response_info .value
64- try :
65- result = api_response .json ()
66- if result .get ("Status" ) == "Error" or (result .get ("Result" ) and not result ["Result" ].get ("IsLoginSuccess" )):
67- return False , "登入失敗" , None , None , None
68- except Exception as e :
69- print (f"Warning: Could not parse login API response (likely redirected): { e } " )
70- # Don't return False here, assume potential success and let the URL check decide
29+ print (f"Attempting login for user: { username } (requests mode)" )
30+ s = requests .Session ()
31+
32+ # 1) GET login page to obtain cookies + hidden token
33+ r = s .get (GradeFetcher .LOGIN_PAGE , timeout = 30 , verify = False )
34+ r .raise_for_status ()
35+ login_token = GradeFetcher ._get_hidden_token (r .text )
36+
37+ # 2) POST login
38+ headers = {
39+ "Content-Type" : "application/x-www-form-urlencoded; charset=UTF-8" ,
40+ "Referer" : GradeFetcher .LOGIN_PAGE ,
41+ "Origin" : "https://shcloud2.k12ea.gov.tw" ,
42+ "X-Requested-With" : "XMLHttpRequest" ,
43+ }
44+ data = {
45+ "SchoolCode" : "030305" ,
46+ "LoginId" : username ,
47+ "PassString" : password ,
48+ "LoginType" : "Student" ,
49+ "IsKeepLogin" : "false" ,
50+ "IdentityId" : "6" ,
51+ "SchoolName" : "國立中大壢中" ,
52+ "GoogleToken" : "8" ,
53+ "isRegistration" : "false" ,
54+ "ShCaptchaGenCode" : "10" ,
55+ "__RequestVerificationToken" : login_token ,
56+ }
57+
58+ resp = s .post (GradeFetcher .DO_CHECK , data = data , headers = headers , timeout = 30 , verify = False )
59+ resp .raise_for_status ()
7160
72- print ("Login API passed, waiting for redirect to Grades page..." )
7361 try :
74- self . page . wait_for_url ( "**/ICampus/**" , timeout = 20000 )
62+ j = resp . json ( )
7563 except Exception :
76- print ("Timed out waiting for URL redirect" )
77-
78- # Go to Grades page specifically to ensure tokens are present
79- print ("Navigating to grades page to scrape tokens..." )
80- self .page .goto (self .GRADES_URL , wait_until = "networkidle" )
81-
82- # Scrape tokens
83- print ("Scraping tokens..." )
84- student_no = username # Confirmed by user
85-
86- # Try to get verification token from hidden input
87- token = self .page .locator ('input[name="__RequestVerificationToken"]' ).first .get_attribute ('value' )
88-
89- if not token :
90- print ("Failed to find __RequestVerificationToken" )
91- return False , "無法取得驗證代碼" , None , None , None
64+ return False , "登入回應格式錯誤" , None , None , None
65+
66+ ok = bool (j .get ("Result" , {}).get ("IsLoginSuccess" ))
67+ if not ok :
68+ msg = j .get ("Result" , {}).get ("DisplayMsg" ) or j .get ("Message" ) or "登入失敗"
69+ return False , msg , None , None , None
70+
71+ print ("Login OK, fetching grades page for API token..." )
72+
73+ # 3) GET grades page to obtain the API-specific __RequestVerificationToken
74+ r2 = s .get (GradeFetcher .GRADES_PAGE , timeout = 30 , verify = False )
75+ r2 .raise_for_status ()
76+ api_token = GradeFetcher ._get_hidden_token (r2 .text )
77+
78+ # 4) Extract cookies as dict (filter out malformed cookies like 'no-cache')
79+ cookies_dict = {}
80+ for c in s .cookies :
81+ try :
82+ if c .name and c .value is not None and c .domain is not None :
83+ cookies_dict [c .name ] = c .value
84+ except Exception :
85+ pass
86+ student_no = username
9287
93- # Get Cookies
94- cookies = {c ['name' ]: c ['value' ] for c in self .context .cookies ()}
95-
9688 print (f"Successfully obtained credentials for { student_no } " )
97-
98- # Close browser immediately to save resources
99- self .close ()
100-
101- return True , "登入成功" , cookies , student_no , token
89+ return True , "登入成功" , cookies_dict , student_no , api_token
10290
10391 except Exception as e :
10492 print (f"Login Exception: { e } " )
105- self .close ()
10693 return False , f"登入錯誤: { str (e )} " , None , None , None
10794
10895 @staticmethod
@@ -118,7 +105,7 @@ def get_structure_via_api(cookies, student_no, token):
118105 "Referer" : "https://shcloud2.k12ea.gov.tw/CLHSTYC/ICampus/StudentInfo/Index?page=%E6%88%90%E7%B8%BE%E6%9F%A5%E8%A9%A2"
119106 }
120107 data = {
121- "searchType" : "各次考試單科成績" , # Based on user info
108+ "searchType" : "各次考試單科成績" ,
122109 "studentNo" : student_no ,
123110 "__RequestVerificationToken" : token
124111 }
@@ -128,38 +115,29 @@ def get_structure_via_api(cookies, student_no, token):
128115 response = requests .post (url , headers = headers , data = data , cookies = cookies , verify = False )
129116 response .raise_for_status ()
130117
131- # Convert API response to format expected by frontend
132- # The API likely returns a list of years/terms. We need to fetch exams for each?
133- # Actually, per user trace, this API returns years.
134- # Let's inspect the raw response logic from previous Playwright code...
135- # The previous code iterated through years and exams.
136- # Ideally we need `GetGradeCanQueryExamNoListByStudentNo` too?
137- # But user only gave `GetGradeCanQueryYearTermListByStudentNo`.
138- # Let's assume for now we return the years and frontend handles it,
139- # OR we try to fetch exams if we can guess the API.
140- # User trace showed `GetGradeCanQueryExamNoListByStudentNo` in the list!
141- # Let's try to implement a basic structure first.
142-
143118 years_data = response .json ()
144119 structure = {}
145120
121+ # Collect items to fetch
122+ items = []
146123 for item in years_data :
147- # Assuming item has 'text' and 'value' or similar based on Kendo
148- # Kendo usually maps from DisplayText/Value.
149- # Let's handle both.
150124 name = item .get ('DisplayText' ) or item .get ('text' )
151125 value = item .get ('Value' ) or item .get ('value' )
152-
153- if not value : continue
154-
155- # We need exams for each year.
156- # Attempt to call `GetGradeCanQueryExamNoListByStudentNo`
157- exams = GradeFetcher .get_exams_via_api (cookies , student_no , token , value )
158-
159- structure [name ] = {
160- "year_value" : value ,
161- "exams" : exams
162- }
126+ if value :
127+ items .append ((name , value ))
128+
129+ # Fetch all exams in parallel
130+ def _fetch_one (name_value ):
131+ n , v = name_value
132+ exams = GradeFetcher .get_exams_via_api (cookies , student_no , token , v )
133+ return n , v , exams
134+
135+ with ThreadPoolExecutor (max_workers = len (items ) or 1 ) as pool :
136+ for name , value , exams in pool .map (_fetch_one , items ):
137+ structure [name ] = {
138+ "year_value" : value ,
139+ "exams" : exams
140+ }
163141
164142 return structure
165143
@@ -172,7 +150,6 @@ def get_exams_via_api(cookies, student_no, token, year_value):
172150 """Helper to fetch exams for a year"""
173151 url = "https://shcloud2.k12ea.gov.tw/CLHSTYC/ICampus/CommonData/GetGradeCanQueryExamNoListByStudentNo"
174152
175- # Parse year and term from year_value (e.g., "114_1")
176153 try :
177154 if "_" in str (year_value ):
178155 year , term = str (year_value ).split ("_" )
@@ -193,7 +170,7 @@ def get_exams_via_api(cookies, student_no, token, year_value):
193170 "Referer" : "https://shcloud2.k12ea.gov.tw/CLHSTYC/ICampus/StudentInfo/Index?page=%E6%88%90%E7%B8%BE%E6%9F%A5%E8%A9%A2"
194171 }
195172 data = {
196- "searchType" : "單次考試所有成績" , # Based on user trace
173+ "searchType" : "單次考試所有成績" ,
197174 "studentNo" : student_no ,
198175 "year" : year ,
199176 "term" : term ,
@@ -225,15 +202,13 @@ def fetch_grades_via_api(cookies, student_no, token, year_value, exam_value):
225202 "Referer" : "https://shcloud2.k12ea.gov.tw/CLHSTYC/ICampus/StudentInfo/Index?page=%E6%88%90%E7%B8%BE%E6%9F%A5%E8%A9%A2"
226203 }
227204
228- # Parse Year and Term from year_value (e.g., "114_2" -> Year: 114, Term: 2)
229205 try :
230206 if "_" in str (year_value ):
231207 year , term = str (year_value ).split ("_" )
232208 elif len (str (year_value )) >= 4 :
233209 year = year_value [:- 1 ]
234210 term = year_value [- 1 ]
235211 else :
236- # Fallback/Default
237212 year = "114"
238213 term = "2"
239214 except Exception :
@@ -258,26 +233,3 @@ def fetch_grades_via_api(cookies, student_no, token, year_value, exam_value):
258233 except Exception as e :
259234 print (f"API Fetch Error: { e } " )
260235 raise e
261-
262- def close (self ):
263- """Close the browser."""
264- try :
265- if self .context :
266- self .context .close ()
267- if self .browser :
268- self .browser .close ()
269- if self .playwright :
270- self .playwright .stop ()
271- except Exception as e :
272- print (f"Error closing fetcher: { e } " )
273- finally :
274- self .context = None
275- self .browser = None
276- self .playwright = None
277-
278- def __enter__ (self ):
279- return self
280-
281- def __exit__ (self , exc_type , exc_val , exc_tb ):
282- self .close ()
283-
0 commit comments