Time Compass × NTUST Moodle 整合方案深度分析¶
簡介:NTUST Moodle 整合的獨特性與挑戰¶
為什麼需要 Moodle 整合?¶
在台科大校園環境中,課程行事曆、作業截止期限、考試時程都集中在 Moodle 教學平臺。時間管理 AI 助理若要真正幫助學生規劃每週任務,必須納入 Moodle 作為資訊源。
獨特性¶
沒有完整的公開 API 文件。 Time Compass 採取了雙路徑策略: - 快速路徑:透過官方 AJAX API 與 SSO(OIDC)登入 - 備備方案:用 Selenium 爬蟲自動化登入
主要挑戰¶
- 認證流程複雜:台科大 Moodle 採用 OIDC 聯邦身份認證
- API 端點隱藏:行事曆查詢依賴內部 AJAX 端點
- 爬蟲易碎性強:HTML 結構改變就會破裂
- 效率與超時:併發爬取大量月份資料容易超時
- 密碼管理:學生帳密涉及隱私
NTUST Moodle API 研究結果¶
官方開放的 Web Service 端點¶
| 端點 | 方法 | 功能 | 參數 |
|---|---|---|---|
/lib/ajax/service.php | POST | 通用 AJAX 服務分發器 | sesskey, info |
core_calendar_get_calendar_monthly_view | API Call | 取得單月行事曆 | year, month, courseid |
core_calendar_get_calendar_event_by_id | API Call | 取得單一事件詳細資訊 | eventid |
core_session_time_remaining | API Call | 驗證登入狀態 | (無) |
core_course_get_enrolled_courses_by_timeline_classification | API Call | 列舉課程 | classification |
Raw JSON 深度解析¶
一個月份查詢的回應結構包含:
{
"error": false,
"data": {
"weeks": [
{
"days": [
{
"mday": 1,
"events": [
{
"id": 12345,
"name": "期中考 15:00-17:00",
"description": "...",
"modulename": "quiz",
"eventtype": "due",
"timestart": 1761926400,
"timeduration": 7200,
"course": {
"id": 17844,
"fullname": "114.2【電資班】EC163B011 物理(下)",
"shortname": "ET173A001",
"summary": "1141EC163B011"
}
}
]
}
]
}
]
}
}
主要發現¶
- 事件型別多樣:
due,start,lesson等 - 內嵌課程物件:每個事件內都包含完整的課程資訊
- Action 物件:可執行操作的指標
- 無分頁機制:API 直接回傳該月所有事件
- sesskey 必需:任何 AJAX 呼叫都需要此參數
雙路徑架構:Async API vs Selenium 爬蟲¶
架構圖¶
user_account + user_password
│
├─────────────────────┬────────────────────┐
│ │ │
▼ ▼ ▼
[1] API Login Path [2] Selenium Path [Fallback]
(aiohttp) (Selenium)
└──→ OIDC SSO └──→ Headless login
登入 自動填表
提取 cookies 提取 cookies
│ │
└─────────────────┴──────────────┬─────────┐
▼
fetch_events_by_range()
│
asyncio.gather()
│
處理月份資料
路徑 1:API 路徑(快速、推薦)¶
檔案:src/time_compass/integrations/moodle/async_core.py
登入流程(api_login 函數)¶
步驟: 1. 發起 OIDC 重定向 2. 填寫 SSO 表單 3. 處理回呼表單 4. 驗證與提取 Cookies
核心優點: - ✅ 完全異步 - ✅ 輕量級,無須啟動瀏覽器進程 - ✅ 快速(通常 3-5 秒)
侷限: - ❌ 若 Moodle UI 改版,容易失效 - ❌ SSO 伺服器抽查檢驗可能拒絕自動化登入
取得事件流程(fetch_events_by_range 函數)¶
步驟: 1. 提取 sesskey 2. 計算月份區間 3. 並發 AJAX 呼叫 4. Raw → Internal 轉換
路徑 2:Selenium 爬蟲路徑(備備方案)¶
檔案:src/time_compass/integrations/moodle/scraper.py
用途:當 API 登入失敗時啟用的備備方案。
初始化與驅動配置¶
class MoodleScraper:
def _create_driver(self) -> webdriver.Chrome:
options = Options()
if self._headless:
options.add_argument("--headless=new")
options.add_argument("--disable-gpu")
# ...
driver = webdriver.Chrome(options=options)
driver.set_page_load_timeout(30)
return driver
特點: - Headless 模式(無 UI) - User-Agent 仿造 Chrome - 忽略自簽章憑證警告
登入流程¶
def login(self) -> bool:
"""執行 Selenium 登入流程"""
try:
self._driver.get(DEFAULT_CONFIG.LOGIN_URL)
self._inject_auto_login_script()
# 等待 SSO 重定向完成
self._wait.until(lambda d: "ssoam2.ntust.edu.tw" not in d.current_url)
# 驗證
if "login" not in self._driver.current_url.lower():
return True
return False
except:
return False
優點: - 完全模擬真人操作,難以被反爬蟲檢測 - 若 Cookies 過期,仍可透過完整登入流程重取
缺點: - ❌ 阻塞(需要透過 asyncio.run_in_executor 才能在非同步函數中使用) - ❌ 緩慢(完整瀏覽器啟動與頁面渲染需 10-30 秒) - ❌ 資源密集
課程範圍、作業與事件的資料模型¶
三層模型架構¶
Time Compass 對 Moodle 資料採取嚴格的 DDD 分層:
層 1:Raw Layer(models_raw.py)¶
目標:與 API JSON 回應 100% 對應
class RawCourse(BaseModel):
"""原始課程資料"""
id: int
fullname: str
shortname: str
summary: str
startdate: int
enddate: int
# ... 其他 80+ 欄位
class RawEvent(BaseModel):
"""原始事件資料"""
id: int
name: str
description: str
modulename: str # "quiz", "assign"
eventtype: str # "due", "start"
timestart: int
timeduration: int
overdue: bool
course: Optional[RawCourse]
# ... 其他 20+ 欄位
層 2:Internal Layer(models_internal.py)¶
目標:轉換為領域語言
class MoodleEvent(BaseModel):
"""Moodle 領域事件模型"""
id: int | None = None
title: str
description: str = ""
type: str = ""
due_at: dt.datetime | None = None
open_at: dt.datetime | None = None
cutoff_at: dt.datetime | None = None
course_code: str = ""
course_name: str = ""
semester: str = ""
@classmethod
def from_raw(cls, raw: RawEvent) -> MoodleEvent:
"""從 RawEvent 轉換"""
# 1. 清洗 HTML
# 2. 轉換時區
# 3. 提取課程資訊
# ...
層 3:Read Layer(TOON 轉換)¶
目標:優化用於 LLM 輸入的格式
class MoodleEventRead(BaseModel):
"""LLM 友善的事件表示"""
cid: str = ""
title: str
status: str # "已開放", "已逾期"
open_date: Optional[str] = None
due_date: str
due_weekday: str
due_hours_minutes: str
非同步爬蟲的實作模式與逾時處理¶
逾時管理策略¶
層 1:全局逾時(函數級)¶
async def scrape_with_timeout():
try:
return await asyncio.wait_for(
scrape_moodle_events(...),
timeout=60 # 預設 60 秒
)
except asyncio.TimeoutError:
return MoodleResult(success=False, message="抓取超時 (60s)")
層 2:並發控制(月份級)¶
tasks = [
_fetch_month(session, base_url, sesskey, y, m)
for y, m in months
]
connector = aiohttp.TCPConnector(limit=concurrency) # 預設 5
results = await asyncio.gather(*tasks, return_exceptions=True)
層 3:登入逾時¶
driver.set_page_load_timeout(30) # 頁面載入 30 秒逾時
self._wait = WebDriverWait(self._driver, 10) # 元素等待 10 秒
容錯模式¶
登入相關錯誤¶
try:
cookies = await api_login(account, password)
if cookies:
logger.info("API 登入成功")
else:
logger.warning("API 登入失敗,啟用 Selenium Fallback...")
cookies = await fallback_selenium_login(...)
if not cookies:
return MoodleResult(success=False, message="登入失敗 (API & Selenium)")
except Exception as e:
logger.error(f"Selenium Login Error: {e}")
return MoodleResult(success=False, message=f"登入異常: {e}")
設計理念: - API 失敗 → 嘗試 Selenium - Selenium 也失敗 → 直接告訴使用者 - 任何例外都被捕捉並轉為 MoodleResult.success=False
月份抓取相關錯誤¶
# 若單個月份失敗,放棄該月但繼續其他月份
results = await asyncio.gather(*tasks, return_exceptions=True)
for res in results:
if isinstance(res, list):
raw_events.extend(res)
elif isinstance(res, Exception):
logger.warning(f"Fetch month failed: {res}") # 僅警告,不中止
認證流程與密碼管理¶
認證架構¶
使用者 (帳密)
│
├─────────────────────────────────────┐
│ │
▼ ▼
[管理員側] [使用者側]
環境變數 (.env) 實時輸入 (UI)
MOODLE_ACCOUNT moodle_helper.py
MOODLE_PASSWORD (Gradio 輸入框)
密碼來源¶
來源 1:環境變數(舊版、批次使用)¶
# .env
MOODLE_ACCOUNT=B11245001
MOODLE_PASSWORD="myPassword@123"
適合: - 完全自動化的批次爬蟲 - 測試環境
來源 2:UI 輸入(新版、使用者友善)¶
async def scrape_moodle_sync(
request: gr.Request,
account: str,
password: str,
headless: bool,
):
# 帳密直接透過函數參數傳入
result = await scrape_moodle_events(...)
安全考量¶
✅ 已採取的保護措施¶
- 內存中儲存,不持久化
- Cookies 和帳密被擷取後,存於函數變數內
- 函數執行完畢後由 GC 回收
-
不寫入磁碟、日誌或資料庫
-
環境變數隔離
.env檔案加入.gitignore-
部署時透過密鑰管理系統注入
-
HTTPS-only
- 所有 Moodle 呼叫都透過 HTTPS
-
忽略自簽章憑證僅在測試環境
-
日誌脫敏
logger.warning(f"API Login: 帳密錯誤 (User: {username})") # 不記錄密碼
⚠️ 潛在風險與建議¶
| 風險 | 現狀 | 建議改善 |
|---|---|---|
| 帳密在命令列可見 | 在 shell history 中可查詢 | 使用密碼提示符(getpass) |
| Cookies 有效期不明 | 通常 24 小時 | 定期重新登入 |
| Selenium 爬蟲保留帳密 | 已隔離 | 考慮 Stealth Mode 以避免反爬蟲 |
快取與更新策略¶
現狀:無持久化快取¶
目前 Time Compass 採取無狀態設計: - 每次呼叫 scrape_moodle_events() 都進行完整重新登入 - 不保存前一次的登入 cookies - 不快取已抓取的事件資料
優點¶
- ✅ 簡單、無狀態
- ✅ 總是取得最新資料
- ✅ 無快取過期問題
- ✅ 當帳密變更時自動適應
缺點¶
- ❌ 效率低:頻繁呼叫時多次重複登入
- ❌ 伺服器負荷:多個使用者同時抓取導致大量登入請求
建議的改善方案¶
方案 A:Client-side Cookies 快取(簡易)¶
class MoodleAuthCache:
_cache = {}
@classmethod
def get_cached_cookies(cls, account: str, password: str):
"""若 Cookies 仍有效,直接回傳;否則重新登入"""
key = hashlib.sha256(f"{account}:{password}".encode()).hexdigest()
cached = cls._cache.get(key)
if cached and time.time() < cached["expires_at"]:
return cached["cookies"]
return None
方案 B:Redis 快取(中等複雜度)¶
async def api_login_with_cache(username, password, cache_ttl=3600):
key = f"moodle:cookies:{hashlib.sha256(username.encode()).hexdigest()}"
# 嘗試快取
cached = await redis_client.get(key)
if cached:
return json.loads(cached)
# 重新登入並存入快取
cookies = await api_login(username, password)
if cookies:
await redis_client.setex(key, cache_ttl, json.dumps(cookies))
return cookies
功能限制與已知問題¶
已知限制¶
| 限制 | 說明 | 原因 |
|---|---|---|
| 僅支援 NTUST | Time Compass 只針對台科大 Moodle 2.x 開發 | SSO 流程與 API 端點為校內特定 |
| 無課程詳細資訊 | 無法抓取課程教材、討論區等 | API 無這些端點 |
| 無成績查詢 | 無法取得課程成績 | API 無開放此端點 |
| 無課程簽到 | 無法自動簽到 | 超出爬蟲職責、涉及道德問題 |
| 無實時推播 | 若 Moodle 上有新公告,無法立即通知 | 需 WebSocket,額外複雜度 |
| 附件下載有限 | 無法自動下載作業範本或教材檔案 | 設計上未包含檔案下載邏輯 |
已知問題¶
1. Moodle 登入不穩定¶
症狀: - 「API 登入失敗,啟用 Selenium Fallback」訊息頻繁出現 - Selenium 爬蟲超時
原因: - SSO 伺服器偶爾延遲 - 防滑動(CAPTCHA)或流量限制 - 帳密過期
解決方案: 1. 檢查帳密是否正確 2. 在 Moodle 網頁手動登入驗證 3. 聯繫資訊中心確認 SSO 狀態
2. 並發限制導致超時¶
症狀: - 抓取跨度超過 6 個月時出現逾時 - 訊息:「抓取超時 (60s)」
原因: - 預設並發數 5 對於大跨度可能不足 - Moodle 伺服器繁忙
臨時解決方案:
result = await scrape_moodle_events(
account, password,
concurrency=10 # 加大並發
)
3. Selenium 驅動進程洩漏¶
症狀: - 長期執行後 ChromeDriver 進程堆積 - 系統 RAM 逐漸被佔用
原因: - Selenium 爬蟲臨時失敗時,驅動進程未妥善關閉 - 例外處理時 scraper.quit() 未被呼叫
防守:
# ✅ 安全用法
with MoodleScraper(account, password) as scraper:
if scraper.login():
cookies = scraper.get_cookies()
# 即便例外也會自動 quit()
升級或替換為官方 API 的可行性評估¶
現狀:Moodle 官方 Web Service API 的限制¶
| 功能 | 官方 API 支援 | Time Compass 現狀 |
|---|---|---|
| 列舉課程 | ✅ 完整支援 | ✅ 已應用 |
| 取得事件清單 | ⚠️ 部份支援 | 🔄 部份應用 |
| 取得事件詳情 | ⚠️ 基礎支援 | ❌ 未應用 |
| 查詢行事曆月份檢視 | ❌ 無官方端點 | ✅ 用 AJAX 逆向工程實現 |
| 作業詳情 | ✅ 部份支援 | ⚠️ 部份支援 |
| 成績查詢 | ⚠️ 受角色限制 | ❌ 未實現 |
升級策略評估¶
選項 A:完全遷移至官方 Web Service API¶
預期效果: - ✅ 不需爬蟲,更穩定 - ✅ 官方維護,文件完整 - ❌ 功能受限 - ❌ 需重寫大量邏輯
實可行性:⭐⭐ (2/5 不推薦)
選項 B:混合方案(推薦)¶
將現有架構分為兩層:
第一層(推薦):優先用官方 API
courses = await get_enrolled_courses_by_timeline()
events = await get_calendar_events_by_course_range(courses)
第二層(備備):官方 API 不足時回落至爬蟲
if not events or missing_details:
events = await fetch_events_by_range(cookies, ...)
實可行性:⭐⭐⭐⭐⭐ (5/5 強烈推薦)
結語¶
Time Compass 的 Moodle 整合代表了一個獨特的工程決策:面對官方 API 的不足,透過雙路徑設計(快速 OIDC API + 穩健 Selenium 爬蟲)實現了功能完整性與可靠性的平衡。
核心設計成就¶
- 完全異步:利用
asyncio與aiohttp,將月份並發請求從串列的 12 秒降至 3-5 秒 - 嚴格 DDD 分層:
RawModel → InternalModel → ReadModel → TOON完整轉換流 - 優雅降級:API 失敗自動轉 Selenium,確保高可用性
- 安全第一:帳密不持久化、不記錄至日誌
文檔版本:v1.0
更新日期:2026年3月2日
技術難度:⭐⭐⭐⭐ (進階)