Skip to content

Commit aa67a1c

Browse files
優化登入邏輯
1 parent 31a32c2 commit aa67a1c

8 files changed

Lines changed: 131 additions & 154 deletions

File tree

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,9 @@ venv.bak/
3535
.env
3636

3737
# Application Data & Temporary Files
38-
sessions/*
3938
grades_raw.json
40-
state.json
4139
shared_grades/
4240
*.json
43-
!sessions/0.json
4441
!requirements.txt
4542
!.env.example
4643
!package.json

Dockerfile

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM mcr.microsoft.com/playwright/python:v1.40.0-jammy
1+
FROM python:3.11-slim
22

33
WORKDIR /app
44

@@ -10,12 +10,10 @@ RUN pip install --no-cache-dir -r requirements.txt
1010
COPY . .
1111

1212
# Create necessary directories
13-
RUN mkdir -p sessions shared_grades
13+
RUN mkdir -p shared_grades
1414

1515
# Expose port
1616
EXPOSE 5000
1717

1818
# Use gunicorn for production
19-
# --timeout 120: Playwright operations (login, fetching grades) can take time
20-
# --workers 2: Keep low since Playwright is memory-intensive
2119
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--timeout", "120", "--workers", "5", "--threads", "4", "server:app"]

docker-compose.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ services:
44
ports:
55
- "5000:5000"
66
volumes:
7-
- ./sessions:/app/sessions
87
- ./shared_grades:/app/shared_grades
98
environment:
109
- PYTHONUNBUFFERED=1
1110
- SECRET_KEY=${SECRET_KEY}
1211
- TZ=Asia/Taipei
13-
ipc: host
12+
1413
restart: unless-stopped
1514
healthcheck:
1615
test: [ "CMD", "curl", "-f", "http://localhost:5000/" ]

grade_fetcher.py

Lines changed: 93 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,95 @@
11
import requests
22
import 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
67
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
78

89
class 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-

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
Flask
22
flask-cors
3-
playwright==1.40.0
3+
beautifulsoup4
44
gunicorn
55
requests

server.log

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,36 @@
5555
2026-02-20 22:29:28,554 - SchoolGradesServer - INFO - Starting School Grades Server...
5656
2026-02-20 22:29:31,657 - SchoolGradesServer - INFO - Accessing index page
5757
2026-02-20 22:30:13,692 - SchoolGradesServer - INFO - Accessing index page
58+
2026-02-23 20:21:20,840 - SchoolGradesServer - INFO - Cleanup thread started
59+
2026-02-23 20:21:20,845 - SchoolGradesServer - INFO - Starting School Grades Server...
60+
2026-02-23 20:21:21,640 - SchoolGradesServer - INFO - Cleanup thread started
61+
2026-02-23 20:21:21,644 - SchoolGradesServer - INFO - Starting School Grades Server...
62+
2026-02-23 20:21:23,602 - SchoolGradesServer - INFO - Accessing index page
63+
2026-02-23 20:21:31,212 - SchoolGradesServer - INFO - Login attempt for user: 310471
64+
2026-02-23 20:22:13,769 - SchoolGradesServer - INFO - Cleanup thread started
65+
2026-02-23 20:22:13,772 - SchoolGradesServer - INFO - Starting School Grades Server...
66+
2026-02-23 20:22:46,193 - SchoolGradesServer - INFO - Accessing index page
67+
2026-02-23 20:22:53,888 - SchoolGradesServer - INFO - Login attempt for user: 310471
68+
2026-02-23 20:23:48,167 - SchoolGradesServer - INFO - Cleanup thread started
69+
2026-02-23 20:23:48,170 - SchoolGradesServer - INFO - Starting School Grades Server...
70+
2026-02-23 20:23:57,268 - SchoolGradesServer - INFO - Login attempt for user: 310471
71+
2026-02-23 20:23:58,374 - SchoolGradesServer - INFO - Login successful for user: 310471
72+
2026-02-23 20:23:58,375 - SchoolGradesServer - INFO - Fetching structure via API...
73+
2026-02-23 20:23:59,722 - SchoolGradesServer - INFO - Structure cached: 4 semesters
74+
2026-02-23 20:24:15,899 - SchoolGradesServer - INFO - Accessing index page
75+
2026-02-23 20:24:21,912 - SchoolGradesServer - INFO - Accessing index page
76+
2026-02-23 20:24:27,293 - SchoolGradesServer - INFO - Login attempt for user: 310471
77+
2026-02-23 20:24:35,466 - SchoolGradesServer - INFO - Login attempt for user: 310471
78+
2026-02-23 20:24:39,610 - SchoolGradesServer - INFO - Login successful for user: 310471
79+
2026-02-23 20:24:39,611 - SchoolGradesServer - INFO - Fetching structure via API...
80+
2026-02-23 20:24:40,554 - SchoolGradesServer - INFO - Structure cached: 4 semesters
81+
2026-02-23 20:25:58,107 - SchoolGradesServer - INFO - Cleanup thread started
82+
2026-02-23 20:25:58,110 - SchoolGradesServer - INFO - Starting School Grades Server...
83+
2026-02-23 20:26:12,998 - SchoolGradesServer - INFO - Cleanup thread started
84+
2026-02-23 20:26:13,001 - SchoolGradesServer - INFO - Starting School Grades Server...
85+
2026-02-23 20:26:19,853 - SchoolGradesServer - INFO - Cleanup thread started
86+
2026-02-23 20:26:19,856 - SchoolGradesServer - INFO - Starting School Grades Server...
87+
2026-02-23 20:26:36,639 - SchoolGradesServer - INFO - Login attempt for user: 310471
88+
2026-02-23 20:26:37,454 - SchoolGradesServer - INFO - Login successful for user: 310471
89+
2026-02-23 20:26:37,456 - SchoolGradesServer - INFO - Fetching structure via API...
90+
2026-02-23 20:26:38,757 - SchoolGradesServer - INFO - Structure cached: 4 semesters

0 commit comments

Comments
 (0)