# [Moodle4.5] 查看課程最新消息(講解修正部分)
:::success
原版 https://hackmd.io/@richen/HJoEhktVle
原始碼/執行檔 https://github.com/CodeRichen/New_Moodle/releases/tag/2.1
事先下載套件
`pip install selenium webdriver-manager colorama requests py7zr patool rarfile`
:::
>[!Tip]若遇到執行上的問題,或是有想要加入的功能,都歡迎在下方留言或私訊
- `C:\Users\User\Downloads\class` : 存放所有下載的資料,可以自行更改路徑(預設在download中是因為你在其他平臺上下載的檔案(line,team)能夠比較方便地拖曳到此資料夾當中)
- `class.txt` : 存放所有課程的歷史紀錄
- `password.txt` : 存放moodle帳號密碼
- `submitted_assignments.json` : 存放所有已繳交作業
## Chrome Options
- headless : 無頭模式。不開啟真實的瀏覽器視窗,所有的操作都在背景完成,能節省大量的 CPU 與記憶體(RAM)資源
- disable-gpu : 禁用硬體加速,避免輸出 Created TensorFlow Lite XNNPACK delegate for CPU.
- `page_load_strategy = 'eager'` : 當 HTML 結構載入完就立刻開始抓資料,不等待外部資源載入,加快開始執行和結束的速度
- `"profile.default_content_setting_values.images": 2` : 禁用圖片載入
- no-sandbox : 加快啟動速度
- `"download.default_directory": download_dir` : 指定預設下載路徑,讓腳本抓下來的檔案直接進到指定的資料夾
- `"plugins.always_open_pdf_externally": True` : 有些課程的連結會用內建預覽器打開 PDF。開啟此項後,PDF 能直接觸發下載
- `log-level=3 & "enable-logging"` : 關閉 Chrome 的除錯日誌
:::warning
1=資訊, 2=警告, 3=錯誤。設定為 3 只顯示嚴重錯誤
:::
- `service = Service(log_path=os.devnull)` : 將 Service 的日誌指向 os.devnull
- `chrome_options.add_experimental_option("excludeSwitches", ["enable-logging"])` : 避免輸出 DevTools listening on xxx
## 登入系統
```javascript=
def simulate_typing(driver, element_id, text):
script = f"""
var element = document.getElementById('{element_id}');
element.focus();
element.value = '';
// 模擬逐字輸入
var text = '{text}';
for (let i = 0; i < text.length; i++) {{
element.value += text[i];
element.dispatchEvent(new KeyboardEvent('keydown', {{
key: text[i],
char: text[i],
bubbles: true
}}));
element.dispatchEvent(new Event('input', {{ bubbles: true }}));
element.dispatchEvent(new KeyboardEvent('keyup', {{
key: text[i],
char: text[i],
bubbles: true
}}));
}}
element.dispatchEvent(new Event('change', {{ bubbles: true }}));
element.blur();
"""
driver.execute_script(script)
```
- KeyboardEvent (keydown, keyup):告訴網頁「有一個按鍵被按下了」。
- Input Event:觸發網頁的即時監控邏輯(例如檢查帳號格式)
- Change & Blur:模擬滑鼠點擊其他地方(移開焦點),這會觸發網頁最後的校驗邏輯
>[!Warning]如果直接填入文字,moodle判定這是機器人,會自動把我輸入的密碼給刪除,若使用原版,必須要趁它刪除之前趕快按登入,當網路不好時常失敗!!
- 按下登入按鈕之後,程式會檢查 current_url。如果 URL 裡面還有 login 字眼,代表登入失敗(時常發生在重複測試或是已登入model的狀況下失敗,推測若系統判定你已登入,若再次登入會失敗),會再重複登入一次,不過這邊可以改成讓他重複嘗試無限次。
## 抓取新資訊 (yellow)
>[!Tip]JavaScript注入加快數據提取
### 週次判斷
- 假設今天是 2026 年 1 月 14 日
- 抓到文字:12月 29日 - 01月 04日 $\rightarrow$ 判斷為過去的週次(不標記)
- 抓到文字:01月 12日 - 01月 18日 $\rightarrow$ 今天落在區間內 $\rightarrow$ 標記 「本週」
- 抓到文字:01月 19日 - 01月 25日 $\rightarrow$ 開始日期在 7 天內 $\rightarrow$ 標記 「下週」
- 該週次 index 為課程中最大的有內容週 $\rightarrow$ 最新週
### 利用CSS 選擇器找到所有"我的課程"的超連結,並為每個課程開啟新分頁
```python
course_links = driver.find_elements(By.CSS_SELECTOR, "a.aalink.coursename")
course_hrefs = [link.get_attribute("href") for link in course_links]
# 預先為所有課程開啟分頁
main_window = driver.current_window_handle # 保存主視窗
for idx, href in enumerate(course_hrefs, 1):
if idx == 1:
# 第一個課程直接在當前分頁載入
driver.get(href)
else:
# 其他課程開新分頁
driver.execute_script(f"window.open('{href}', '_blank');")
# 獲取所有分頁的 handle
all_tabs = driver.window_handles
```
### 將所有分頁都跑一次(下面的1-3)
```python
# 隨抓隨存:直接提取並儲存有效數據(只需一次迴圈)
tab_data_map = {}
for tab_handle in all_tabs:
driver.switch_to.window(tab_handle)
try:
data = driver.execute_script(extraction_script)
# 只儲存有效數據,失敗的分頁直接跳過
if data and data.get('courseName'):
tab_data_map[tab_handle] = data
```
#### 1. 課程標題
```javascript
document.querySelector('h1.h2').textContent
```
#### 2. 每週標題
```javascript
document.querySelectorAll('li.section')
```
>[!Tip]過濾無意義標題
```!(name.startsWith('第') && name.endsWith('週'))```
>[!Tip]優先從屬性 ```data-activityname``` 拿名稱,拿不到再找 ```span.instancename```。
#### 3. 活動連結獲取
```javascript
href = link ? link.getAttribute('href') : '(無連結)';
```
### tab_data_map 的內容範例
```javascript
{
"tab_handle_1": {
"courseName": "計算機概論",
"sections": [
{
"index": 1,
"weekText": "第一週:環境架設",
"activities": [
{"name": "安裝教學影片", "href": "http://..."},
{"name": "作業繳交區", "href": "http://..."}
]}]}}
```
## 多執行緒並行處理
- 當一個執行緒在等待 CPU 計算或等待網路回傳時,GIL 會被釋放,讓另一個執行緒執行
```python
course_results = []
with ThreadPoolExecutor(max_workers=len(all_tabs)) as executor:
future_to_tab = {
executor.submit(process_extracted_data, tab_handle, data, href): (tab_handle, idx)
for idx, (tab_handle, data) in enumerate(zip(all_tabs, tab_data_map.values()))
}
# 先完成的先輸出(非阻塞式)
for future in as_completed(future_to_tab):
result = future.result()
course_results.append(result)
# 立即輸出本週/下週/最新週
with output_lock:
print_week_info(result['current_week_info'])
```
### 針對每個分頁都開一個執行緒 :
```ThreadPoolExecutor(max_workers=len(all_tabs))```
### 並行啟動 :
```executor.submit```
- 將 process_extracted_data 函式丟進線程池
- Future 物件會記錄任務的狀態:PENDING(排隊中)、RUNNING(執行中)、FINISHED(已完成)
### 誰先完成就先輸出誰 :
```as_completed(future_to_tab)```
### 執行緒 X 唯一的鑰匙 :
```with output_lock```
- 避免同時印出
## 下載新資訊 (rad)
>[!Note] 比對執行時的網頁狀態和class.txt,列出新資訊
>[!Caution] 如果資料夾已經存在同名檔案,會覆蓋成新檔案
- `driver.get(link)` 點擊link進入活動
- `driver.get(course_url)` 回到上一頁(下圖)

### 下載內嵌圖片
只抓取 src 屬性中包含 pluginfile.php 的圖片連結
```python=
if link == "(無連結)" or not link or not link.startswith("http"):
# 回到課程頁面,找出這個活動的圖片
driver.get(course_url)
time.sleep(0.2)
# 找到包含此活動名稱的活動元素
activities = driver.find_elements(By.CSS_SELECTOR, "div.activity-item")
for act in activities:
act_name = act.get_attribute("data-activityname")
if not act_name:
try:
act_name = act.find_element(By.CSS_SELECTOR, "span.instancename").text.strip()
except:
continue
if act_name == name:
# 找到對應的活動,檢查是否有圖片
try:
images = act.find_elements(By.CSS_SELECTOR, "img[src*='pluginfile.php']")
for img in images:
img_url = img.get_attribute("src")
filename = extract_filename_from_url(img_url)
if filename in downloaded_files or filename in existing_files:
continue
# 使用 requests 直接下載圖片
try:
# 使用 session 以保持登入狀態
session = create_session_with_cookies()
# 下載圖片
response = session.get(img_url, stream=True)
if response.status_code == 200:
file_path = os.path.join(course_path, filename)
with open(file_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
remove_zone_identifier(file_path)
downloaded_files.add(filename)
existing_files.add(filename)
total_downloaded_files += 1
```
### 下載討論區文字內容

將標題與內容整合,儲存為 .txt 檔案
```python=
if "mod/forum/view.php" in link:
try:
driver.get(link)
time.sleep(0.2)
# 提取討論區描述內容
description_text = ""
try:
description_divs = driver.find_elements(By.CSS_SELECTOR, "div.activity-description")
if description_divs:
description_text = description_divs[0].text.strip()
except:
pass
if description_text:
# 清理檔案名稱
safe_filename = "".join(c if c.isalnum() or c in " _-()()" else "_" for c in name)
if len(safe_filename) > 100:
safe_filename = safe_filename[:100]
# 儲存為文字檔
txt_file = os.path.join(course_path, f"{safe_filename}.txt")
with open(txt_file, 'w', encoding='utf-8') as f:
f.write(f"{name}\n")
f.write("=" * 60 + "\n\n")
f.write(description_text)
```
### 下載資源檔案(PDF, PPT...)
活動連結(link)網址格式: https://elearningv4.nuk.edu.tw/mod/resource/view.php?id=xxx

- 點擊link時,它不會直接下載檔案,而是跳轉到一個帶有「如果您沒有自動下載,請點擊此處」字樣的頁面
- 程式會模擬人類視覺,直接去抓 resourceworkaround 區塊內的真正下載連結
```python=
if "mod/resource/view.php" in link:
driver.get(link)
time.sleep(0.2)
# 尋找 Moodle 資源頁面特有的「下載區塊」
download_links = driver.find_elements(By.CSS_SELECTOR, "div.resourceworkaround a[href*='pluginfile.php']")
for link_elem in download_links:
dl_href = link_elem.get_attribute("href")
filename = extract_filename_from_url(dl_href)
if filename in downloaded_files or filename in existing_files:
continue
# 使用 Requests 進行穩定下載
# (此處省略重複的 Cookie 設置與下載邏輯...)
# 如果是壓縮檔 (.zip, .rar),下載後自動解壓並刪除原檔
if filename.endswith((".zip", ".rar", ".7z")):
if os.path.getsize(file_path) > 100: # 簡單檢查檔案是否損毀
if extract_file(file_path, course_path):
os.remove(file_path) # 解壓成功則刪除
```
### 下載「作業說明」

1. 下載作業說明附件
2. 將自己提交的作業加入黑名單
3. 下載在其他位置的檔案(pluginfile.php,forcedownload=1,introattachment的連結
```python=
if "mod/folder/view.php" in link or "mod/assign/view.php" in link:
driver.get(link)
time.sleep(0.2)
# A. 針對「作業」:優先下載作業說明中的附件
if "mod/assign/view.php" in link:
intro_attachments = driver.find_elements(By.CSS_SELECTOR, "div.activity-description a[href*='pluginfile.php']")
# (遍歷並下載附件...)
# B. 排除已提交的檔案 (避免下載到自己交上去的作業)
submission_links = set()
submission_blocks = driver.find_elements(By.CSS_SELECTOR, "div[class*='summary_assignsubmission_file']")
for block in submission_blocks:
for a in block.find_elements(By.CSS_SELECTOR, "a[href]"):
submission_links.add(a.get_attribute("href"))
# C. 收集該頁面所有有效的檔案連結 (去重與過濾)
file_href_set = set()
# 策略:抓取所有 pluginfile、強制下載參數、以及說明區附件
all_potential_links = driver.find_elements(By.CSS_SELECTOR, "a[href*='pluginfile.php'], a[href*='forcedownload=1']")
for f in all_potential_links:
f_href = f.get_attribute("href")
if f_href not in submission_links: # 排除自己交的作業
file_href_set.add(f_href)
# (批次處理 file_href_set 內的下載...)
```
### URL 類型活動
- 構造一個 export?format=xlsx 的網址,這會強制 Google Docs 將該試算表轉存為 Excel 檔案下載
- 如果下載失敗(例如權限不開放),它會自動降級回「儲存連結文字檔」,確保資訊不遺漏
- 目前只加入Google Sheets,可加入Google Word
```python=
if "mod/url/view.php" in link:
driver.get(link)
# 尋找頁面跳轉的實際外部 URL
url_links = driver.find_elements(By.CSS_SELECTOR, "div.urlworkaround a[href]")
for url_link in url_links:
actual_url = url_link.get_attribute("href")
# 如果是 Google Spreadsheets,嘗試直接轉 Excel 下載
if "docs.google.com/spreadsheets" in actual_url:
match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', actual_url)
if match:
# 構造導出連結:將網址改為 /export?format=xlsx
export_url = f"https://docs.google.com/spreadsheets/d/{match.group(1)}/export?format=xlsx"
# 下載並存為 .xlsx
else:
# 一般外部連結,則建立一個「_連結.txt」檔案保存網址備查
url_file = os.path.join(course_path, f"{safe_filename}_連結.txt")
with open(url_file, 'w', encoding='utf-8') as f:
f.write(f"連結: {actual_url}\n")
```
### 避免重複下載
| 檢查對象 | 來源數據 | 代表意義 |解決的問題|
| -------- | -------- | -------- |-------- |
| existing_files(只是記錄,沒有使用) | Local Disk |過去累積的結果。程式啟動前,資料夾裡已經有的檔案。 |避免重複下載上次執行時已經抓過的檔案。|
downloaded_files |記憶體 | 本次執行的成果。程式在「現在這一秒」之前剛抓完的檔案。 |避免在同一次執行中,因為 Moodle 網頁重複列出連結而重複下載到不同課程的資料夾中。|
### 解除封鎖

- 移除Windows對下載檔案的保護
```python
subprocess.run(
["powershell", "-Command", f"Unblock-File -Path '{filepath}'"],
capture_output=True,
timeout=5
)
```
## 作業上傳
>[!Note]若已繳交作業卻取消提交,檢查未繳交作業時不會列出(已列入以繳交清單json,可手動清除)
1. 遍歷課程: 使用 all_tabs(預先開啟的課程分頁)切換視窗,讀取每個課程的名稱與網址。
1. 抓取作業連結: 利用 CSS Selector 找出所有符合 mod/assign/view.php(作業模組)的連結。
1. 過濾機制: 首先比對 submitted_assignments 本地記錄,如果該作業之前已經確認繳交過,就直接跳過(節省時間)。
1. 若無記錄,則 driver.get(act_href) 進入作業頁面。
1.判斷繳交狀態: 檢查按鈕: 是否有「繳交作業」的按鈕。
1. 檢查文字: 抓取表格中的「繳交狀態」,判斷是否包含「尚無任何作業繳交」等關鍵字。
1. 更新記錄: 若未繳交,加入 empty_assignments 清單;若已繳交,更新本地的 JSON/資料庫記錄
## 輸出範例

>[!Note]偵測到按了 Enter,就會清理程序並退出。是為了讓使用者能夠觀看終端機輸出的內容,才用input()設置一個斷點的
## 其他
### 打包成exe(刪除中間檔,加入圖標,並輸出到dist資料夾)
```
pyinstaller --clean -F -i "lin2.ico" --distpath . "new_moodle.py"; if ($?) { Remove-Item -Path "build", "new_moodle.spec" -Recurse -ErrorAction SilentlyContinue }
```
### 終止exe執行後仍占用的資源
```
taskkill /f /im chrome.exe
taskkill /f /im chromedriver.exe
```
## 待完成功能(有能力的幫忙改一下)
1. 小黑窗的字體要小一點,長度要電腦的螢幕長度從(0,0)-工作列,下方程式碼使用ANSI更改終端機的長寬,但其出發點似乎沒辦法更改
```python
sys.stdout.write("\x1b[8;38;120t")
sys.stdout.flush()
```
2. 開啟檔案總管的不同資料夾時,在同一個視窗當中開啟(不要開啟多個視窗),下方程式碼會更改焦點,我希望能夠不更改焦點即可完成,或是能搭配Windows設定做修改
```powershell
import subprocess
def open_tabs_v3(path1, path2):
# 這裡使用 PowerShell 直接發送鍵盤事件到檔案總管
ps_script = f"""
$p1 = "{path1}"
$p2 = "{path2}"
# 1. 啟動第一個路徑
Invoke-Item $p1
# 2. 透過 COM 物件找到目前的檔案總管視窗
$shell = New-Object -ComObject Shell.Application
$window = $shell.Windows() | Where-Object {{ $_.Document.Folder.Self.Path -eq $p1 -or $_.LocationName -eq "Screenshots" }} | Select-Object -First 1
if ($window) {{
# 3. 獲取視窗句柄並將其帶到最前台
$wshell = New-Object -ComObject WScript.Shell
$wshell.AppActivate($window.HWND)
# 4. 發送 Ctrl+T (開新分頁)
$wshell.SendKeys("^t")
# 5. 發送 Ctrl+L (選取網址列) -> 輸入路徑 -> Enter
$wshell.SendKeys("^l")
$wshell.SendKeys($p2)
$wshell.SendKeys("{{ENTER}}")
}} else {{
# 備案:如果抓不到視窗,改開第二個視窗
Start-Process explorer.exe $p2
}}
"""
subprocess.run(["powershell", "-Command", ps_script])
# 你的路徑
path_a = r"xxx"
path_b = r"xxx"
open_tabs_v3(path_a, path_b)
```
3. 下載的部分你也可以改成不覆蓋,也就是2個同名檔案,原先的改名,新的留著,但問題是可能會重複下載,而且大多數都是舊的需要捨棄(新的是更新版)的狀況,所以最好的辦法是比對前100個byte如果相同就覆蓋,不相同就取代這樣