第19讲:Pyppeteer 爬取实战
在上一課時(shí)我們了解了 Pyppeteer 的基本用法,確實(shí)我們可以發(fā)現(xiàn)其相比 Selenium 有很多方便之處。
本課時(shí)我們就來使用 Pyppeteer 針對(duì)之前的 Selenium 案例做一次改寫,來體會(huì)一下二者的不同之處,同時(shí)也加強(qiáng)一下對(duì) Pyppeteer 的理解和掌握情況。
1.爬取目標(biāo)
本課時(shí)我們要爬取的目標(biāo)和之前是一樣的,還是 Selenium 的那個(gè)案例,地址為:https://dynamic2.scrape.cuiqingcai.com/,如下圖所示。
這個(gè)網(wǎng)站的每個(gè)詳情頁的 URL 都是帶有加密參數(shù)的,同時(shí) Ajax 接口也都有加密參數(shù)和時(shí)效性。具體的介紹可以看下 Selenium 課時(shí)。
2.本節(jié)目標(biāo)
爬取目標(biāo)和那一節(jié)也是一樣的:
- 遍歷每一頁列表頁,然后獲取每部電影詳情頁的 URL。
- 爬取每部電影的詳情頁,然后提取其名稱、評(píng)分、類別、封面、簡(jiǎn)介等信息。
- 爬取到的數(shù)據(jù)存為 JSON 文件。
要求和之前也是一樣的,只不過我們這里的實(shí)現(xiàn)就全用 Pyppeteer 來做了。
3.準(zhǔn)備工作
在本課時(shí)開始之前,我們需要做好如下準(zhǔn)備工作:
- 安裝好 Python (最低為 Python 3.6)版本,并能成功運(yùn)行 Python 程序。
- 安裝好 Pyppeteer 并能成功運(yùn)行示例。
其他的瀏覽器、驅(qū)動(dòng)配置就不需要了,這也是相比 Selenium 更加方便的地方。
頁面分析在這里就不多介紹了,還是列表頁 + 詳情頁的結(jié)構(gòu),具體可以參考 Selenium 那一課時(shí)的內(nèi)容。
4.爬取列表頁
首先我們先做一些準(zhǔn)備工作,定義一些基礎(chǔ)的配置,包括日志定義、變量等等并引入一些必要的包,代碼如下:
import logging logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(levelname)s: %(message)s') INDEX_URL = 'https://dynamic2.scrape.cuiqingcai.com/page/{page}' TIMEOUT = 10 TOTAL_PAGE = 10 WINDOW_WIDTH, WINDOW_HEIGHT = 1366, 768 HEADLESS = False這里大多數(shù)的配置和之前是一樣的,不過這里我們額外定義了窗口的寬高信息,這里定義為 1366 x 768,你也可以隨意指定適合自己屏幕的寬高信息。另外這里定義了一個(gè)變量 HEADLESS,用來指定是否啟用 Pyppeteer 的無頭模式,如果為 False,那么啟動(dòng) Pyppeteer 的時(shí)候就會(huì)彈出一個(gè) Chromium 瀏覽器窗口。
接著我們?cè)俣x一個(gè)初始化 Pyppeteer 的方法,包括啟動(dòng) Pyppeteer,新建一個(gè)頁面選項(xiàng)卡,設(shè)置窗口大小等操作,代碼實(shí)現(xiàn)如下:
from pyppeteer import launch browser, tab = None, None async def init():global browser, tabbrowser = await launch(headless=HEADLESS,args=['--disable-infobars',f'--window-size={WINDOW_WIDTH},{WINDOW_HEIGHT}'])tab = await browser.newPage()await tab.setViewport({'width': WINDOW_WIDTH, 'height': WINDOW_HEIGHT})在這里我們先聲明了一個(gè) browser 對(duì)象,代表 Pyppeteer 所用的瀏覽器對(duì)象,tab 代表新建的頁面選項(xiàng)卡,這里把兩項(xiàng)設(shè)置為全局變量,方便其他的方法調(diào)用。
另外定義了一個(gè) init 方法,調(diào)用了 Pyppeteer 的 launch 方法,傳入了 headless 為 HEADLESS,將其設(shè)置為非無頭模式,另外還通過 args 指定了隱藏提示條并設(shè)定了窗口的寬高。
接下來我們像之前一樣,定義一個(gè)通用的爬取方法,代碼如下:
from pyppeteer.errors import TimeoutError async def scrape_page(url, selector):logging.info('scraping %s', url)try:await tab.goto(url)await tab.waitForSelector(selector, options={'timeout': TIMEOUT * 1000})except TimeoutError:logging.error('error occurred while scraping %s', url, exc_info=True)這里我們定義了一個(gè) scrape_page 方法,它接收兩個(gè)參數(shù),一個(gè)是 url,代表要爬取的鏈接,使用 goto 方法調(diào)用即可;另外一個(gè)是 selector,即要等待渲染出的節(jié)點(diǎn)對(duì)應(yīng)的 CSS 選擇器,這里我們使用 waitForSelector 方法并傳入了 selector,并通過 options 指定了最長等待時(shí)間。
這樣的話在運(yùn)行時(shí)頁面會(huì)首先訪問這個(gè) URL,然后等待某個(gè)符合 selector 的節(jié)點(diǎn)加載出來,最長等待 10 秒,如果 10 秒內(nèi)加載出來了,那就接著往下執(zhí)行,否則拋出異常,捕獲 TimeoutError 并輸出錯(cuò)誤日志。
接下來,我們就實(shí)現(xiàn)一下爬取列表頁的方法,代碼實(shí)現(xiàn)如下:
async def scrape_index(page):url = INDEX_URL.format(page=page)await scrape_page(url, '.item .name')這里我們定義了 scrape_index 方法來爬取頁面,其接受一個(gè)參數(shù) page,代表要爬取的頁碼,這里我們首先通過 INDEX_URL 構(gòu)造了列表頁的 URL,然后調(diào)用 scrape_page 方法傳入了 url 和要等待加載的選擇器。
這里的選擇器我們使用的是 .item .name,這就是列表頁中每部電影的名稱,如果這個(gè)加載出來了,那么就代表頁面加載成功了,如圖所示。
好,接下來我們可以再定義一個(gè)解析列表頁的方法,提取出每部電影的詳情頁 URL,定義如下:
這里我們調(diào)用了 querySelectorAllEval 方法,它接收兩個(gè)參數(shù),第一個(gè)參數(shù)是 selector,代表要選擇的節(jié)點(diǎn)對(duì)應(yīng)的 CSS 選擇器;第二個(gè)參數(shù)是 pageFunction,代表的是要執(zhí)行的 JavaScript 方法,這里需要傳入的是一段 JavaScript 字符串,整個(gè)方法的作用是選擇 selector 對(duì)應(yīng)的節(jié)點(diǎn),然后對(duì)這些節(jié)點(diǎn)通過 pageFunction 定義的邏輯抽取出對(duì)應(yīng)的結(jié)果并返回。
所以這里第一個(gè)參數(shù) selector 就傳入電影名稱對(duì)應(yīng)的節(jié)點(diǎn),其實(shí)是超鏈接 a 節(jié)點(diǎn)。由于提取結(jié)果有多個(gè),所以這里 JavaScript 對(duì)應(yīng)的 pageFunction 輸入?yún)?shù)就是 nodes,輸出結(jié)果是調(diào)用了 map 方法得到每個(gè) node,然后調(diào)用 node 的 href 屬性即可。這樣返回結(jié)果就是當(dāng)前列表頁的所有電影的詳情頁 URL 組成的列表了。
好,接下來我們來串聯(lián)調(diào)用一下看看,代碼實(shí)現(xiàn)如下:
import asyncio async def main():await init()try:for page in range(1, TOTAL_PAGE + 1):await scrape_index(page)detail_urls = await parse_index()logging.info('detail_urls %s', detail_urls)finally:await browser.close() if __name__ == '__main__':asyncio.get_event_loop().run_until_complete(main())這里我們定義了一個(gè) mian 方法,將前面定義的幾個(gè)方法串聯(lián)調(diào)用了一下。首先調(diào)用了 init 方法,然后循環(huán)遍歷頁碼,調(diào)用了 scrape_index 方法爬取了每一頁列表頁,接著我們調(diào)用了 parse_index 方法,從列表頁中提取出詳情頁的每個(gè) URL,然后輸出結(jié)果。
運(yùn)行結(jié)果如下:
2020-04-08 13:54:28,879 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/page/1 2020-04-08 13:54:31,411 - INFO: detail_urls ['https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx', ..., 'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5', 'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA=='] 2020-04-08 13:54:31,411 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/page/2由于內(nèi)容較多,這里省略了部分內(nèi)容。
在這里可以看到,每一次的返回結(jié)果都會(huì)是當(dāng)前列表頁提取出來的所有詳情頁 URL 組成的列表,我們下一步就可以用這些 URL 來接著爬取了。
5.爬取詳情頁
拿到詳情頁的 URL 之后,下一步就是爬取每一個(gè)詳情頁然后提取信息了,首先我們定義一個(gè)爬取詳情頁的方法,代碼如下:
async def scrape_detail(url):await scrape_page(url, 'h2')代碼非常簡(jiǎn)單,就是直接調(diào)用了 scrape_page 方法,然后傳入了要等待加載的節(jié)點(diǎn)的選擇器,這里我們就直接用了 h2 了,對(duì)應(yīng)的就是詳情頁的電影名稱,如圖所示。
如果順利運(yùn)行,那么當(dāng)前 Pyppeteer 就已經(jīng)成功加載出詳情頁了,下一步就是提取里面的信息了。
接下來我們?cè)俣x一個(gè)提取詳情信息的方法,代碼如下:
async def parse_detail():url = tab.urlname = await tab.querySelectorEval('h2', 'node => node.innerText')categories = await tab.querySelectorAllEval('.categories button span', 'nodes => nodes.map(node => node.innerText)')cover = await tab.querySelectorEval('.cover', 'node => node.src')score = await tab.querySelectorEval('.score', 'node => node.innerText')drama = await tab.querySelectorEval('.drama p', 'node => node.innerText')return {'url': url,'name': name,'categories': categories,'cover': cover,'score': score,'drama': drama}這里我們定義了一個(gè) parse_detail 方法,提取了 URL、名稱、類別、封面、分?jǐn)?shù)、簡(jiǎn)介等內(nèi)容,提取方式如下:
- URL:直接調(diào)用 tab 對(duì)象的 url 屬性即可獲取當(dāng)前頁面的 URL。
- 名稱:由于名稱只有一個(gè)節(jié)點(diǎn),所以這里我們調(diào)用了 querySelectorEval 方法來提取,而不是querySelectorAllEval,第一個(gè)參數(shù)傳入 h2,提取到了名稱對(duì)應(yīng)的節(jié)點(diǎn),然后第二個(gè)參數(shù)傳入提取的 pageFunction,調(diào)用了 node 的 innerText 屬性提取了文本值,即電影名稱。
- 類別:類別有多個(gè),所以我們這里調(diào)用了 querySelectorAllEval 方法來提取,其對(duì)應(yīng)的 CSS 選擇器為 .categories button span,可以選中多個(gè)類別節(jié)點(diǎn)。接下來還是像之前提取詳情頁 URL 一樣,pageFunction 使用 nodes 參數(shù),然后調(diào)用 map 方法提取 node 的 innerText 就得到所有類別結(jié)果了。
- 封面:同樣地,可以使用 CSS 選擇器 .cover 直接獲取封面對(duì)應(yīng)的節(jié)點(diǎn),但是由于其封面的 URL 對(duì)應(yīng)的是 src 這個(gè)屬性,所以這里提取的是 src 屬性。
- 分?jǐn)?shù):分?jǐn)?shù)對(duì)應(yīng)的 CSS 選擇器為 .score ,類似的原理,提取 node 的 innerText 即可。
- 簡(jiǎn)介:同樣可以使用 CSS 選擇器 .drama p 直接獲取簡(jiǎn)介對(duì)應(yīng)的節(jié)點(diǎn),然后調(diào)用 innerText 屬性提取文本即可。
最后我們將提取結(jié)果匯總成一個(gè)字典然后返回即可。
接下來 main 方法里面,我們?cè)黾?scrape_detail 和 parse_detail 方法的調(diào)用,main 方法改寫如下:
async def main():await init()try:for page in range(1, TOTAL_PAGE + 1):await scrape_index(page)detail_urls = await parse_index()for detail_url in detail_urls:await scrape_detail(detail_url)detail_data = await parse_detail()logging.info('data %s', detail_data)finally:await browser.close()重新看下運(yùn)行結(jié)果,運(yùn)行結(jié)果如下:
2020-04-08 14:12:39,564 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/page/1 2020-04-08 14:12:42,935 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx 2020-04-08 14:12:45,781 - INFO: data {'url': 'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx', 'name': '霸王別姬 - Farewell My Concubine', 'categories': ['劇情', '愛情'], 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'score': '9.5', 'drama': '影片借一出《霸王別姬》的京戲,牽扯出三個(gè)人之間一段隨時(shí)代風(fēng)云變幻的愛恨情仇。段小樓(張豐毅 飾)與程蝶衣(張國榮 飾)是一對(duì)打小一起長大的師兄弟,兩人一個(gè)演生,一個(gè)飾旦,一向配合天衣無縫,尤其一出《霸王別姬》,更是譽(yù)滿京城,為此,兩人約定合演一輩子《霸王別姬》。但兩人對(duì)戲劇與人生關(guān)系的理解有本質(zhì)不同,段小樓深知戲非人生,程蝶衣則是人戲不分。段小樓在認(rèn)為該成家立業(yè)之時(shí)迎娶了名妓菊仙(鞏俐 飾),致使程蝶衣認(rèn)定菊仙是可恥的第三者,使段小樓做了叛徒,自此,三人圍繞一出《霸王別姬》生出的愛恨情仇戰(zhàn)開始隨著時(shí)代風(fēng)云的變遷不斷升級(jí),終釀成悲劇。'} 2020-04-08 14:12:45,782 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy這里可以看到,首先先爬取了列表頁,然后提取出了詳情頁之后接著開始爬詳情頁,然后提取出我們想要的電影信息之后,再接著去爬下一個(gè)詳情頁。
這樣,所有的詳情頁都會(huì)被我們爬取下來啦。
6.數(shù)據(jù)存儲(chǔ)
最后,我們?cè)傧裰耙粯犹砑右粋€(gè)數(shù)據(jù)存儲(chǔ)的方法,為了方便,這里還是保存為 JSON 文本文件,實(shí)現(xiàn)如下:
import json from os import makedirs from os.path import exists RESULTS_DIR = 'results' exists(RESULTS_DIR) or makedirs(RESULTS_DIR) async def save_data(data):name = data.get('name')data_path = f'{RESULTS_DIR}/{name}.json'json.dump(data, open(data_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)這里原理和之前是完全相同的,但是由于這里我們使用的是 Pyppeteer,是異步調(diào)用,所以 save_data 方法前面需要加 async。
最后添加上 save_data 的調(diào)用,完整看下運(yùn)行效果。
7.問題排查
在運(yùn)行過程中,由于 Pyppeteer 本身實(shí)現(xiàn)的原因,可能連續(xù)運(yùn)行 20 秒之后控制臺(tái)就會(huì)出現(xiàn)如下錯(cuò)誤:
pyppeteer.errors.NetworkError: Protocol Error (Runtime.evaluate): Session closed. Most likely the page has been closed.其原因是 Pyppeteer 內(nèi)部使用了 Websocket,在 Websocket 客戶端發(fā)送 ping 信號(hào) 20 秒之后仍未收到 pong 應(yīng)答,就會(huì)中斷連接。
問題的解決方法和詳情描述見 https://github.com/miyakogi/pyppeteer/issues/178,此時(shí)我們可以通過修改 Pyppeteer 源代碼來解決這個(gè)問題,對(duì)應(yīng)的代碼修改見:https://github.com/miyakogi/pyppeteer/pull/160/files,即把 connect 方法添加 ping_interval=None, ping_timeout=None 兩個(gè)參數(shù)即可。
另外也可以復(fù)寫一下 Connection 的實(shí)現(xiàn),其解決方案同樣可以在 https://github.com/miyakogi/pyppeteer/pull/160 找到,如 patch_pyppeteer 的定義。
8.無頭模式
最后如果代碼能穩(wěn)定運(yùn)行了,我們可以將其改為無頭模式,將 HEADLESS 修改為 True 即可,這樣在運(yùn)行的時(shí)候就不會(huì)彈出瀏覽器窗口了。
9.總結(jié)
本課時(shí)我們通過實(shí)例來講解了 Pyppeteer 爬取一個(gè)完整網(wǎng)站的過程,從而對(duì) Pyppeteer 的使用有進(jìn)一步的掌握。
本節(jié)代碼:https://github.com/Python3WebSpider/ScrapeDynamic2。
總結(jié)
以上是生活随笔為你收集整理的第19讲:Pyppeteer 爬取实战的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 第20讲:代理的基本原理和用法
- 下一篇: 第11讲:Reqeusts + PyQu