# [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)` 回到上一頁(下圖) ![螢幕擷取畫面 2026-01-13 105426](https://hackmd.io/_uploads/S1wXB47HZx.png) ### 下載內嵌圖片 只抓取 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 ``` ### 下載討論區文字內容 ![螢幕擷取畫面 2026-01-13 114840](https://hackmd.io/_uploads/SJ5AerXSWg.png) 將標題與內容整合,儲存為 .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 ![螢幕擷取畫面 2026-01-13 105426](https://hackmd.io/_uploads/S1wXB47HZx.png) - 點擊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) # 解壓成功則刪除 ``` ### 下載「作業說明」 ![螢幕擷取畫面 2026-01-13 173936](https://hackmd.io/_uploads/H15bH5XBbg.png) 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 網頁重複列出連結而重複下載到不同課程的資料夾中。| ### 解除封鎖 ![螢幕擷取畫面 2026-01-14 094536](https://hackmd.io/_uploads/B1zGQ0VHbl.png) - 移除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/資料庫記錄 ## 輸出範例 ![螢幕擷取畫面 2026-01-14 153636](https://hackmd.io/_uploads/H1lqV4RESWg.png) >[!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如果相同就覆蓋,不相同就取代這樣