跳轉到

Time Compass × NTUST Moodle 整合方案深度分析

簡介:NTUST Moodle 整合的獨特性與挑戰

為什麼需要 Moodle 整合?

在台科大校園環境中,課程行事曆、作業截止期限、考試時程都集中在 Moodle 教學平臺。時間管理 AI 助理若要真正幫助學生規劃每週任務,必須納入 Moodle 作為資訊源

獨特性

沒有完整的公開 API 文件。 Time Compass 採取了雙路徑策略: - 快速路徑:透過官方 AJAX API 與 SSO(OIDC)登入 - 備備方案:用 Selenium 爬蟲自動化登入

主要挑戰

  1. 認證流程複雜:台科大 Moodle 採用 OIDC 聯邦身份認證
  2. API 端點隱藏:行事曆查詢依賴內部 AJAX 端點
  3. 爬蟲易碎性強:HTML 結構改變就會破裂
  4. 效率與超時:併發爬取大量月份資料容易超時
  5. 密碼管理:學生帳密涉及隱私

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"
                }
              }
            ]
          }
        ]
      }
    ]
  }
}

主要發現

  1. 事件型別多樣due, start, lesson
  2. 內嵌課程物件:每個事件內都包含完整的課程資訊
  3. Action 物件:可執行操作的指標
  4. 無分頁機制:API 直接回傳該月所有事件
  5. 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(...)

安全考量

✅ 已採取的保護措施

  1. 內存中儲存,不持久化
  2. Cookies 和帳密被擷取後,存於函數變數內
  3. 函數執行完畢後由 GC 回收
  4. 不寫入磁碟、日誌或資料庫

  5. 環境變數隔離

  6. .env 檔案加入 .gitignore
  7. 部署時透過密鑰管理系統注入

  8. HTTPS-only

  9. 所有 Moodle 呼叫都透過 HTTPS
  10. 忽略自簽章憑證僅在測試環境

  11. 日誌脫敏

    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 爬蟲)實現了功能完整性與可靠性的平衡

核心設計成就

  1. 完全異步:利用 asyncioaiohttp,將月份並發請求從串列的 12 秒降至 3-5 秒
  2. 嚴格 DDD 分層RawModel → InternalModel → ReadModel → TOON 完整轉換流
  3. 優雅降級:API 失敗自動轉 Selenium,確保高可用性
  4. 安全第一:帳密不持久化、不記錄至日誌

文檔版本:v1.0
更新日期:2026年3月2日
技術難度:⭐⭐⭐⭐ (進階)