2024年6月30日 星期日

2024 年第 26 周記事

時序來到第 26 周, 一年已經過半了. 今年有三位同事於六月底退休, 週二在辦公室外中庭辦了一個小型歡送會, 這也標示著我離退休已邁入七年內了 : 




鄉下家的芒果兩周前開始陸續採收, 今年結果率差, 大概只套袋了 300 顆左右, 已挑了一些送阿中與岳家, 這周如果有品相好的再挑一些送大樓警衛. 今日在廚房聽聞外面有咚咚聲, 料想是蒂熟的芒果被風吹下來, 出去搜尋一遍發現水圳裡躺了四顆, 均為樹頂無法套袋者, 高速著地通常半邊會撞爛, 馬上剖開吃可也, 若隔了一日以上, 則撞擊部分會有發酵之酸味. 明年打算到五金行買圍籬用的黑塑膠布綁在樹下承接樹上自然掉落的芒果, 避免撞擊地面損傷. 

我從四月開始放下生成式 AI 的追逐, 專心學習 Python 爬蟲, 將以往所買書籍與圖書館所借支爬蟲書看了個遍, 一一測試書中具有代表性技巧之範例, 三個月下來可說收穫頗豐, 不僅寫完念想已久的 BeautifulSoup 與 Selenium 筆記, 也完成 4 年前就想寫的市圖與母校圖書館爬蟲 (這是此番主要目的), 成功地從 PHP 爬蟲世界轉換到 Python 技術. 這場爬蟲之戰即將隨著七月來臨而結束, 因為我要發起為期兩個月的 Pandas 戰爭了. 不過仍會找時間繼續玩爬蟲. 

大帥夫妻本周從廈門返台, 約好今日來鄉下找我. 我一早即切好三盤水果等候大駕, 由於申請的長照喘息服務時數關係, 叫我別準備午餐, 暢聊到近午才返回高雄, 老友相見度過了一個愉快的早上. Line 也傳來高師大碩班同學的 20 周年同學會, 但是辦在台中太遠我就沒去了. 哇, 轉眼間已是 20 年, 當年我還在抱著二哥餵奶, 歲月不饒人, 那時才 20 出頭的碩班同學如今也已過了我那時的年紀矣.  

requests 的 encoding 問題

今天在用 requests 爬 https://books.toscrape.com 網站上的書價時遇到英鎊符號出現亂碼問題 (這個書籍網站上的書價均以英鎊計價), 我找到下面這篇文章去測試也沒用, 所以先記下來 : 





檢視原始碼只有英鎊符號 : 

<p class="price_color">£51.77</p>

但是用 requests 去抓卻在英鎊符號前面出現一個額外的亂碼 Â : 

>>> import requests     
>>> from bs4 import BeautifulSoup   
>>> url=f'https://books.toscrape.com/'     
>>> res=requests.get(url)    
>>> soup=BeautifulSoup(res.text, 'lxml')    
>>> prices=soup.select('article > div.product_price > p.price_color')      
>>> prices[0].text     
'£51.77'

檢查 encoding 發現編碼是 ISO-8859-1 (即 LATIN-1) :
 
>>> res.encoding      
'ISO-8859-1'

但檢視原始碼的 meta 標籤, charset 是 UTF-8 編碼 : 

<meta http-equiv="content-type" content="text/html; charset=UTF-8" />

好奇怪, requests 不是會根據網頁 meta 標籤內的 charset 屬性自動辨別網頁的編碼格式嗎? 為何明明是 utf-8 卻把 encoding 設為 ISO-8859-1 ?

將 encoding 改成 'utf-8' 還是一樣沒有用 :

>>> res.encoding='utf-8'   
>>> prices[0].text    
'£51.77'

好像在哪裡看過說編碼轉換成 utf-8 也不見得會完全成功, 不知道這是不是這種情形. 

Python 學習筆記 : 網頁爬蟲實戰 (十六) books.toscrape.com 的書籍分頁資料

前兩篇的分頁資料網站 (104 人力銀行, NBA 球員資料) 都是 Javascript 動態產生的, 因此必須利用 Selenium 來抓. 本篇則要來抓一個靜態的分頁網站 : books.toscrape.com 的書籍列表網站, 這應該是專門設計給爬蟲學習者的測試網站. 

本系列之前的筆記參考 : 



一. 檢視目標網頁 :  

目標網址如下 : 





此網站上總共有 1000 本的書籍資料, 以分頁的方式呈現, 每頁有 20 本書, 每四本排成一列, 每頁有五列書籍, 一頁有 20 本書, 故總共分成 50 頁, 在每頁最底下中央可以看到 Page x of 50 的分頁訊息 : 




按網頁右下角的 "next" 鈕可切換到下一個分頁, 觀察網址列, 每一個分頁的 URL 格式為 :

https://books.toscrape.com/catalogue/page-x.html   

其中 x 為分頁編號, 首頁 https://books.toscrape.com/ 顯示的其實是第一頁, 所以這 50 頁的網址為 :

https://books.toscrape.com/catalogue/page-1.html 
https://books.toscrape.com/catalogue/page-2.html 
...
https://books.toscrape.com/catalogue/page-50.html 

用 Quick Javascript Switcher 檢查發現即使關閉 Javascript 網頁內容仍在, 可見這不是 Javascript 動態生成的網頁, 而是一個靜態網頁, 只要用 requests 與 BeautifulSoup 即可擷取. 

每一本書的圖片底下有書名與價格, 本篇的任務是擷取這 50 頁共 1000 本書的書名與價格並將其存入 csv 檔中. 


二. 用 requests 與 BeautifulSoup 擷取目標網頁 :  

在 Chrome 按 F12 開啟開發人員工具視窗切到 Element 頁籤, 然後將滑鼠移到書籍圖片上, 按滑鼠右鍵點選 "檢查" 就會在 Element 頁籤中顯示該書的 HTML 碼位置 :




可見每一本書都用 li 清單項目表示, 其內容都放在 class="product_pod" 的 article 元素裡面, 書名可從其內 h3 元素下 a 元素的 title 屬性取得 : 

<a href="a-light-in-the-attic_1000/index.html" title="A Light in the Attic">A Light in the ...</a>

而價格則可從 div 下具有 class="price_color" 的 p 元素取得 : 

<p class="price_color">£51.77</p>




先匯入套件與類別 :

>>> import requests   
>>> from bs4 import BeautifulSoup    

先擷取第一頁的書 : 

>>> url='https://books.toscrape.com/catalogue/page-1.html'    
>>> res=requests.get(url)    
>>> res.encoding    
'ISO-8859-1'
>>> soup=BeautifulSoup(res.text, 'lxml')    

可見網頁使用的編碼方式是俗稱 Latin-1 的 ISO-8859-1.

先從 a 元素的 title 屬性取得書名, 這些超連結可以用 CSS 選擇器 'article > h3 > a' 透過 select() 方法來選取, 它會傳回所有被選取之書目 a 元素之 Tag 物件 :

>>> links=soup.select('article > h3 > a')   
>>> len(links)     
20

可見確實是每頁 20 本書的超連結, 呼叫 Tag 物件的 get() 方法來取得 title 屬性值 : 

>>> links[0].get('title')   
'A Light in the Attic'
>>> links[1].get('title')     
'Tipping the Velvet'
>>> links[19].get('title')   
"It's Only the Himalayas"

這個選擇器可以在 Element 頁籤中點選 a 元素後按滑鼠右鍵, 選取 "Copy/Copyselector" 取得 :




#default > div > div > div > div > section > div:nth-child(2) > ol > li:nth-child(1) > article > h3 > a

但不需要使用完整的選擇器, 只要最後面的 'article > h3 > a' 即足以定位這些超連結.

接下來是擷取書價, 同樣方式取得 p 元素選擇器 :

#default > div > div > div > div > section > div:nth-child(2) > ol > li:nth-child(1) > article > div.product_price > p.price_color

但只需要用 'article > div.product_price > p.price_color' 即可定位這些 p 元素 :

可以用串列生成式從 a 元素 Tag 物件串列中取出書名組成 20 本書的書名串列 :

>>> book_names=[link.get('title') for link in links]   
>>> book_names   
['A Light in the Attic', 'Tipping the Velvet', 'Soumission', 'Sharp Objects', 'Sapiens: A Brief History of Humankind', 'The Requiem Red', 'The Dirty Little Secrets of Getting Your Dream Job', 'The Coming Woman: A Novel Based on the Life of the Infamous Feminist, Victoria Woodhull', 'The Boys in the Boat: Nine Americans and Their Epic Quest for Gold at the 1936 Berlin Olympics', 'The Black Maria', 'Starving Hearts (Triangular Trade Trilogy, #1)', "Shakespeare's Sonnets", 'Set Me Free', "Scott Pilgrim's Precious Little Life (Scott Pilgrim #1)", 'Rip it Up and Start Again', 'Our Band Could Be Your Life: Scenes from the American Indie Underground, 1981-1991', 'Olio', 'Mesaerion: The Best Science Fiction Stories 1800-1849', 'Libertarianism for Beginners', "It's Only the Himalayas"]

接下來同樣利用選擇器從 p 元素取得書價資訊 :

>>> prices=soup.select('article > div.product_price > p.price_color')    
>>> len(p_prices)    
20   
>>> prices[0].text   
'£51.77'   
>>> prices[1].text   
'£53.74'

由於英鎊符號在轉成 utf-8 時會出現怪碼, 可用正規式取出書價的數值部分即可 :

>>> import re  
>>> re.findall(r'\d+\.\d+', prices[0].text)   
['51.77']
>>> re.findall(r'\d+\.\d+', prices[0].text)[0]   
'51.77'   
>>> float(re.findall(r'\d+\.\d+', prices[0].text)[0])   
51.77

同樣使用串列生成式從 p 元素的 Tag 物件串列中取得書價組成串列 :

>>> book_prices=[float(re.findall(r'\d+\.\d+', price.text)[0]) for price in prices]   
>>> book_prices     
[51.77, 53.74, 50.1, 47.82, 54.23, 22.65, 33.34, 17.93, 22.6, 52.15, 13.99, 20.66, 17.46, 52.29, 35.02, 57.25, 23.88, 37.59, 51.33, 45.17]

以上我們已經得到 a 元素 Tag 物件串列 links (可取得書名) 與 p 元素 Tag 物件串列 (可取得書價), 我們可以用 zip 函式將此二組 Tag 物件串列合組成 zip 物件, 以便能在迴圈中走訪 (書名, 書價) 對, 關於 zip() 用法參考 :


>>> zipped=zip(book_names, book_prices)  # 將書名與書價串列組成 zip 對
>>> type(zipped)  
<class 'zip'>

然後走訪此 zip 物件, 並使用串列生成式分別從兩組 Tag 物件串列對中取出書名與書價組成 tuple 串列 data, 這樣就能將書名與書價綁在 tuple 中了, 方便寫入 csv 檔中 : 

>>> data=[(z[0], z[1]) for z in zipped]       # 將被 zip 的書名與書價組成 tuple list      
>>> data[0]   
('A Light in the Attic', '51.77')
>>> data[1]   
('Tipping the Velvet', '53.74')

然後匯入 csv 套件直接將此 tuple list 寫入 csv 檔即可 :

>>> with open('books_page_1.csv', 'w', newline='', encoding='utf-8') as f:  
    writer=csv.writer(f)   
    writer.writerows(data)   

用 Excel 開啟 books_page_1.csv 檔 : 




可見已將第一頁的書名與書價資訊成功寫入 csv 檔了. 

接下來只要用迴圈去逐頁抓取書名與價格並寫入 csv 檔即可, 此網站雖然已知有 50 頁, 但網站可能在既有結構下增加書籍數量, 分頁數可能因此而增加, 因此為了增強爬蟲程式的強固性, 可以先抓首頁 (其實就是第一頁) 底下的分頁數來確定迴圈要跑幾圈, 這個分頁標示是放在 class="current" 的 li 元素內 :




在 Element 頁籤中按 Ctrl+F 搜尋 "current" 發現只有一個, 因此可以用 select_one() 來尋找它 :


>>> url='https://books.toscrape.com/'    
>>> res=requests.get(url)    
>>> soup=BeautifulSoup(res.text, 'lxml')    
>>> page_li=soup.select_one('.current')
>>> page_li
<li class="current">
            
                Page 1 of 50
            
            </li>
>>> page_li.text    
'\n            \n                Page 1 of 50\n            \n            '
>>> page_li.text.strip()   
'Page 1 of 50'
>>> pages=int(page_li.text.strip().split('of')[1])    
>>> pages    
50

以上測試之完整程式碼如下 :

import requests   
from bs4 import BeautifulSoup
import re
import csv
import time

url=f'https://books.toscrape.com/'
res=requests.get(url)    
soup=BeautifulSoup(res.text, 'lxml')
page_li=soup.select_one('.current')
pages=int(page_li.text.strip().split('of')[1])
csv_file='books.toscrape.com.csv'
with open(csv_file, 'w+', newline='', encoding='utf-8') as f:
    writer=csv.writer(f)
    for i in range(pages):
        print(f'擷取第 {i + 1} 頁 ... ', end='')
        url=f'https://books.toscrape.com/catalogue/page-{i+1}.html'    
        res=requests.get(url)    
        soup=BeautifulSoup(res.text, 'lxml')
        links=soup.select('article > h3 > a')
        prices=soup.select('article > div.product_price > p.price_color')
        book_names=[link.get('title') for link in links]
        reg=r'\d+\.\d+'
        book_prices=[float(re.findall(reg, price.text)[0]) for price in prices]
        zipped=zip(book_names, book_prices)
        data=[(z[0], z[1]) for z in zipped]
        writer.writerows(data)
        print(f'存檔完成!')
    time.sleep(0.5)

執行結果如下 : 

>>> %Run books_toscrape_1.py   
擷取第 1 頁 ... 存檔完成!
擷取第 2 頁 ... 存檔完成!
擷取第 3 頁 ... 存檔完成!
擷取第 4 頁 ... 存檔完成!
擷取第 5 頁 ... 存檔完成!
擷取第 6 頁 ... 存檔完成!
擷取第 7 頁 ... 存檔完成!
擷取第 8 頁 ... 存檔完成!
擷取第 9 頁 ... 存檔完成!
擷取第 10 頁 ... 存檔完成!
擷取第 11 頁 ... 存檔完成!
擷取第 12 頁 ... 存檔完成!
擷取第 13 頁 ... 存檔完成!
擷取第 14 頁 ... 存檔完成!
擷取第 15 頁 ... 存檔完成!
擷取第 16 頁 ... 存檔完成!
擷取第 17 頁 ... 存檔完成!
擷取第 18 頁 ... 存檔完成!
擷取第 19 頁 ... 存檔完成!
擷取第 20 頁 ... 存檔完成!
擷取第 21 頁 ... 存檔完成!
擷取第 22 頁 ... 存檔完成!
擷取第 23 頁 ... 存檔完成!
擷取第 24 頁 ... 存檔完成!
擷取第 25 頁 ... 存檔完成!
擷取第 26 頁 ... 存檔完成!
擷取第 27 頁 ... 存檔完成!
擷取第 28 頁 ... 存檔完成!
擷取第 29 頁 ... 存檔完成!
擷取第 30 頁 ... 存檔完成!
擷取第 31 頁 ... 存檔完成!
擷取第 32 頁 ... 存檔完成!
擷取第 33 頁 ... 存檔完成!
擷取第 34 頁 ... 存檔完成!
擷取第 35 頁 ... 存檔完成!
擷取第 36 頁 ... 存檔完成!
擷取第 37 頁 ... 存檔完成!
擷取第 38 頁 ... 存檔完成!
擷取第 39 頁 ... 存檔完成!
擷取第 40 頁 ... 存檔完成!
擷取第 41 頁 ... 存檔完成!
擷取第 42 頁 ... 存檔完成!
擷取第 43 頁 ... 存檔完成!
擷取第 44 頁 ... 存檔完成!
擷取第 45 頁 ... 存檔完成!
擷取第 46 頁 ... 存檔完成!
擷取第 47 頁 ... 存檔完成!
擷取第 48 頁 ... 存檔完成!
擷取第 49 頁 ... 存檔完成!
擷取第 50 頁 ... 存檔完成!






完整抓到 50 頁共 1000 本書的書名與書價資料, 大功告成!

2024年6月29日 星期六

Selenium 4 不再支援 PhantomJS

我在手上幾本網路爬蟲的書裏面 (都是 2018 年買的) 看到 PhantomJS 的介紹, 趁著離六月底還有兩天想來試試看這個無頭模式運作的模擬瀏覽器怎麼用, 沒想到下載好 Windows 版的 PhantomJS 放到 Python 安裝目錄後執行 PhantomJS() 卻出現如下錯誤 :

Python 3.10.11 (C:\Users\tony1\AppData\Local\Programs\Thonny\python.exe)
>>> from selenium import webdriver   
>>> driver=webdriver.PhantomJS()    
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: module 'selenium.webdriver' has no attribute 'PhantomJS'

搜尋此錯誤訊息發現, 原來由於 PhantomJS 已在 2018 年停止開發, 所以 Selenium 也已從第 4 版起停止對 PhantomJS 的支援了, 參考 :


"The native support for Opera and PhantomJS is removed in Selenium 4, as their WebDriver implementations are no longer under development. The Opera browser is based on Chromium, and users looking to test their implementation on Opera can opt for testing on the Chrome browser."

如果想使用 PhantomJS 可用虛擬環境安裝 Selenium 3 (例如 Selenium 3.3.0) 或以下之版本來跑 : 

pip install selenium==3.3.0

哈哈, 我總是做甚麼都慢半拍, 連 Selenium 的書都是買來 4 年才看, 難怪要用都過時了. 雖然如此我還是稍微查詢了一下 PhantomJS 的歷史以資緬懷先烈的偉業 : 

PhontomJS 於 2011 年由開發者 Ariya Hidayat 發布, 可在 Windows, MacOS, 與 Linux 下運作. 不過由於乏人投入後續開發, PhantomJS 已於 2018 年停止維護, 其最後版本為 v2.2.1, 參考 :


PhantomJS 原始碼寄存於 GitHub :


教學文件參考 :


如果還在用 Selenium 3 的話倒是可以試看看 PhantomJS, 但我可沒那麼多閒功夫. 

2024年6月28日 星期五

Python 學習筆記 : 網頁爬蟲實戰 (十五) NBA 球員分頁資料

六月底即將結束我的爬蟲戰爭, 趕緊把想爬的網站趁空檔來爬一爬. 今天要爬的對象是 NBA 官網的球員資料, 此範例來自下面這本書 : 

本系列之前的筆記參考 : 



一. 檢視目標網頁 :  

NBA 球員資料網址如下 : 


首次進入 NBA 網站時會彈出一個要求同意隱私權政策聲明的視窗 :




按右邊的 "I Accept" 鈕才會顯示表格全貌 :




用 Quick Javascript Switcher 檢測發現, 當關閉 Javascript 功能時表格內的資料即消失不見, 可見此網頁是透過 Javascript 生成的, 必須動用 Selenium 來抓. 

本篇測試的任務是要擷取這些分頁的資料儲存到 csv 檔案裡. 


二. 分析目標網頁使用 Selenium 擷取資料 :  

首先來處理 NBA 網頁初次連線時彈出的隱私權聲明視窗問題, 我們必須按下 "I Accept" 鈕才會使此視窗關閉, 用一個尚未接受隱私權聲明的瀏覽器, 例如 Firefox, 在開發人員工具的 Inspector  中搜尋 "I Accept" 可以找到此按鈕具有 id 屬性 : 

<button id="onetrust-accept-btn-handler">I Accept</button>

也可以用其 XPATH  //*[@id="onetrust-accept-btn-handler"] 來定位它. 

先匯入 Selenium 套件模組並載入 NBA 網頁 :

>>> from selenium import webdriver      
>>> driver=webdriver.Firefox()   
>>> driver.implicitly_wait(20)   
>>> url='https://www.nba.com/stats/players/traditional'     
>>> driver.get(url)   

接下來為初次拜訪會出現的隱私權聲明彈出視窗, 利用 id 屬性取得視窗中 "I Accept" 按鈕的 button 元素物件, 然後按下它以便關閉隱私權聲明視窗 : 

>>> accept_btn=driver.find_element('id', 'onetrust-accept-btn-handler')     
>>> accept_btn.text          # 確認是 "I Accept" 按鈕
'I Accept'
>>> accept_btn.click()      # 接受隱私權聲明關閉彈出視窗

這樣 NBA 球員資料表格就完整呈現了 : 




此網頁與上回的 104 人力銀行網頁一樣以分頁方式呈現資料, 但 104 網頁會隨滑鼠滾輪滑動而自動持續載入, NBA 的網頁則不會這樣, 必須按表格右上方的下一頁按鈕或下拉式選單選擇要顯示的分頁. 

利用開發人員視窗可以在 Element 頁籤中找到此表格具有 "Crom_table__p1iZz" 的樣式類別, 而且是唯一的 :




我們先抓目前的第一頁資料 :

>>> table=driver.find_element('class name', 'Crom_table__p1iZz')   # 取得表格
>>> trs=table.find_elements('tag name', 'tr')   # 取得全部 tr 元素物件
>>> len(trs)     
51

可見第一頁有 51 列, 接下來走訪每一列, 取出每一欄的資料. 首先來看標示欄位名稱的表格標頭  , 它位於 trs 的索引 0, 欄位名稱釋放在 th 元素裡 :




在 trs[0] 中搜尋全部 th 元素 :

>>> ths=trs[0].find_elements('tag name', 'th')     
>>> header=[th.text for th in ths]   
>>> headers   
[' ', 'PLAYER', 'TEAM', 'AGE', 'GP', 'W', 'L', 'MIN', 'PTS', 'FGM', 'FGA', 'FG%', '3PM', '3PA', '3P%', 'FTM', 'FTA', 'FT%', 'OREB', 'DREB', 'REB', 'AST', 'TOV', 'STL', 'BLK', 'PF', 'FP', 'DD2', 'TD3', '+/-', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '']   
>>> len(header)   
56

可見 th 元素在 "+/-" 欄位之後有一堆空的欄位, 這些都是 hidden 不顯示的欄位 :




可以用串列生成式刪除這些隱藏的空欄位 : 

>>> header=[th.text for th in ths if th.text != '']   
>>> header    
[' ', 'PLAYER', 'TEAM', 'AGE', 'GP', 'W', 'L', 'MIN', 'PTS', 'FGM', 'FGA', 'FG%', '3PM', '3PA', '3P%', 'FTM', 'FTA', 'FT%', 'OREB', 'DREB', 'REB', 'AST', 'TOV', 'STL', 'BLK', 'PF', 'FP', 'DD2', 'TD3', '+/-']
>>> len(header)   
30

關於串列生成式用法參考 :


接下來檢視表格內容, 它們都是放在 td 元素裡面, 檢視其中第一列, 即 trs[1] 的內容 : 

>>> tds1=trs[1].find_elements('tag name', 'td')     
>>> len(tds1)     
30
>>> [td.text for td in tds1]      
['1', 'Joel Embiid', 'PHI', '30', '6', '2', '4', '41.4', '33.0', '9.8', '22.2', '44.4', '2.2', '6.5', '33.3', '11.2', '13.0', '85.9', '3.2', '7.7', '10.8', '5.7', '4.2', '1.2', '1.5', '3.3', '58.3', '4.0', '1.0', '7.7']

可見每列的欄位數與上面表格標題一致, 都是 30 欄. 接下來可以建立一個串列來儲存第一頁的表個內容, 先建一個 rows 串列來儲存各列內容, 先把前面擷取的表格標題放在第一列 : 

>>> rows=[]     
>>> rows.append(header)    
>>> rows     
[[' ', 'PLAYER', 'TEAM', 'AGE', 'GP', 'W', 'L', 'MIN', 'PTS', 'FGM', 'FGA', 'FG%', '3PM', '3PA', '3P%', 'FTM', 'FTA', 'FT%', 'OREB', 'DREB', 'REB', 'AST', 'TOV', 'STL', 'BLK', 'PF', 'FP', 'DD2', 'TD3', '+/-']]

然後用迴圈走訪表格第二列到第一頁的最後一列, 分別將每列的各儲存格內容 td.text 放入 cols 串列中, 到列尾再將整列放入 rows 串列中 : 

>>> for tr in trs[1:]:      # 從第二列開始至表格尾端
   cols=[]      # 儲存各欄位內容之串列
   tds=tr.find_elements('tag name', 'td')      
   for td in tds:     
       cols.append(td.text)      
   rows.append(cols)     
  
>>> len(rows)  
51

可見含表格標頭共 51 列 (每頁有 50 個球員資料). 檢視列內容 : 

>>> rows[1]   
['1', 'Joel Embiid', 'PHI', '30', '6', '2', '4', '41.4', '33.0', '9.8', '22.2', '44.4', '2.2', '6.5', '33.3', '11.2', '13.0', '85.9', '3.2', '7.7', '10.8', '5.7', '4.2', '1.2', '1.5', '3.3', '58.3', '4.0', '1.0', '7.7']
>>> rows[2]   
['2', 'Jalen Brunson', 'NYK', '27', '13', '7', '6', '39.8', '32.4', '11.6', '26.2', '44.4', '2.0', '6.5', '31.0', '7.2', '9.2', '77.5', '0.6', '2.7', '3.3', '7.5', '2.7', '0.8', '0.2', '2.0', '47.6', '3.0', '0.0', '0.9']
>>> rows[50]     
['50', 'Brandon Ingram', 'NOP', '26', '4', '0', '4', '36.4', '14.3', '4.8', '13.8', '34.5', '0.5', '2.0', '25.0', '4.3', '4.8', '89.5', '0.3', '4.3', '4.5', '3.3', '2.3', '1.0', '1.3', '2.0', '29.0', '0.0', '0.0', '-11.0']

這樣就可以來將第一個分頁的資料儲存到 csv 檔了 : 

>>> import csv   
>>> csv_file='nba_players_stata_page_1.csv'   
>>> with open(csv_file, 'w', newline='') as f:    
  writer=csv.writer(f)   
  writer.writerows(rows)   

注意, 此處 open() 務必傳入 newline='', 否則用 Excel 開啟 csv 檔時會多出一個空列. 開啟  nba_players_stata_page_1.csv 檔結果如下 : 




可見已順利將第一頁存成 csv 檔了. 

上面操作的完整程式碼如下 :

from selenium import webdriver
import csv

driver=webdriver.Firefox()   
driver.implicitly_wait(20)   
url='https://www.nba.com/stats/players/traditional'     
driver.get(url)
try:
    accept_btn=driver.find_element('id', 'onetrust-accept-btn-handler')
    accept_btn.click()
except Exception as e:
    pass
table=driver.find_element('class name', 'Crom_table__p1iZz')
trs=table.find_elements('tag name', 'tr')
ths=trs[0].find_elements('tag name', 'th') 
header=[th.text for th in ths if th.text != '']
rows=[]
rows.append(header)
for tr in trs[1:]:
   cols=[]
   tds=tr.find_elements('tag name', 'td')
   for td in tds:
       cols.append(td.text)
   rows.append(cols)
csv_file='nba_players_stata_page_1.csv'
with open(csv_file, 'w', newline='') as f:
  writer=csv.writer(f)
  writer.writerows(rows)

如果要將全部分頁都各自存檔可按下一頁鈕重複上面操作. 此按鈕再表格右上方, 在開發人員工具視窗的 Element 頁籤搜尋 "Next Page Button" 即可找到, 它具有 "Pagination_button__sqGoH" 的樣式類別, 搜尋此 class 只找到兩個, 第一個是前一頁按鈕, 第二個便是此下一頁按鈕了 :




此下一頁按鈕 HTML 碼如下 :

<button type="button" title="Next Page Button" class="Pagination_button__sqGoH" data-track="click" data-type="controls" data-pos="next"> <svg xmlns="http://www.w3.org/2000/svg" height="14" width="8" viewBox="0 0 8 14" data-no-icon="right" role="presentation"> <polyline fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" points="6.5,1.6 1.5,7 6.5,12.4"></polyline> </svg> </button>

所以只要找出具有此樣式類別的按鈕, 按第二個即可 : 

>>> page_buttons=driver.find_elements('class name', 'Pagination_button__sqGoH')    
>>> len(page_buttons)     
2
>>> page_buttons[0].get_attribute('title')      
'Previous Page Button'       
>>> page_buttons[1].get_attribute('title')     # 確認第二個即是下一頁按鈕   
'Next Page Button'   
>>> page_buttons[1].click()   

這時 Seleneium 開啟的網頁果然已切換至第二頁了 : 




重複執行上面的程式碼即可將第二頁球員資料存入 csv 檔中. 但問題是按到最後一頁時下一頁的按鈕雖然還在, 但是按下去沒作用, 如果要用迴圈來做重複按下一頁按鈕的動作, 必須知道總共有幾頁資料, 這可以從前面的下拉式選單的選項數目取得. 在 Element 頁籤中搜尋可知此下拉式選單有一個 class 名稱 DropDown_select__4pIg9 :

<select name="" class="DropDown_select__4pIg9"> <option value="-1">All</option> <option value="0">1</option> <option value="1">2</option> <option value="2">3</option> <option value="3">4</option> <option value="4">5</option> </select>

但具有此樣式類別名稱的元素有 26 個 :




而分頁選單是其中最後一個 (索引 25), 因此只要取得最後一個下拉式選單的 option 數目, 即可得到頁數 (要減 1, 因第一個選項是 All) : 

>>> selects=driver.find_elements('class name', 'DropDown_select__4pIg9')   
>>> len(selects)    
26
>>> options=selects[25].find_elements('tag name', 'option')    
>>> len(options)   
6
>>> [option.text for option in options]    
['All', '1', '2', '3', '4', '5']
>>> pages=len(options) - 1    
>>> pages   
5

完整程式碼如下 : 

from selenium import webdriver
import csv
import time

driver=webdriver.Firefox()   
driver.implicitly_wait(10)   
url='https://www.nba.com/stats/players/traditional'     
driver.get(url)
try:
    accept_btn=driver.find_element('id', 'onetrust-accept-btn-handler')
    accept_btn.click()
except Exception as e:
    pass
selects=driver.find_elements('class name', 'DropDown_select__4pIg9')
options=selects[25].find_elements('tag name', 'option')
pages=len(options) - 1
print(f'總頁數: {pages}')
for page in range(pages):
    print(f'擷取第 {page + 1} 頁 ...')
    table=driver.find_element('class name', 'Crom_table__p1iZz')
    trs=table.find_elements('tag name', 'tr')
    ths=trs[0].find_elements('tag name', 'th') 
    header=[th.text for th in ths if th.text != '']
    rows=[]
    rows.append(header)
    for tr in trs[1:]:
       cols=[]
       tds=tr.find_elements('tag name', 'td')
       for td in tds:
           cols.append(td.text)
       rows.append(cols)
    csv_file=f'nba_players_stata_page_{page + 1}.csv'
    with open(csv_file, 'w', newline='') as f:
      writer=csv.writer(f)
      writer.writerows(rows)
    print(f'第 {page + 1} 頁已存檔 {csv_file}')
    class_name='Pagination_button__sqGoH'
    page_buttons=driver.find_elements('class name', class_name)
    page_buttons[1].click()
    time.sleep(1)
print('NBA 球員資料已擷取存檔完成!')
driver.close()

執行結果 :

>>> %Run nba_players_stats_2.py    
總頁數: 5
擷取第 1 頁 ...
第 1 頁已存檔 nba_players_stata_page_1.csv
擷取第 2 頁 ...
第 2 頁已存檔 nba_players_stata_page_2.csv
擷取第 3 頁 ...
第 3 頁已存檔 nba_players_stata_page_3.csv
擷取第 4 頁 ...
第 4 頁已存檔 nba_players_stata_page_4.csv
擷取第 5 頁 ...
第 5 頁已存檔 nba_players_stata_page_5.csv
NBA 球員資料已擷取存檔完成!

也可以操控下拉式選單來依序選擇分頁, 這就要用到 Select 類別了, 參考 :


from selenium import webdriver
from selenium.webdriver.support.select import Select   
import csv
import time

driver=webdriver.Firefox()   
driver.implicitly_wait(10)   
url='https://www.nba.com/stats/players/traditional'     
driver.get(url)
try:
    accept_btn=driver.find_element('id', 'onetrust-accept-btn-handler')
    accept_btn.click()
except Exception as e:
    pass
selects=driver.find_elements('class name', 'DropDown_select__4pIg9')    
page_select=Select(selects[25])   # 分頁下拉式選單
options=page_select.options        # 選項 option 物件串列
pages=len(options) - 1                  # 要扣掉第一個 All 選項
print(f'總頁數: {pages}')
for page in range(pages):
    page_select.select_by_visible_text(str(page + 1))    # 選擇分頁
    print(f'擷取第 {page + 1} 頁 ...')
    table=driver.find_element('class name', 'Crom_table__p1iZz')
    trs=table.find_elements('tag name', 'tr')
    ths=trs[0].find_elements('tag name', 'th') 
    header=[th.text for th in ths if th.text != '']
    rows=[]
    rows.append(header)
    for tr in trs[1:]:
       cols=[]
       tds=tr.find_elements('tag name', 'td')
       for td in tds:
           cols.append(td.text)
       rows.append(cols)
    csv_file=f'nba_players_stata_page_{page + 1}.csv'
    with open(csv_file, 'w', newline='') as f:
      writer=csv.writer(f)
      writer.writerows(rows)
    print(f'第 {page + 1} 頁已存檔 {csv_file}')
print('NBA 球員資料已擷取存檔完成!')
driver.close()

此處使用 selenium.webdriver.support.select.Select 類別來建立分頁下拉式選單的 Select 物件, 透過其 options 屬性值串列長度即可得到總頁數, 然後在迴圈中呼叫 Select 物件的 select_by_visible_text() 方法切換分頁. 

這個範例非常具有挑戰性, 使用了許多 Selenium 爬蟲技巧. 要注意的是, 此網站除了隱私權聲明地跳出視窗外, 還會不定時跳出邀請註冊或其他公告之跳出視窗, 但因種類不確定且不是每次都會出現, 所以就不個別處理了. 

2024年6月26日 星期三

Python 學習筆記 : 網頁爬蟲實戰 (十四) 104 人力銀行的分頁搜尋結果

爬完富時中國 A50 網頁後快馬加鞭來爬 104 人力銀行的分頁搜尋結果, 此網站特點是分頁內容會隨著滑鼠滾輪向下捲動而持續載入, 模擬此動作需要用到 Selenium 來執行 Javascript. 此範例來自下面這本書 : 

本系列之前的筆記參考 : 



一. 檢視目標網頁 :  

104 人力銀行首頁網址如下 : 


首先用 Quick Javascript Switcher 檢查發現這個網站是 Javascript 動態生成的, 必須用 Selenium 來爬. 但與之前遇到的網站不同之處有二 :
  • 一是此網站是使用 vue.js 前端技術寫的, 連輸入表單都是 Javascript 生成, 必須費一番手腳才能順利使用 Selenium 進行操控. 
  • 其二是當使用者搜尋關鍵字時不會一次列出全部結果, 而是以分頁的方式顯示部分結果, 當使用者向下滑動滾輪或拖曳卷軸時才動態載入下一頁的搜尋結果. 
我們這次的任務很簡單, 就是搜尋 Python 相關工作後抓出前五頁工作機會的資訊. 

在首頁左上方輸入框輸入 "Python" 後按右方 "搜尋" 鈕會出現一列列與 Python 相關的工作資訊, 當滑鼠往下拉時資料筆數就會不斷增加, 右方卷軸讓使用者感覺似乎沒有底部 (其實是有的), 這種網站採用的是無限捲動的分頁結構. 

從下圖的搜尋結果可知, 搜尋關於 Python 的工作機會總共有 150 頁, 總頁數是放在一個關聯式陣列 initFilter 中的 totalPage 屬性裡  :

var initFilter = {"query":{"ro":0,"jobcat":"","isnew":"","kwop":7,"keyword":"Python",...
... (略) ...
"pageNo":1,"totalPage":150,"totalCount":7100,"personalBoost":0,"firstCustNo":""};




使用 Quick Javascript Switcher 檢查發現, 當 Javascript 功能關閉時, 卷軸就不再有無限捲動功能, 說明這確實是一個利用 Javascript 動態產生內容的網頁.

檢視搜尋關鍵字 "Python" 後的網頁原始碼會發現資料內容是放在一個型態為 JSON 的 Javascript 程式碼裡面, 它是透過 Javascript 繪製成網頁內容的 (網頁原始碼其實在此任務中用不到) :

<script type="application/ld+json">[ { "@context": "https:\/\/schema.org", "@type": "BreadcrumbList", "itemListElement": [ { "@type": "ListItem", "position": 1, "name": "104 人力銀行", "item": "https:\/\/www.104.com.tw" }, { "@type": "ListItem", "position": 2, "name": "找工作", "item": "https:\/\/www.104.com.tw\/jobs\/search" }, { "@type": "ListItem", "position": 3, "name": "「Python」找工作職缺", "item": "https:\/\/www.104.com.tw\/jobs\/search\/?keyword=Python" } ] }, { "@context": "https:\/\/schema.org", "@type": "WebSite", "url": "\/\/www.104.com.tw", "name": "104 人力銀行", "potentialAction": { "@type": "SearchAction", "target": "https:\/\/www.104.com.tw\/jobs\/search\/?keyword={searchTerms}&jobsource=open", "query-input": "required name=searchTerms" } }, { "@context": "https:\/\/schema.org", "@type": "Event", "startDate": "2024\/06\/23", "eventAttendanceMode": "https:\/\/schema.org\/MixedEventAttendanceMode", "eventStatus": "EventScheduled", "name": "Python Data Scientist", "organizer": { "@type": "Organization", "name": "BigGo_樂方股份有限公司", "url": "https:\/\/www.104.com.tw\/company\/1a2x6bkf3h" }, "location": { "@type": "Place", "name": "高雄市鼓山區", "address": { "@type": "PostalAddress", "postalCode": 804 } }, "performer": { "@type": "Organization", "url": "https:\/\/www.104.com.tw\/company\/1a2x6bkf3h", "name": "BigGo_樂方股份有限公司" }, "description": "BigGo - [[[Python]]] 資料科學家\n\nBigGo是全球超過十二個國家上線的跨境的商品搜尋引擎,是目前台灣, 南美跟東南亞最大的比價服務網站。\n我們正在為全球市場招募[[[Python]]] 資料科學家\n\n我們擁有人類史上數量級最龐大的全球商品資料庫", "image": "https:\/\/static.104.com.tw\/b_profile\/cust_picture\/3773\/130000000113773\/custintroduce\/image1.jpg?v=20201003032418", "url": "https:\/\/www.104.com.tw\/job\/7op6q?jobsource=google_event", "offers": { "@type": "Offer", "url": "https:\/\/www.104.com.tw\/company\/1a2x6bkf3h", "priceCurrency": "TWD", "description": "免費", "price": "0", "availability": "http:\/\/schema.org\/InStock", "validFrom": "2024\/06\/23" } }, { "@context": "https:\/\/schema.org", "@type": "Event", "startDate": "2024\/06\/23", "eventAttendanceMode": "https:\/\/schema.org\/MixedEventAttendanceMode", "eventStatus": "EventScheduled", "name": "Python工程師", "organizer": { "@type": "Organization", "name": "云智資訊股份有限公司", "url": "https:\/\/www.104.com.tw\/company\/1a2x6bk61j" }, "location": { "@type": "Place", "name": "台北市松山區", "address": { "@type": "PostalAddress", "postalCode": 105 } }, "performer": { "@type": "Organization", "url": "https:\/\/www.104.com.tw\/company\/1a2x6bk61j", "name": "云智資訊股份有限公司" }, "description": " Vmware 有使用經驗。\n\n加分條件:\n1. 具備撰寫自動化腳本的經驗,例如:PowerShell、[[[Python]]] 或 Ansible。 \n2. 具備監控服務工具的使用經驗,例如:ELK、Grafana 或 Zabbix , Prometheus", "image": "https:\/\/static.104.com.tw\/b_profile\/cust_picture\/2039\/130000000102039\/logo.gif?v=20240413194234", "url": "https:\/\/www.104.com.tw\/job\/80bs8?jobsource=google_event", "offers": { "@type": "Offer", "url": "https:\/\/www.104.com.tw\/company\/1a2x6bk61j", "priceCurrency": "TWD", "description": "免費", "price": "0", "availability": "http:\/\/schema.org\/InStock", "validFrom": "2024\/06\/23" } }, { "@context": "https:\/\/schema.org", "@type": "Event", "startDate": "2024\/06\/23", "eventAttendanceMode": "https:\/\/schema.org\/MixedEventAttendanceMode", "eventStatus": "EventScheduled", "name": "Python軟體開發工程師", "organizer": { "@type": "Organization", "name": "先傑電腦股份有限公司", "url": "https:\/\/www.104.com.tw\/company\/ujsf2ow" }, "location": { "@type": "Place", "name": "嘉義縣中埔鄉", "address": { "@type": "PostalAddress", "postalCode": 606 } }, "performer": { "@type": "Organization", "url": "https:\/\/www.104.com.tw\/company\/ujsf2ow", "name": "先傑電腦股份有限公司" }, "description": "薪水:大學剛畢業3萬元起+個人資歷+個人化多項津貼\n其他專業及研發人員面議\n熟悉 [[[Python]]]、PostgreSQL 和 ERP作業流程 \n1.使用 [[[Python]]] 語言開發系統經驗(基於ODOO平台開發),有一年以上的經驗更佳\n2.熟悉", "image": "https:\/\/static.104.com.tw\/b_profile\/cust_picture\/0000\/66500060000\/logo.gif?v=20210709151827", "url": "https:\/\/www.104.com.tw\/job\/5duf4?jobsource=google_event", "offers": { "@type": "Offer", "url": "https:\/\/www.104.com.tw\/company\/ujsf2ow", "priceCurrency": "TWD", "description": "免費", "price": "0", "availability": "http:\/\/schema.org\/InStock", "validFrom": "2024\/06\/23" } } ] </script>

但是其顯示長度是透過偵測滑鼠捲動而動態分頁載入, 因此如果要擷取這些資料必須使用 Selenium 來控制瀏覽器, 模擬滑鼠捲動行為來連續載入不同分頁, 這需要用到 WebDriver 物件的 execute_script() 方法來執行 Javascript 的 window 物件之 scrollTo() 方法. 

接下來使用開發者工具觀察執行 Javascript 後所渲染出來的網頁碼來找出網頁元素 (例如表單控制項). 在 Chrome 按 F12 開啟開發者工具, 然後重新整理 104 官網, 切到 Element 頁嵌搜尋輸入框的預設文字開頭 "關鍵字" 可找到上方的搜尋功能是放在一個 id=js-search-form 的表單內 :

<form class="l-container main-search__form" id="js-search-form" autocomplete="off"> <div class="b-fake-input"> <div class="b-search-block--l"> <input type="text" placeholder="關鍵字(例:職稱、公司名、技能專長...)" id="keyword" data-temp="" maxlength="50"> <svg class="icon-clear b-icon--weak-gray b-icon--w16" xmlns="http://www.w3.org/2000/svg"> <use xlink:href="//www.104.com.tw/jobs/search/static/img/sprite.svg#icon-clear"> </use> </svg> </div> <span class="b-divide"></span> <div class="b-search-block--m"> <input type="text" readonly placeholder="地區" id="area-cat" data-temp=""> <svg class="b-icon--weak-gray b-icon--w16"> <use xlink:href="//www.104.com.tw/jobs/search/static/img/sprite.svg#icon-arrow-down"> </use> </svg> </div> <span class="b-divide"></span> <div class="b-search-block--m"> <input type="text" readonly placeholder="職務類別" id="job-cat" data-temp=""> <svg class="b-icon--weak-gray b-icon--w16"> <use xlink:href="//www.104.com.tw/jobs/search/static/img/sprite.svg#icon-arrow-down"> </use> </svg> </div> </div> <button class="b-btn b-btn--primary is-large gtm-main-search" type="submit"> 搜尋 </button> <div id="search-relative" class="main-search__auxiliary"> <label> <input type="checkbox" id="only-title" class="b-checkbox"> 只搜尋職務名稱 </label> <p class="js-related-keyword js-related-keyword--related related-keyword"> 相關搜尋: <span> <a href="//www.104.com.tw/jobs/search/?ro=0&amp;kwop=7&amp;expansionType=area%2Cspec%2Ccom%2Cjob%2Cwf%2Cwktm&amp;order=15&amp;asc=0&amp;page=3&amp;mode=s&amp;langFlag=0&amp;langStatus=0&amp;recommendJob=1&amp;hotJob=1&amp;remoteWork=&amp;irsTag=&amp;label=&keyword=%E8%BB%9F%E9%AB%94%E5%B7%A5%E7%A8%8B%E5%B8%AB&jobsource=keyword2Keyword" class="b-link--gray"> 軟體工程師 </a> </span> <span> <a href="//www.104.com.tw/jobs/search/?ro=0&amp;kwop=7&amp;expansionType=area%2Cspec%2Ccom%2Cjob%2Cwf%2Cwktm&amp;order=15&amp;asc=0&amp;page=3&amp;mode=s&amp;langFlag=0&amp;langStatus=0&amp;recommendJob=1&amp;hotJob=1&amp;remoteWork=&amp;irsTag=&amp;label=&keyword=C%2B%2B&jobsource=keyword2Keyword" class="b-link--gray"> C++ </a> </span> <span> <a href="//www.104.com.tw/jobs/search/?ro=0&amp;kwop=7&amp;expansionType=area%2Cspec%2Ccom%2Cjob%2Cwf%2Cwktm&amp;order=15&amp;asc=0&amp;page=3&amp;mode=s&amp;langFlag=0&amp;langStatus=0&amp;recommendJob=1&amp;hotJob=1&amp;remoteWork=&amp;irsTag=&amp;label=&keyword=Linux&jobsource=keyword2Keyword" class="b-link--gray"> Linux </a> </span> <span> <a href="//www.104.com.tw/jobs/search/?ro=0&amp;kwop=7&amp;expansionType=area%2Cspec%2Ccom%2Cjob%2Cwf%2Cwktm&amp;order=15&amp;asc=0&amp;page=3&amp;mode=s&amp;langFlag=0&amp;langStatus=0&amp;recommendJob=1&amp;hotJob=1&amp;remoteWork=&amp;irsTag=&amp;label=&keyword=%E8%B3%87%E8%A8%8A%E5%B7%A5%E7%A8%8B&jobsource=keyword2Keyword" class="b-link--gray"> 資訊工程 </a> </span> <span> <a href="//www.104.com.tw/jobs/search/?ro=0&amp;kwop=7&amp;expansionType=area%2Cspec%2Ccom%2Cjob%2Cwf%2Cwktm&amp;order=15&amp;asc=0&amp;page=3&amp;mode=s&amp;langFlag=0&amp;langStatus=0&amp;recommendJob=1&amp;hotJob=1&amp;remoteWork=&amp;irsTag=&amp;label=&keyword=Java&jobsource=keyword2Keyword" class="b-link--gray"> Java </a> </span> </p> <label> <input id="job-type" type="checkbox" class="b-checkbox gtm-highend-switch">切換高階職類 </label> </div> </form>

輸入框的 id 為 keyword, 但搜尋按鈕卻沒有足以定位它的 id, name 或單一的 class, 因此只好去 Element 頁籤搜尋 "搜尋" 找到此按鈕, 然後按滑鼠右鍵選取 "Copy/Copy XPATH" 取得其 XPATH 為 '//*[@id="js-search-form"]/button' : 




照之前的爬蟲經驗, 有了這些資訊應該就能用 Selenium 來操控瀏覽器了. 但其實不然, 因為上面透過開發者工具找出的表單控制項在 Selenium 開啟的瀏覽器中用 find_element() 或 find_elements() 尋找根本就不存在, 這很奇怪, 我也還不清楚原因是甚麼, 因為我對 Vue.js 生成網頁碼的方式並不了解, 我最後是利用 WebDriver 物件的 page_source 屬性內容才找到 Vue.js 實際渲染出來的網頁元素 (如下所示). 


二. 用 Selenium 擷取目標網頁 :  

首先載入 Selenium : 

>>> from selenium import webdriver   
>>> driver=webdriver.Firefox()   
>>> driver.implicitly_wait(20)   
>>> url='https://www.104.com.tw/'     
>>> driver.get(url)   

然後在 WebDriver 物件中尋找上面利用開發者工具的 Element 頁籤找到的網頁表單 id=js-search-form 以及其內 id=keyword 的輸入框 :

>>> search_form=driver.find_element('id', 'js-search-form')   
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="js-search-form"];
>>> keyword=driver.find_element('id', 'keyword')    
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="keyword"];

以前用 Selenium 爬過的網站沒遇到過這種情形, 用開發者工具搜尋 Element 找到的渲染後元素基本上都可以用 find_element() 方法找到才對, 但這個用 Vue.js 生成的網頁卻找不到. 於是我將 WebDriver 物件的 page_source 屬性值寫到 htm 檔來觀察 : 

>>> with open('104.htm', 'w', encoding='utf-8') as f:    
    f.write(driver.page_source)   
    
130693

用記事本開啟 104.htm 搜尋 "關鍵字" 找到如下完全不同於上面在 Element 頁籤中所看到的網頁碼, 其中所有元素的屬性名稱都是自訂的 (應該是 Vue.js 產生的), 不僅 form 元素沒有 id=js-search-form 屬性值, 關鍵字輸入框的 input 元素也沒有 id=keyword 屬性值, 難怪用 find_element() 會找不到 :





將這個表單用 "HTML Formatter" 整理後得到下面可讀性較高的格式化後之 HTML 碼 :

<form data-v-34b0b8e5="" class=""> <div data-v-34b0b8e5="" class=""> <div data-v-34b0b8e5="" class="row"> <div data-v-34b0b8e5="" class="col col-10"> <div data-v-34b0b8e5="" class="input-group input-group--search"> <i data-v-34b0b8e5="" class="jb_icon_delete input-group__input-clear d-none"></i><input data-v-34b0b8e5="" data-gtm-index="搜尋欄位-搜尋點擊" type="text" class="form-control" placeholder="關鍵字(例:工作職稱、公司名、技能專長...)" maxlength="50"> <div data-v-34b0b8e5="" class="input-group-append"><button data-v-34b0b8e5="" data-gtm-index="搜尋欄位-搜尋點擊" class="btn btn-sm btn-block d-flex justify-content-between align-items-center" type="button"><span data-v-34b0b8e5="" class="h3">地區</span><i data-v-34b0b8e5="" class="jb_icon_down"></i></button></div> <div data-v-34b0b8e5="" class="input-group-append"><button data-v-34b0b8e5="" data-gtm-index="搜尋欄位-搜尋點擊" class="btn btn-sm btn-block d-flex justify-content-between align-items-center" type="button"><span data-v-34b0b8e5="" class="h3">職務類別</span><i data-v-34b0b8e5="" class="jb_icon_down"></i></button></div> <div data-v-34b0b8e5="" class="nav__search nav__search--recent overflow-hidden" style="display: none;"> <ul data-v-34b0b8e5="" class="list-group"> <li data-v-34b0b8e5="" class="list-group-item"> <adsmart-ui-switch data-v-34b0b8e5="" ads-shape="pc" ads-board-identify="pc_c_index_sponsor_company" class="d-flex align-items-center adsmart-item pl-3 adsmart-ui--mounted"></adsmart-ui-switch> </li> </ul> </div> <div data-v-34b0b8e5="" class="nav__search nav__search--auto-complete overflow-hidden" style="display: none;"> <ul data-v-34b0b8e5="" class="list-group" style="display: none;"></ul> <ul data-v-34b0b8e5="" class="list-group" style="display: none;"> <li data-v-34b0b8e5="" class="list-group-item list-group-item--addon t4"> 公司 </li> </ul> <ul data-v-34b0b8e5="" class="list-group d-none mt-2"> <li data-v-34b0b8e5="" class="list-group-item row no-gutters"> <adsmart-ui-switch data-v-34b0b8e5="" ads-shape="pc" ads-board-identify="pc_c_index_keywords_job" ads-keyword-input="" class="d-flex align-items-center adsmart-item pl-3"></adsmart-ui-switch> </li> </ul> <ul data-v-34b0b8e5="" class="list-group"> <li data-v-34b0b8e5="" class="list-group-item list-group-item--addon t4"> 公司 </li> <li data-v-34b0b8e5="" class="list-group-item row no-gutters"><a data-v-34b0b8e5="" class="col jb-link jb-link-blue t4" href="//www.104.com.tw/company/search/?jobsource=index_s_ac"> 更多 <b data-v-34b0b8e5=""></b> 相關公司 </a></li> </ul> </div> </div> </div> <div data-v-34b0b8e5="" class="col col-2"><button data-v-34b0b8e5="" data-gtm-index="搜尋欄位-搜尋點擊" class="btn btn-secondary btn-block btn-lg" type="submit"> 搜尋 </button></div> </div> </div> </form>

觀察此 HTML 碼可知 "關鍵字" 輸入框有一個樣式類別名稱 "form-control", 且搜尋整個渲染後的網頁只有這個元素使用此樣式類別, 因此可以直接用 find)element() 找到此輸入框物件, 然後呼叫其 send_keys() 方法傳入 "Python" :

>>> keyword=driver.find_element('class name', 'form-control')    
>>> keyword.send_keys('Python') 

接下來只要模擬按下搜尋鈕的動作即可, 但此 submit 按鈕卻沒有 id, name 或足以單獨識別它的 class 屬性, 因此嘗試用搜尋所有標籤名為 button 的元素, 然後逐一利用 text 屬性與 get_attribute() 方法找出其索引 : 

>>> buttons=driver.find_elements('tag name', 'button')    # 搜尋全部按鈕元素物件

檢查索引 0 與 1 都不是 "搜尋" 鈕 :

>>> buttons[0].text     
'地區'
>>> buttons[1].text    
'職務類別'
>>> buttons[2].text      # 是 "搜尋" 鈕
'搜尋'

檢查 type 與 data-gtm-index 屬性值 : 

>>> buttons[2].get_attribute('type')    
'submit'
>>> buttons[2].get_attribute('data-gtm-index')    
'搜尋欄位-搜尋點擊'

這樣即確認 "搜尋" 鈕索引為 2 無誤, 呼叫其 click() 方法進行搜尋 :

>>> buttons[2].click()    

這時 Selenium 開啟的 Firefox 瀏覽器就會列出搜尋結果, 從上方的下拉式選單可知總共有 150 頁, 現在顯示的是第一頁 : 




檢視開發人員工具的 Element 頁籤可知這些工作機會項目是放在一個 id="js-job-content" 的 div 元素內, 每個項目就是一個 article 元素 : 




本爬蟲的目標是抓出上圖紅框中的資訊, 亦即 article 的 data-job-name 與 data-cust-name 的屬性值, 還有裡面指向此項目詳細說明頁面的超連結. 

上圖中我們在 Element 頁籤內搜尋 "<article" 發現共有 24 個 article 元素, 用 find_elements() 方法去找也是 24 個, 這表示每頁會顯示 24 個項目 : 

>>> articles=driver.find_elements('tag name', 'article')   
>>> len(articles)   
24

這樣就可以取得各個項目的目標資料了, 第一個項目如下 :

>>> articles[0].get_attribute('data-job-name')    
'[暑期實習]量化交易策略研發實習生(※請至官網下載申請表並投遞至專屬信箱※)'
>>> articles[0].get_attribute('data-cust-name')      
'統一綜合證券股份有限公司'
>>> link=articles[0].find_element('tag name', 'a')   
>>> link.get_attribute('href')     
'https://www.104.com.tw/job/8daij?jobsource=hotjob_chr'   

下面是第二個項目 : 

>>> articles[1].get_attribute('data-job-name')    
'【招聘赴日東京】軟件開發工程師(待遇優厚,辦理簽證)'
>>> articles[1].get_attribute('data-cust-name')   
'株式会社ブライトスター'
>>> link=articles[1].find_element('tag name', 'a')    
>>> link.get_attribute('href')    
'https://www.104.com.tw/job/8d8yl?jobsource=hotjob_chr'

此頁最後一個項目 :

>>> articles[23].get_attribute('data-cust-name')   
'磐弈有限公司'   
>>> link=articles[23].find_element('tag name', 'a')     
>>> link.get_attribute('href')    
'https://www.104.com.tw/job/8bek0?jobsource=hotjob_chr'   

當滑鼠滾輪往下移動時會不斷載入後續頁的項目 (往下疊, 前面的不會消失), 這相當於是執行 Javascript 的 window.scrollTo() 方法將滑鼠滾輪移動整個視窗高度的效果, 可以用 WebDriver 的 execute_script() 方法來執行 Javascript 移動滑鼠的程式碼 :

>>> js='window.scrollTo(0, document.body.scrollHeight)'    # 移動滑鼠滾輪視窗高度
>>> driver.execute_script(js)       

這時再去搜尋 article 元素會發現它大約增加一倍 : 

>>> articles=driver.find_elements('tag name', 'article')     
>>> len(articles)      
46

預期是 48 個, 但只有 46 個, 且其中有兩個 article 內容是空的, 檢查前面第一頁的資料還是一樣 : 

>>> articles[0].get_attribute('data-job-name')     
'[暑期實習]量化交易策略研發實習生(※請至官網下載申請表並投遞至專屬信箱※)'
>>> articles[1].get_attribute('data-job-name')    
'【招聘赴日東京】軟件開發工程師(待遇優厚,辦理簽證)'
>>> articles[23].get_attribute('data-job-name')    
'助理工程師'

下面是呼叫 execute_script() 執行模擬移動滑鼠滾輪後載入的第二頁內容, 但最後兩個 article (索引 44, 45) 內容卻是空的 :

>>> articles[24].get_attribute('data-job-name')   
'(Sr.) Automation Test Engineer(Python) - Tainan/Hybrid'
>>> articles[25].get_attribute('data-job-name')     
'Python  軟體工程師 (台北市 內湖)'
... (略) ...
>>> articles[42].get_attribute('data-job-name')    
'Python 軟體工程師'
>>> articles[43].get_attribute('data-job-name')      
'Python 軟體工程師'
>>> articles[44].get_attribute('data-job-name')       # 傳回 None
>>> articles[45].get_attribute('data-job-name')       # 傳回 None

現在已載入兩頁資料, 所以我們只要再呼叫三次 execute_script() 就可以載入 5 頁的資料了 : 

>>> for i in range(3):    
  js='window.scrollTo(0, document.body.scrollHeight)'   
  driver.execute_script(js)    
  
重新搜尋 article 元素物件 : 

>>> articles=driver.find_elements('tag name', 'article')     
>>> len(articles)  
68

預期 5 頁應該有 100 多筆, 卻只有 68 筆. 這可能是迴圈跑太快, 執行 Javascript 滑動滑鼠滾輪需要亦點時間之故, 因此可能需要在迴圈底用 time.sleep(0.5) 休息個半秒鐘. 檢視前面幾頁資料都沒變 , 可見滑動滑鼠滾輪是載入更多分頁往下疊, 不是覆蓋 : 

>>> articles[0].get_attribute('data-job-name')        
'[暑期實習]量化交易策略研發實習生(※請至官網下載申請表並投遞至專屬信箱※)'
>>> articles[1].get_attribute('data-job-name')   
'【招聘赴日東京】軟件開發工程師(待遇優厚,辦理簽證)'
>>> articles[23].get_attribute('data-job-name')     
'助理工程師'
>>> articles[25].get_attribute('data-job-name')      
'Python  軟體工程師 (台北市 內湖)'
>>> articles[43].get_attribute('data-job-name')      
'Python 軟體工程師'
>>> articles[65].get_attribute('data-job-name')       
'AJ6-後端/全端軟體開發工程師(Python)'

最後兩筆是空的 : 

>>> articles[66].get_attribute('data-job-name')       # 傳回 None 
>>> articles[67].get_attribute('data-job-name')       # 傳回 None 

如果要抓出搜尋結果的總頁數, 可以從網頁上方 class 屬性值為 "gtm-paging-top" 的下拉式選單的取得 (具有此屬性值者只有一個) : 




 先用 class='gtm-paging-top' 搜尋 select 元素之物件, 然後再搜尋其下的第一個 option 物件 :

>>> page_select=driver.find_element('class name', 'gtm-paging-top')    
>>> page_option=page_select.find_element('tag name', 'option')    
>>> page_option.text    
'第 1 / 150 頁'

這樣便取得包含總頁數之字串了, 接著將 text 屬性值以 '/' 為界拆分取第二個索引, 然後用正規式將其中的數字 150 抓出來 :

>>> pages_str=page_option.text.split('/')[1]     
>>> pages_str   
' 150 頁'
>>> pages=re.findall(r'\d+', pages_str)[0]   
>>> pages   
'150'

如果要載入全部分頁的資料, 可以用 pages 當迴圈終點 :

>>> import time   
>>> for i in range((int(pages)):       
  js='window.scrollTo(0, document.body.scrollHeight)'       
  driver.execute_script(js)     
  time.sleep(0.5)     

以上測試之完整程式碼如下 :

from selenium import webdriver
import re
import time

driver=webdriver.Firefox()   
driver.implicitly_wait(20)   
url='https://www.104.com.tw/'     
driver.get(url)
keyword=driver.find_element('class name', 'form-control')
keyword.send_keys('Python')
buttons=driver.find_elements('tag name', 'button')
buttons[2].click()
for i in range(5):    
  js='window.scrollTo(0, document.body.scrollHeight)'   
  driver.execute_script(js)
  time.sleep(0.5)
articles=driver.find_elements('tag name', 'article')
print(len(articles))
for i in range(len(articles)):
    job_name=articles[i].get_attribute('data-job-name')
    cust_name=articles[i].get_attribute('data-cust-name')
    if job_name:
        print(f'工作名稱: {job_name}')
        print(f'徵求公司: {cust_name}')
        link=articles[i].find_element('tag name', 'a')
        url=link.get_attribute('href')
        print(f'詳細資訊: {url}\n')
    else:
        print(f'索引 {i} 無資料\n')
driver.close()

執行結果 :

>>> %Run 104_job_search_1.py   
132
工作名稱: [暑期實習]量化交易策略研發實習生(※請至官網下載申請表並投遞至專屬信箱※)
徵求公司: 統一綜合證券股份有限公司
詳細資訊: https://www.104.com.tw/job/8daij?jobsource=hotjob_chr

工作名稱: 廣告行銷數據分析師 Marketing Data Analyst
徵求公司: 雅德思行銷顧問有限公司
詳細資訊: https://www.104.com.tw/job/8d6bk?jobsource=hotjob_chr

工作名稱: Python Data Scientist
徵求公司: BigGo_樂方股份有限公司
詳細資訊: https://www.104.com.tw/job/7op6q?jobsource=index_s

工作名稱: Python工程師
徵求公司: 創昇資訊有限公司
詳細資訊: https://www.104.com.tw/job/82dtp?jobsource=index_s

工作名稱: Python軟體工程師
徵求公司: 奇力速工業股份有限公司
詳細資訊: https://www.104.com.tw/job/6vayl?jobsource=index_s

工作名稱: Python工程師
徵求公司: 云智資訊股份有限公司
詳細資訊: https://www.104.com.tw/job/80bs8?jobsource=index_s

工作名稱: Python軟體開發工程師
徵求公司: 先傑電腦股份有限公司
詳細資訊: https://www.104.com.tw/job/5duf4?jobsource=index_s
... (略) ...
工作名稱: 機器學習工程師 AI / ML Engineer (高雄)
徵求公司: 資旅軟體開發有限公司
詳細資訊: https://www.104.com.tw/job/7jaus?jobsource=index_s

工作名稱: 【雲端系統部】Python工程師  (中和)
徵求公司: 昇銳電子股份有限公司
詳細資訊: https://www.104.com.tw/job/6zloe?jobsource=index_s

工作名稱: T-【2024軟體技術人才招募】Python 工程師_人才招募
徵求公司: 緯創軟體股份有限公司
詳細資訊: https://www.104.com.tw/job/69za2?jobsource=index_s

工作名稱: Python 程式設計語言講師 - 基隆、大台北地區
徵求公司: 聯成電腦有限公司(聯成電腦/聯成外語)
詳細資訊: https://www.104.com.tw/job/5pxb7?jobsource=index_s

索引 130 無資料

索引 131 無資料

如果要取得全部搜尋到的資料, 就把迴圈的終點設為從下拉式選單中取得的總頁數 :

from selenium import webdriver
import re
import time

driver=webdriver.Firefox()   
driver.implicitly_wait(20)   
url='https://www.104.com.tw/'     
driver.get(url)
keyword=driver.find_element('class name', 'form-control')
keyword.send_keys('Python')
buttons=driver.find_elements('tag name', 'button')
buttons[2].click()
page_select=driver.find_element('class name', 'gtm-paging-top')
page_option=page_select.find_element('tag name', 'option')
pages_str=page_option.text.split('/')[1]
pages=int(re.findall(r'\d+', pages_str)[0])
print(f'總頁數: {pages}')
for i in range(pages):    
  js='window.scrollTo(0, document.body.scrollHeight)'   
  driver.execute_script(js)
  time.sleep(0.5)
articles=driver.find_elements('tag name', 'article')
print(f'總筆數: {len(articles)}')
for i in range(len(articles)):
    job_name=articles[i].get_attribute('data-job-name')
    cust_name=articles[i].get_attribute('data-cust-name')
    if job_name:
        print(f'{i}. {job_name}')
        print(f'❖ {cust_name}')
        link=articles[i].find_element('tag name', 'a')
        url=link.get_attribute('href')
        print(f'▶ {url}\n')
    else:
        print(f'索引 {i} 無資料\n')
driver.close()

但是不知何因 (我猜是瀏覽器記憶體限制), 模擬滑動滑鼠滾輪的動作到第 15 頁時都會停住, 所以只載入 311 筆資料 :




>>> %Run 104_job_search_2.py   
總頁數: 150
總筆數: 311

... (略) ...
306. PHP、Perl 程式設計工程師(新竹)
❖ 桓基科技股份有限公司
▶ https://www.104.com.tw/job/4o46l?jobsource=index_s

307. Business Insight Analyst - 數據分析專員
❖ 美商泰優股份有限公司台灣分公司
▶ https://www.104.com.tw/job/6a28o?jobsource=index_s

308. 軟體工程師/系統維護工程師
❖ 偉福科技有限公司
▶ https://www.104.com.tw/job/8acdh?jobsource=index_s

309. 後端工程師
❖ 神瑞人工智慧股份有限公司
▶ https://www.104.com.tw/job/827of?jobsource=index_s

索引 310 無資料

索引 311 無資料

參考 :