【Python爬虫】从零开始爬取Sci-Hub上的论文(串行爬取)
【Python爬蟲】從零開始爬取Sci-Hub上的論文(串行爬取)
- 維護日志
- 項目簡介
- 步驟與實踐
- STEP1 獲取目標內容的列表
- STEP2 利用開發者工具進行網頁調研
- 2.1 提取文章鏈接和分頁鏈接的特征
- 2.2 提取文章 DOI 所在元素的特征
- 2.3 探索 sci-hub 上 pdf 資源的打開方式
- 2.3.1 梳理基本流程
- 2.3.2 查看 robots.txt
- 2.3.3 提取pdf資源的元素特征
- STEP3 開始寫代碼,就從"下載"入手吧
- 3.1 conda虛擬環境搭建、加載和使用
- 3.1.1 創建虛擬環境
- 3.1.2 配置下載包的鏡像源
- 3.1.3 給虛擬環境安裝所需模塊
- 3.1.4 加載虛擬環境到我們的 IDE —— PyCharm
- 3.2 下載一個pdf文獻的代碼實現
- 3.2.1 用 requests 根據 DOI 獲取文獻對應網頁的文本
- 3.2.2 提取 html 中的 pdf 資源鏈接 (3種方式)
- 正則表達式 (Regular Expression)
- Beautiful Soup
- Lxml ?\star?
- 在瀏覽器控制臺中使用選擇器
- 在代碼中使用選擇器
- 3.2.3 根據獲得的 pdf 鏈接執行下載
- 3.2.4 看看哪些地方仍需改進
- 磁盤文件合法命名
- 獲得文件所需的名稱 —— 文獻標題
- robots.txt 解析以及下載時間間隔設置
- 添加一個簡單的緩存類 Cache
- 3.3 回到 Web of Science,提取搜索頁的 DOI 列表
- 3.3.1 方法一:修改 doc 屬性值快速構建 url,然后從中爬取 doi
- 3.3.2 方法二:結合 Web of Science 導出功能的"零封禁幾率"方法
- STEP4 組裝起來,形成終極接口:sci_spider()
- 4.1 流程梳理 ?\star?
- 4.2 組裝起來,給它取個名字,就叫 "sci_spider" 好了
- 4.3 對第一次運行結果的分析與問題處理 ?\star?
- 4.3.1 運行結果分析
- 4.3.2 問題1:標題抓取為空 —— 用 DOI 作為名字
- 4.3.3 問題2:HTTP協議頭重復 —— 添加判斷去重
- 4.3.4 最后的調試
- 爬蟲感想
- 資源 (見GitHub)
- References
WARNING:專業人士請速速撤離,否則將浪費至少半小時的時間!
2020-12-06 陰
是時候上手鴿了半個月的小項目了。。。
筆者為了偷懶,準備邊做爬蟲邊記錄過程,畢竟做完后還要花很多時間回顧,這里就直接省去回顧的過程,每完成一個步驟便做好相應的步驟記錄。當你讀到這段文字時,筆者尚未開始進行這個項目的實踐,但也并非完全"從零開始" —— 在此之前筆者學了一些爬蟲相關的先修知識,并作了實踐環境和工具的一些配置,具體如下:
★\bigstar★ 先修知識 (每一項筆者都附上了教程鏈接,如有需要可以點擊查看)
-
Web基礎 (html5,只需看得懂網頁層級結構和標簽屬性含義即可) →\rightarrow→ 點擊查看 html 基礎教程
-
正則表達式 (能看懂并構建簡單的表達式) →\rightarrow→ 點擊查看正則表達式基礎教程
-
Python3 基礎語法 (能編寫函數和類,懂列表、字典等經典數據結構的操作) →\rightarrow→ 點擊回顧 Python3 基礎知識
-
爬蟲的基本操作 (對應文獻1的前三章 [1])
-
網頁調研 (了解html文本結構,分析元素特征,查看網頁的 robots.txt 獲取爬取的基本要求)
-
數據抓取 (獲取html文本中的目標內容,如 url,列表項內容等,常用的方式有:① 正則表達式;② BeautifulSoup;③ Lxml 以及CSS選擇器和Xpath選擇器)
-
下載緩存 (這個項目可能用不到 )
★\bigstar★ 工具與環境
-
語言版本:Python3.6 (這個安裝就不用我多說了吧,網上搜一下就有)
-
IDE:PyCharm Community Edition 2019.3.3 (同上)
-
虛擬環境管理:Anaconda3 →\rightarrow→ 點此查看 ① PyCharm加載和使用虛擬環境;②conda環境管理
注:如果不清楚虛擬環境的意義,可以自行搜索了解,簡單來說就是為爬蟲用到的庫單獨創建一個容器,與爬蟲相關的模塊都放在這里,以防止各模塊版本錯亂,導致用 Python3 寫的其余項目因版本問題出現錯誤。
好了,有了以上準備,我們就可以真正地"從零開始"我們的爬蟲之旅了!
維護日志
爬蟲代碼是很不穩定的,當爬取目標發生變化時,原來的代碼很可能就不能運行了,因此讀者應盡可能地掌握爬蟲實踐的方法,這樣便可以通過修改代碼來應對新的變化。
2021-09-24 爬蟲代碼ver2.1
上傳了version2.1版本,修復了兩個錯誤:
error - list index out of range: 這是由于pdf文獻的url在網頁中的標簽層次不一致所致,有些為buttons/button,還有些為buttons/ul/li/a,而以往代碼只考慮了其中一種層次,解決方法是兩種情況全部考慮(不排除還有其他的層次),篩選列表不為空的即可。
error - Invalid URL: 調用requests.get(url, …)時,有些url中存在轉義字符’’,比如https:\/\/sci.bban.top\/pdf\/10.1145\/3132847.3132909.pdf?download=true,導致get()方法誤認為’‘需要轉義,從而額外添加轉義字符’’,使得上述url變為https:\\/\\/sci.bban.top\\/pdf\\/10.1145\\/3132847.3132909.pdf?download=true,因而出錯。解決方法是,在GET請求前去掉url中的轉義字符’’。
2021-08-05 爬蟲代碼ver2.0
代碼可以通過私信向筆者索要,或者關注筆者的github自行獲取,請使用最新版。
項目簡介
開始構建爬蟲之前,首先明確我們的需求:根據搜索文本從sci-hub上爬取論文(pdf格式),具體方式是:
在 Web-of-Science 網站上輸入搜索文本,執行搜索后獲取每一項搜索結果的 DOI (Digital Object Identifier,數字對象唯一標識)
根據得到的 DOI,依次在 Sci-Hub 上查找到論文資源并下載
當然,上述描述是可行的 (手動操作),但現在我們要通過爬蟲來實現"無UI交互",即通過代碼而不是人為瀏覽網頁的方式達到目的。為此,需要細化以上過程的描述,這個過程筆者是邊想邊做的,當實現所有的描述時,任務也就完成了。
步驟與實踐
先附上項目涉及的兩個主要網站:
-
Web of Science: http://apps.webofknowledge.com
-
Sci-Hub: https://sci-hub.do/ (這個經常被封禁,如果用不了可以上網搜其他的域名)
補充:筆者使用的瀏覽器是Google Chrome,其實用哪個瀏覽器都行,只要能正常訪問網頁,且瀏覽器有開發者工具(按F12調出)即可。
STEP1 獲取目標內容的列表
由用戶手動在 Web-of-Science 上搜索某一內容(這里采用主題模式,搜索 “unity3D”),獲得相應的列表,如下圖所示:
注意:
搜索結果后面顯示了查詢結果個數,我們可以根據它來決定下載文件的數量
有一些是專利發表,不包含 DOI (如上圖第2項),不過只要我們的爬蟲不會錯誤地訪問并下載它們就行
STEP2 利用開發者工具進行網頁調研
對產生列表的網頁進行html文本特征分析,發現列表每一項中并沒有列出 DOI,這意味著兩件事:
-
我們需要存儲列表網頁中的目標url (用一個列表結構存儲),由于搜索結果可能不止一頁,因此,我們保存的 url 中應既包括文章鏈接,也包括分頁鏈接 (其他鏈接就不考慮了)。
-
我們需要訪問每一個 url 列表中的鏈接,找到 DOI 所在的標簽,分析其所在的嵌套層級以及它本身的特征 (標簽的特征屬性,如 ”href“、“id”、“class” 等),并把所有 DOI 也保存到一個列表中,甚至可以將它存到磁盤 (例如以 .csv 或 .txt 格式保存)。
先吃個飯,然后分析一波目標url的特征以及DOI所在標簽的特征。。。
2.1 提取文章鏈接和分頁鏈接的特征
那么,文章鏈接和分頁鏈接怎么找呢?打開開發者工具(F12),使用 “選擇元素” 功能 (Shift + Ctrl + C)選定頁面中的目標元素,幫助我們縮小查找范圍,甚至直接定位鏈接。下面分別給出了針對文章鏈接和分頁鏈接的選擇元素圖例,以及各自的查找到的鏈接情況:
下面是找到的目標區域鏈接,依次為 文章鏈接 和 分頁鏈接:
<a class="smallV110 snowplow-full-record" href="/full_record.do?product=UA&search_mode=GeneralSearch&qid=2&SID=7ERiKiVBBTB6qFk3KUC&page=1&doc=1" tabindex="0" oncontextmenu="javascript:return IsAllowedRightClick(this);" hasautosubmit="true"> <value lang_id="">Implementing Virtual Reality technology for safety training in the precast/prestressed concrete industry</value> </a> <a class="paginationNext snowplow-navigation-nextpage-bottom" href="http://apps.webofknowledge.com/summary.do?product=UA&parentProduct=UA&search_mode=GeneralSearch&parentQid=&qid=2&SID=7ERiKiVBBTB6qFk3KUC&&update_back2search_link_param=yes&page=2" alt="下一頁" title="下一頁" aria-label="下一頁" tabindex="0" oncontextmenu="javascript:return IsAllowedRightClick(this);" hasautosubmit="true"> <i></i> </a>為了提取特征,筆者額外找了幾個不同文獻和不同分頁的鏈接,發現了一些共同點,它們正是我們做爬蟲的重要依據:
每一個文章鏈接都在 <a> 標簽的 href 屬性中,且標簽 class 相同,均為 "smallV110 snowplow-full-record" ;分頁鏈接的 class="paginationNext snowplow-navigation-nextpage-bottom"
觀察 url 本身,發現 文章鏈接 的 url 是個相對url,/full_record.do?... 打頭,如果手動點擊進入的話其鏈接為 http://apps.webofknowledge.com/full_record.do?...;而 分頁鏈接 的 url 是個絕對url,鏈接為 http://apps.webofknowledge.com/summary.do?product...,我們需要的是絕對url,這說明我們對于 文章鏈接 還需要額外處理,加上 http://apps.webofknowledge.com 這一部分。
由于 文章鏈接 和 分頁鏈接 的處理模式存在區別,而它們的 class 屬性不同,因此我們可以創建兩個隊列,利用 class 屬性區分兩種鏈接,分別加入到相應的隊列中,并且優先處理文章鏈接所在的隊列,這意味著當且僅當文章鏈接的隊列為空時,才會處理分頁鏈接的隊列。然而,還有更簡單且高效的方法,不用一個個請求網頁的內容,沒有IP封禁的危險 (見 3.3 節)。
2.2 提取文章 DOI 所在元素的特征
下面我們把目光聚焦到 文章鏈接 打開的頁面,找尋文章的DOI,其操作和上一節的一樣。僅陳列頁面布局和目標元素內容:
<div class="block-record-info"> <p class="FR_field"> ... </p> </div> <div class="block-record-info-source-values"> ... <p class="FR_field"> <span class="FR_label">文獻號:</span> <value>103286</value> </p> <p class="FR_field"> <span class="FR_label">DOI:</span> <value>10.1016/j.apergo.2020.103286</value> </p> ... </div>通過對其父級標簽 <p class="FR_field"> 比對發現,它并不能區分DOI和其余同級內容。另外,在其所屬的 <div> 標簽的同級標簽中,也存在一些標簽含有 <p class="FR_field">,這意味著我們可能需要分層篩選,但我們也可以簡單粗暴直接匹配到 DOI,方法和細節稍后提及。
如此一來,我們便可以輕松獲得多個文獻的 DOI,并把它們存放于一個列表中,甚至寫入磁盤文件。
2.3 探索 sci-hub 上 pdf 資源的打開方式
2.3.1 梳理基本流程
假定我們已經有一個十分有效的方式得到合法的 DOI 列表 (然而現在我們并沒有),那么下一步就是將 sci-hub 的手動下載模式轉換為程序控制的批量作業。為此我們得先摸清手動操作是怎么個流程:
wtf,校園網連接沒有響應,訪問不了 sci-hub 資源鏈接。。。那我先看會書,等回宿舍后再接著做吧
2.3.2 查看 robots.txt
可能是今天網絡問題 (事實上當時 sci-hub 在維護中),使得我無法通過輸入 DOI 來訪問 sci-hub 相應資源,不過沒關系,我們先看看 sci-hub 的 robots.txt 文件 (主頁域名后面加個 /robots.txt 即可):
User-agent: Twitterbot Disallow:User-agent: * Allow: /lang/ Allow: /alexandra Allow: /$ Disallow: /這個文件是一個非強制性的協議,每個良好的網絡公民都應該遵守這些限制,否則有可能遭到封禁。該文件的解讀方法 →\rightarrow→ 爬蟲之robots.txt
從該文件的內容可知,第一部分:該網頁允許用 Twitterbot 作為用戶代理爬取該網站上的任何東西 (更正,經筆者測試,如果用 Twitterbot 作為用戶代理抓取網頁,那么似乎會重定向,讓你抓取其他網頁的內容,所以不要用 Twitterbot ) ;第二部分:對所有用戶代理都有效,但只允許了部分網站的爬取,那個 /$ 按照解釋應該為允許任何以 / 結尾的 url (更正,似乎也不太對,只要不是 sci-hub 主頁,即便沒有以 / 結尾也可以抓取) 。
以上解釋和實際的解析有些出入,筆者拿學習爬蟲時做的 robots.txt 解析代碼試了一下,發現 Twitterbot 的爬取不受任何限制, 其余用戶代理除了主頁不能爬取外,其余都能爬。不過,誰來 sci-hub 是為了爬取主頁呀? 所以說,基本沒有限制,我們可以"橫著走"。
(應該說只要用戶代理不是 Twitterbot,那我們便可以"橫著走"。)
2020-12-07 雪
2.3.3 提取pdf資源的元素特征
我們隨便找個文獻的DOI,請求相應的鏈接。試了好久,終于成功進去了!!(薛定諤的網絡連接)
審查網頁中 save 按鈕,定位其標簽在超文本中的位置:
<div id="buttons"> <ul> <!-- <li id = "reload"><a target = "_blank" href = "//sci-hub.do/reload/10.1016/j.apergo.2020.103286">? reload</a></li>--> <li><a href="#" onclick="location.href='//sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true'">? save</a></li> </ul> </div>我們注意 <a> 標簽中的 onclick 屬性內容,將 location.href 單引號中的內容擰出來,很容易發現:如果在前面加個 http(s):,那么就構成了絕對 url,直接把加了 http(s): 的上述內容 cv 到域名搜索框中,看看能否彈出下載頁面 —— 實踐告訴我們,輸入后直接鏈接到了 pdf 資源,并自動開始了下載!(來自未來:這里筆者當時并沒有考慮 HTTP協議頭可能重復的問題)
太好了!這樣我們的一輪流程就走完了,剩下的都是重復的迭代過程,就交給計算機處理了。因此,現在我們可以順著以上的思路寫代碼了。
STEP3 開始寫代碼,就從"下載"入手吧
再次提一下,我們最終的期望是實現批量 pdf 下載,可見"下載"是我們的關鍵步驟(之一)。當我們的流水線到達下載這一步時,我們已經獲得了一個 DOI 列表,我們需要根據這個 DOI 列表中的每一項來依次執行下載任務,2.3.3 節已經分析了 pdf 資源鏈接的構成,即 HTTP協議 + 冒號’:’ + location.href 的內容,最后一部分是從鏈接{ 'http(s)://sci-hub.do' + '/' + DOI }對應的 html 中抓取的。思路有了,我們如果能實現一個文件的下載,那么批量下載無非就是加了個循環 (暫不涉及多線程)。但在此之前,為了符合"從零開始",我還是從搭建環境開始簡單地演示一遍吧。
3.1 conda虛擬環境搭建、加載和使用
可以跳過這部分內容 —— 虛擬環境不是必要的。但當你有多個項目正在開發時,虛擬環境可以有效管理安裝包的版本,避免混亂。
現在筆者手上已有 PyCharm (ver 2019.3.3) 以及 Anaconda 3,使用的 Python3 版本為 3.6,OS 為 Win10。
我想你應該已經把 Python 語言以及 Anaconda 的環境變量配置好了,如果不確定的話可以查看 Path 變量中是否包括如下路徑:
-
Python3 安裝根目錄下的Scripts文件夾所在路徑,e.g: D:\…\Scripts
-
Anaconda 安裝根目錄下Scripts文件夾所在路徑,e.g: E:\…\Anaconda\Scripts
3.1.1 創建虛擬環境
首先找到 Anaconda Prompt (筆者直接在開始菜單 'A’字母中的 Anaconda文件夾下找到),打開后如下:
輸入 conda create -n <虛擬環境名> python=<版本號> 創建環境 (筆者用的Python的版本號為3.6)。比如下圖中,筆者創建了名為 Python36_WebCrawler 的虛擬環境,過程中等待片刻后需要按個確認按鈕:
至此,虛擬環境安裝完畢,我們本次項目要用到的所有包都放在這個環境中。
3.1.2 配置下載包的鏡像源
一般使用的是清華鏡像源,里面有大量的包,我們可以按需從鏡像源獲取模塊。
如果你已經配置過,那么不論之后是否創建新的虛擬環境,你都無需再配置了。
鍵入如下指令添加鏡像源 (注意:HTTP協議不要用 https,否則有可能出現問題;另外,你的鍵入順序就是搜索包的路徑順序,所以可以預先調研一波 —— 你所需要的包大多數在哪個目錄下,那么就把該目錄放在最前面)
conda config --add channels http://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/win-64/ conda config --add channels http://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ conda config --add channels http://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/msys2/win-64/ conda config --set show_channel_urls yes如果你用的是其他操作系統,可能需要更改一波鏡像源路徑,具體可以去清華鏡像站查看目錄層級,比如修改 /win-64。
這樣我們就可以在 C:\Users\Administrator 下找到一個 .condarc,用記事本打開可以查看我們的設置:
下載源設置好了,下面我們可以安裝包了。
3.1.3 給虛擬環境安裝所需模塊
我們來安裝待會要用到的 requests 模塊。
-
回到 Anaconda Prompt,輸入 conda activate <你的虛擬環境名> 激活環境,光標跳轉到 (Python36_WebCrawler) C:\Users\Administrator>
-
輸入 conda install requests,找到下載源和相應版本后 (可能還附帶該模塊的依賴項),出現確認事件 Proceed ([y]/n)?,回車默認 yes,畫面如下:
至此,requests 模塊安裝完畢,我們鍵入 conda deactivate 退出 Python36_WebCrawler 環境。
之后所有的包都是這么個安裝流程:激活環境 →\rightarrow→ 安裝包 →\rightarrow→ 退出環境。
3.1.4 加載虛擬環境到我們的 IDE —— PyCharm
如果讀者用的是其他 IDE,沒有關系,網上有很多介紹如何將虛擬環境加載到 IDE 的資料。筆者這里也不再贅述,僅給個PyCharm使用虛擬環境的鏈接 —— PyCharm加載和使用虛擬環境。
3.2 下載一個pdf文獻的代碼實現
如果您是從頭開始讀到這里,那么,在這里說聲,幸苦了!表面上看,我們的征途才剛剛開始,但筆者認為,現在已經快結束了 (可能有些夸張 (來自未來:太TM夸張了),但至少已經完成一半的工作了 (來自未來:這倒是沒錯))。后續基本上是代碼的實踐與細節,每一個步驟,筆者會先上代碼,然后簡要分析一下:①代碼干了什么;②為什么這么寫?
至于里面用到的一些模塊函數的用法,我確定您是知道的 (至少有兩種辦法知道,其中一個方法是查看模塊源文件;另一個是上網搜索解決方案)。因此筆者就偷個懶,省去一些功夫去解析它們 —— 我們這里更強調對模塊函數的使用,了解接口的功能和用法就好了 (在PyCharm中,只需要把光標放在相應函數上,Ctrl + 鼠標左鍵 即可查看相應的函數定義)。
3.2.1 用 requests 根據 DOI 獲取文獻對應網頁的文本
下面我們直接給出 獲取特定 DOI 網頁超文本 的 Python3 實現 (download.py):
import requestsdef download(doi, user_agent="sheng", proxies=None, num_retries=2, start_url='sci-hub.do'):headers = {'User-Agent': user_agent}url = 'https://{}/{}'.format(start_url, doi)print('Downloading: ', url)try:resp = requests.get(url, headers=headers, proxies=proxies, verify=False)html = resp.textif resp.status_code >= 400:print('Download error: ', resp.text)html = Noneif num_retries and 500 <= resp.status_code < 600:return download(url, user_agent, proxies, num_retries-1)except requests.exceptions.RequestException as e:print('Download error', e)return Nonereturn html# 簡單的測試 if __name__ == '__main__':doi = '10.1016/j.apergo.2020.103286'print(download(doi))print('Done.')運行結果:
Downloading: https://sci-hub.do/10.1016/j.apergo.2020.103286 # 拼接好的 DOI 文獻鏈接 D:\...\connectionpool.py:852: InsecureRequestWarning: ... # 一個警告,可以忽略,這與我們設置vertify=False有關 <html> # 我們要的超文本 ... </html> Done.對代碼的說明:
簡析一下函數原型:def download(doi, user_agent="sheng", proxies=None, num_retries=2, start_url='sci-hub.do'):
doi →\rightarrow→ 文獻的DOI號
user_agent →\rightarrow→ 用戶代理,根據sci-hub主頁的 robots.txt 確定,默認值只要不是 Twitterbot 就行
proxies →\rightarrow→ 代理,默認置為 None
num_retries →\rightarrow→ 下載的重試次數,僅當請求狀態碼為 5xx 時執行重試,像 4xx 之類的就沒必要重試了
start_url →\rightarrow→ 主頁域名,絕對路徑中固定的一部分,單獨擰出來,默認為 sci-hub.do (其他能用的也行)
因此簡要概括這個函數的功能:根據傳入的 DOI 號,抓取所在文獻的 html 文本,以供后續提取 .pdf 文件鏈接。
請求訪問網站的關鍵函數: requests.get(...),必須傳入 url,其余都是可選的;為了讓我們的爬蟲請求網頁時能更加可靠 (更像個人,而不是機器),我們傳入額外參數 user-agent 和 proxies,前者構成請求時發送給瀏覽器的頭信息 headers,后者設置代理支持,默認設置為 None。
requests.get() 參數列表中的 verify=False 不能少,否則很有可能出現 SSL: CERTIFICATE_VERIFY_FAILED 的錯誤:
Downloading: https://sci-hub.do/10.1016/j.apergo.2020.103286 Download error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:748) Done.3.2.2 提取 html 中的 pdf 資源鏈接 (3種方式)
下載搞定了,意味著我們得到了文獻網頁的 html,它將作為提取pdf資源鏈接的輸入,也可稱作"原材料"。
在 2.3.3 節我們已經大致分析了 pdf 資源鏈接的特征,我們的任務是從中找到一種模式 (pattern),它能較好地幫我們從冗長的 html 文本中篩選并匹配到目標元素。
為了方便說明筆者后續的提取方法,把 2.3.3 節目標所在位置的文本再陳列一遍 (原汁原味,沒有任何修改,之前的為了美觀,把一些空行、空格去掉了):
<div id="buttons"><ul><!-- <li id = "reload"><a target = "_blank" href = "//sci-hub.do/reload/10.1016/j.apergo.2020.103286">? reload</a></li>--><li><a href="#" onclick="location.href='//sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true'">? save</a></li></ul></div>對比一下抓取到的 html 文本的相同部分:
<div id = "buttons"><ul><!-- <li id = "reload"><a target = "_blank" href = "//sci-hub.do/reload/10.1016/j.apergo.2020.103286">? reload</a></li>--><li><a href = # onclick = "location.href='//sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true'">? save</a></li></ul></div>我們由以上對比,我們可以發現兩者的一些共性和區別:
-
各個標簽之間有可能有空行、空格,也可能沒有
-
前者的 href="#" 和后者的 href = #,其中有兩處不同:① = 前后是否有空格; ② # 是否有引號。關于 2,這是因為 href="#" 有特殊意義。
這些細微的點如果不注意,將令我們編寫正則表達式時吃盡苦頭 (反正筆者已經"吃飽了")。
正則表達式 (Regular Expression)
如果你能熟練運用正則表達式,相信你會有 “萬物皆可RegEx” 的信念,并且會更傾向于用它解決這類匹配問題 —— 即使它仍存在很多局限。如果想要了解它,筆者在文章開頭就已經給出了教程,這里再附個有關 Python中使用正則表達式 的鏈接。
像筆者這種笨比,編寫的正則表達式又臭又長,且經常需要修改很多遍才能弄好,不過好在有正則表達式在線測試網站,大大提高了正則表達式的編寫效率。
我們可以根據其中一篇文獻的下載鏈接來編寫正則表達式,然后隨機找幾篇文獻(不同領域)的下載鏈接對正則表達式驗證,如果測試的那些沒問題,我們就直接用吧。
下面給出 用正則表達式匹配超文本以獲取匹配內容列表 的 Python3 實現 (scraping_using_regex.py):
import redef get_links(pattern, html):regex = re.compile(pattern, re.IGNORECASE)return regex.findall(html)if __name__ == '__main__':from download import downloaddois = ['10.1016/j.apergo.2020.103286', # VR'10.1016/j.jallcom.2020.156728', # SOFC'10.3964/j.issn.1000-0593(2020)05-1356-06'] # 飛行器# 筆者又臭又長的正則表達式pattern = '''<div id\s*=\s*"buttons">\s*<ul>\s*.*?\s*<li.*?\s*<li><a[^>]+href\s*=\s*#\s*onclick\s*=\s*"location.href='(.*?)'">'''links = []for doi in dois:html = download(doi)# print(html)links.append(get_links(pattern, html))for link in links:print(link)運行結果:
Downloading: https://sci-hub.do/10.1016/j.apergo.2020.103286 D:\Anaconda\...\connectionpool.py:852: InsecureRequestWarning:... Downloading: https://sci-hub.do/10.1016/j.jallcom.2020.156728 D:\Anaconda\...\connectionpool.py:852: InsecureRequestWarning:... Downloading: https://sci-hub.do/10.3964/j.issn.1000-0593(2020)05-1356-06 D:\Anaconda\...\connectionpool.py:852: InsecureRequestWarning:... # 我們獲取的 pdf 鏈接結果 ['//sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true'] ['//sci-hub.do/downloads/2020-11-23/ac/tsvinkinberg2021.pdf?download=true'] []對代碼與結果的說明:
def compile() 將一個正則表達式轉變為 pattern 對象, def findall(pattern, string, flags=0): 進行非重疊匹配,返回模式中小括號 () 里的內容組成的列表,如果有多個小括號,則以元組 (tuple) 形式返回所有結果組成的列表;在結果列表中會包含空結果。下面是 標準模塊 re.py 中的源碼:
def compile(pattern, flags=0):"Compile a regular expression pattern, returning a pattern object."return _compile(pattern, flags)def findall(pattern, string, flags=0):"""Return a list of all non-overlapping matches in the string.If one or more capturing groups are present in the pattern, returna list of groups; this will be a list of tuples if the patternhas more than one group.Empty matches are included in the result."""return _compile(pattern, flags).findall(string)筆者編寫這個正則表達式花了大約 1 個小時,中間遇到了很多坑,其中就包括了 3.2.2 節開頭提到的點 (主要問題還是筆者的 RegEx 水平爛),最后寫了個能用但很繁瑣的表達式 <div id\s*=\s*"buttons">\s*<ul>\s*.*?\s*<li.*?\s*<li><a[^>]+href\s*=\s*#\s*onclick\s*=\s*"location.href='(.*?)'">,正好由此簡單談一談我對正則表達式的看法:
-
靈活性高,可以隨時調整;匹配速度比較快
-
對使用者的要求高,容易寫錯,復雜匹配的正則表達式晦澀難懂
-
十分脆弱,可能網頁標簽格式不對、或者網頁稍微變一下,整個正則表達式就失效了
話雖如此,但還是有必要掌握基本的構造模式滴。
筆者選擇了3個不同主題文獻的 DOI 組成了一個列表,從運行結果可見,飛行器那篇文獻匹配為空,筆者特意手動打開鏈接,發現不能正常訪問,這說明我們得到的 pdf 資源列表中可能存在不可用的項,需要篩選那些為空的項 (在后續方法中會考慮這點);另外,能訪問的鏈接也未必能正常下載,需要額外考慮這些情況。
輸出中有警告信息,暫且不要管
2020-12-08 晴
Beautiful Soup
Beautiful Soup,不知道設計者為何取了個這個名字,但使用起來確實感覺 very beautiful 就是了。語法簡單,而且可以對 html 網頁的語法問題進行修復,唯一的瑕疵就是有些慢,不過能理解,畢竟是用純 Python 編寫的 (正則 和 Lxml 是 C 語言寫的)。
需要安裝兩個模塊 (別忘了先進入虛擬環境) :
conda install beautifulsoup4
conda install html5lib
直接上手,下面給出 用 BeautifulSoup 匹配超文本以獲取匹配內容列表 的 Python3 實現 (scraping_using_bs4.py):
from bs4 import BeautifulSoup import redef get_link_using_bs4(html, parser='html5lib'):# parse the HTMLtry:soup = BeautifulSoup(html, parser)except:print('parser not available, now use the default parser "html.parser"...')parser = 'html.parser'soup = BeautifulSoup(html, parser)soup.prettify()div = soup.find('div', attrs={'id': 'buttons'})if div:a = div.find('a', attrs={'href': '#'})if a:a = a.attrs['onclick']return re.findall(r"location.href\s*=\s*'(.*?)'", a)[0]return Noneif __name__ == '__main__':from download import downloaddois = ['10.1016/j.apergo.2020.103286', # VR'10.1016/j.jallcom.2020.156728', # SOFC'10.3964/j.issn.1000-0593(2020)05-1356-06'] # 飛行器links = []for doi in dois:html = download(doi)# print(html)link = get_link_using_bs4(html)if link:links.append(link)for link in links:print(link)運行結果 (省去警告):
Downloading: https://sci-hub.do/10.1016/j.apergo.2020.103286 Downloading: https://sci-hub.do/10.1016/j.jallcom.2020.156728 Downloading: https://sci-hub.do/10.3964/j.issn.1000-0593(2020)05-1356-06 //sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true //sci-hub.do/downloads/2020-11-23/ac/tsvinkinberg2021.pdf?download=true對代碼的說明:
上面的代碼采用了 BeautifulSoup + 正則表達式,主要做了如下幾件事:
-
soup.prettify() →\rightarrow→ 修復 html 文本存在的問題,規范格式
-
soup.find() →\rightarrow→ 根據所給標簽屬性定位元素位置,下面是定義 (element.py):
def find(self, name=None, attrs={}, recursive=True, text=None,**kwargs):"""Return only the first child of this Tag matching the givencriteria."""r = Nonel = self.find_all(name, attrs, recursive, text, 1, **kwargs)if l:r = l[0]return r
雖然沒有直接導入 html5lib,但不代表不需要 (除非你只需要用 html.parser)
通過條件判斷是否為 None,初步篩選了用不了的鏈接
Lxml ?\star?
和 BeautifulSoup 一樣,使用 Lxml 模塊的第一步也是將有可能不合法的 html 解析為統一格式;同樣地,Lxml 也可以正確解析屬性兩側缺失的引號,并閉合標簽,不過該模塊沒有額外添加 <html> 和 <body> 標簽,這些都不是標準 XML 的要求,因此對于 Lxml 來說,插入它們是不必要的。[1]
本小節我們將使用選擇器來定位元素,包括 CSS選擇器 和 XPath選擇器。
對于它們的相關說明,讀者可以通過以下參考鏈接查閱:
- CSS選擇器
- Xpath選擇器
筆者這里列出幾個基本但常用的選擇器表達式,見下表:
表 3.1 常用選擇器表達式的比較 [1]| 選擇所有鏈接 | ‘//a’ | ‘a’ |
| 選擇類名為"main"的 div 元素 | ‘//div[@class=“main”]’ | ‘div.main’ |
| 選擇ID為"list"的 ul 元素 | ‘//ul[@id=“list”]’ | ‘ul#list’ |
| 從所有段落中選擇文本 | ‘//p/text()’ | None |
| 選擇所有類名中包含’test’的 div 元素 | ‘//div[contains(@class, ‘test’)]’ | None |
| 選擇所有包含鏈接或列表的 div 元素 | ‘//div[a|ul]’ | ‘div a, div ul’ |
| 選擇 href 屬性中包含 google.com 的鏈接 | ‘//a[contains(@href, “google.com”)]’ | None |
在后續實踐中可能用到的CSS選擇器的補充:
- 選擇任意標簽 →\rightarrow→ *
- 選擇標簽 <a> 的孩子中標簽名為 <span> 的所有標簽 →\rightarrow→ a > span
- 選擇標簽 <a> 的后代 (包括孩子) 中標簽名為 <span> 的所有標簽 →\rightarrow→ a span
- 選擇標簽 <a> 中的屬性 title 值為 “Home” 的所有標簽 →\rightarrow→ a[title=Home]
我們可以在開發者工具的控制臺 (Console) 中使用這些選擇器來預先調試我們的選擇器字符串,看看能否正常篩選。對于CSS選擇器,其在瀏覽器中的選擇器使用格式為 $('選擇器表達式');對于XPath選擇器,其在瀏覽器中的選擇器使用格式為 $x('選擇器表達式')
在瀏覽器控制臺中使用選擇器
下面先演示CSS選擇器在開發者工具中如何使用:
隨意選一片文獻,比如筆者選擇了 https://sci-hub.do/10.1016/j.apergo.2020.103286
F12 打開開發者工具,切換到 Console 控制臺
鍵入我們的CSS選擇表達式:$('div#buttons a') 或者 $('div#buttons > ul > li > a'),發現正確選擇了我們的目標標簽 <a>:
對于XPath選擇器,我們甚至可以直接找到 onclick 屬性內容,只需輸入$x('//div[@id="buttons"]/ul/li/a')[0].attributes[1].textContent:
在代碼中使用選擇器
在瀏覽器中我們已經見識了選擇器的方便與強大,下面看看代碼中怎么使用它們。
安裝 lxml 和 cssselect 模塊:conda install cssselect 、conda install lxml
下面給出 用 lxml 以及一種選擇器匹配超文本以獲取匹配內容列表 的 Python3 實現 (scraping_using_lxml.py):
from lxml.html import fromstringdef get_link_cssselect(html):try:tree = fromstring(html)a = tree.cssselect('div#buttons > ul > li > a')[0] # 區別onclick = a.get('onclick')return onclickexcept Exception as e:print('error occurred: ', e)return Nonedef get_link_xpath(html):try:tree = fromstring(html)a = tree.xpath('//div[@id="buttons"]/ul/li/a')[0] # 區別onclick = a.get('onclick')return onclickexcept Exception as e:print('error occurred: ', e)return Nonedef test_selector(selector):from download import downloaddois = ['10.1016/j.apergo.2020.103286', # VR'10.1016/j.jallcom.2020.156728', # SOFC'10.3964/j.issn.1000-0593(2020)05-1356-06'] # 飛行器links = []for doi in dois:html = download(doi)# print(html)link = selector(html)if link:links.append(link)for link in links:print(link)print('Done')if __name__ == '__main__':print('test_cssselect(): ')test_selector(get_link_cssselect)print('test_xpath(): ')test_selector(get_link_xpath)運行結果 (省去警告):
test_cssselect(): Downloading: https://sci-hub.do/10.1016/j.apergo.2020.103286 Downloading: https://sci-hub.do/10.1016/j.jallcom.2020.156728 Downloading: https://sci-hub.do/10.3964/j.issn.1000-0593(2020)05-1356-06 error occurred: Document is empty location.href='//sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true' location.href='//sci-hub.do/downloads/2020-11-23/ac/tsvinkinberg2021.pdf?download=true' Done test_xpath(): Downloading: https://sci-hub.do/10.1016/j.apergo.2020.103286 Downloading: https://sci-hub.do/10.1016/j.jallcom.2020.156728 Downloading: https://sci-hub.do/10.3964/j.issn.1000-0593(2020)05-1356-06 error occurred: list index out of range location.href='//sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true' location.href='//sci-hub.do/downloads/2020-11-23/ac/tsvinkinberg2021.pdf?download=true' Done對代碼和結果的說明:
兩種選擇器最終都達到了目標要求,而且兩種方法的代碼只有一行的差別,即 tree.cssselect() 以及 tree.xpath() 調用的那一行
lxml 模塊函數 fromstring(...) 用于統一 html 格式,并返回一個 document 或 element 對象
cssselect() 和 xpath() 均返回一個匹配列表,鑒于 sci-hub 目標元素所在層級中只存在一個匹配,所以我們取列表中的 0 位置元素
get(attr) 方法用于獲取特定標簽屬性的內容,實踐中我們要找的是 <a> 標簽的 onclick 屬性
注意異常的處理 (第3個 doi 獲取的網頁是不合法的)
這里還沒有篩選完,我們可以沿用 BeautifulSoup 小節的方式,采用正則表達式作為最后的篩選工作:return re.findall(r"location.href\s*=\s*'(.*?)'", onclick)[0],讀者可以自行補上 (需要導入 re 模塊)
2020-12-09 晴
3.2.3 根據獲得的 pdf 鏈接執行下載
下載鏈接得到了,我們通過組裝鏈接構成絕對 url,然后使用 request 模塊的 get() 方法請求資源,最后將獲得的內容以二進制流的操作寫入文件即可。
下面給出 給定 DOI 執行一次相應文獻的 pdf 下載 的 Python3 實現 (download.py):
import requestsdef download_pdf(url, user_agent="sheng", proxies=None, num_retries=2):headers = {'User-Agent': user_agent}url = 'https:{}'.format(url) # 改動1print('Downloading: ', url)try:resp = requests.get(url, headers=headers, proxies=proxies, verify=False)if resp.status_code >= 400:print('Download error: ', resp.status_code)if num_retries and 500 <= resp.status_code < 600:return download(url, user_agent, proxies, num_retries-1)# ok, let's write it to filewith open('file.pdf', 'wb') as fp: # 改動2,注意 'wb' 而不是 'w'fp.write(resp.content)except requests.exceptions.RequestException as e:print('Download error', e) # 簡單的測試 if __name__ == '__main__':doi = '10.1016/j.apergo.2020.103286'html = download(doi) # 獲取文獻資源網頁的 html 文本from scraping_using_lxml import get_link_xpathurl = get_link_xpath(html) # 提取下載鏈接download_pdf(url) # 執行下載print('Done.')運行結果:
對代碼的說明:
注意 url 的拼接格式
注意是用 'wb',即二進制流寫入的方式打開的文件
測試代碼中使用的是 XPath 選擇器提取下載鏈接,這里換成 3.2.2 節中任意一種方式都可行 (可能有一些細節需要變化)
這里的文件名暫時用一個比較固定的 file.pdf,如果執行批量下載,我們肯定得找一個能概括此文獻的文本作為其名稱,以方便我們后續查閱,容易想到文獻標題是一個不錯的名稱候選,因此我們在下載之前,還需要抓取文獻的標題 (或其他能唯一標識文獻的文本),3.2.4 節會介紹改進方法
3.2.4 看看哪些地方仍需改進
磁盤文件合法命名
對于文件名稱,我們有以下要求:
-
不重復 (在目錄中唯一地標識一個文件)
-
有意義 (便于查找)
綜上,我們選擇文獻標題作為文件名 (不要抬杠)。那么問題來了,能不能不做任何轉換就拿來用?比如其中一篇文獻的題名為:
Implementing Virtual Reality technology for safety training in the precast/ prestressed concrete industry. Applied Ergonomics, 90, 103286.
我們注意到,此標題中含有 /,而文件名稱不能含有 \ / ? * < > | : " (共9個字符),所以不能直接拿來作為文件名稱。我們需要作一些轉換使得以標題作為文件名合法,且限制長度在操作系統要求的最大值之內 (WIn10 是 260字節,但經筆者測試實際最大命名長度低于此值。保險起見,我們默認設置長度為 128 字節)。代碼非常簡單:
import redef get_valid_filename(filename, name_len=128):# return re.sub(r'[/\\|*<>?":]', '_', filename)[:name_len] # '\n' 來自未來:這些轉義符號會算作合法,但會出錯return re.sub(r'[^0-9A-Za-z\-,._;]', '_', filename)[:name_len] # 這個可以if __name__ == '__main__':title = r'''Implementing Virtual Reality technology for safety training in the precast/ prestressed concrete industry. Applied Ergonomics, 90, 103286.'''print(get_valid_filename(title))print(get_valid_filename(title, 40))運行結果:
Implementing Virtual Reality technology for safety training in the precast_ prestressed concrete industry. Applied Ergonomics, 90, 103286. Implementing Virtual Reality technology從結果可見,/ 被替換成了下劃線 _,其余合法字符沒變;另外,第二行輸出被限制了長度。
2020-12-10 晴
獲得文件所需的名稱 —— 文獻標題
現在瀏覽器中用"選擇元素" (Ctrl + Shift + C) 對標題定位一波,然后使用XPath選擇器篩選出標題文本 (CSS選擇器類似):$x('//div[@id="citation"]/i/text()')[0],然后筆者寫好代碼套用該選擇器時,發現某些文獻標題并沒有 <i> 標簽,所以需要加判斷 (比如零長度等等)
修改我們在 3.2.2 節寫的 scraping_using_lxml.py,之前我們只返回了一個 onclick 中的下載鏈接,我們現在要額外返回一個標題名稱,使用字典是一個不錯的選擇:
from lxml.html import fromstring import redef get_link_cssselect(html):try:tree = fromstring(html)a = tree.cssselect('div#buttons > ul > li > a')[0]onclick = a.get('onclick')title = tree.cssselect('div#menu > div#citation > i') # 1if len(title) == 0: # 2title = tree.cssselect('div#menu > div#citation')title = title[0].text # 3onclick = re.findall(r"location.href\s*=\s*'(.*?)'", onclick)[0]return {'title': title, 'onclick': onclick} # 4except Exception as e:print('error occurred: ', e)return Nonedef get_link_xpath(html):try:tree = fromstring(html)a = tree.xpath('//div[@id="butdtons"]/ul/li/a')[0]onclick = a.get('onclick')onclick = re.findall(r"location.href\s*=\s*'(.*?)'", onclick)[0]title = tree.xpath('//div[@id="citation"]/i/text()') # 1if len(title) == 0: # 2title = tree.xpath('//div[@id="citation"]/text()')return {'title': title[0], 'onclick': onclick} # 3except Exception as e:print('error occurred: ', e)return None對代碼的說明:
筆者后續實踐中發現,不是所有文獻標題都有 <i> 標簽,因此需要加個判斷,即當匹配列表為空時,匹配其父級內容作為標題
注意改動的部分 (后面加注了數字)
正則表達式和 Beautiful Soup也采用相似的修改方式,只需要多抓取一個標題名稱就行,筆者在此僅給出主要修改處的代碼,其余保持不變:
# Beautiful Soup (scraping_using_bs4.py) def get_link_using_bs4(html, parser='html5lib'):try:...except:...# 修改的部分try:div = soup.find('div', attrs={'id': 'buttons'})if div:a = div.find('a', attrs={'href': '#'})if a:a = a.attrs['onclick']onclick = re.findall(r"location.href\s*=\s*'(.*?)'", a)[0]div = soup.find('div', attrs={'id': 'citation'})title = div.find('i')if title:title = title.get_text()else:title = div.get_text()return {'title': title, 'onclick': onclick}except Exception as e:print('error occured: ', e)return None # --------------------------------------------------------------------------- # regular expression (scraping_using_regex.py) def get_links(pattern, html):...def get_link_using_regex(html):pattern_onclick = '''<div id\s*=\s*"buttons">\s*<ul>\s*.*?\s*<li.*?\s*<li><a[^>]+href\s*=\s*#\s*onclick\s*=\s*"location.href='(.*?)'">'''pattern_title = '''<div id\s*=\s*"citation"[^>]+>(.*?)</div>'''try:title = get_links(pattern_title, html)[0]if title:i = get_links('<i>(.*?)</i>', title)title = i[0] if i else titleonclick = get_links(pattern_onclick, html)[0]if onclick and title:return {'title': title, 'onclick': onclick}elif onclick:print('No title, now use onclick string to be the title.')return {'title': onclick, 'onclick': onclick}except Exception as e:print('error occurred: ', e)return Noneif __name__ == '__main__':from download import downloadfrom download import doi_parserdois = ['10.1016/j.apergo.2020.103286', # VR'10.1016/j.jallcom.2020.156728', # SOFC'10.3964/j.issn.1000-0593(2020)05-1356-06'] # 飛行器links = []for doi in dois:url = doi_parser(doi, 'sci-hub.do')html = download(url, headers={'User-Agent': 'sheng'})link = get_link_using_regex(html)if link:links.append(link)for link in links:print(link)robots.txt 解析以及下載時間間隔設置
還記得 2.3.2 節提到的 robots.txt 嗎?它是我們進行爬蟲前的一個參考,為了降低爬蟲被封禁的風險,我們需要遵守其中的約束,可以在 網站域名 + /robots.txt 查看文件要求,我們在 2.3.2 節已經初步分析過了,只要我們的用戶代理不是 Twitterbot 并且不以它為子串,那么就沒有限制。盡管如此,我們還是可以設置一個下載的間隔時間,并且在發送請求前檢查請求是否符合 robots.txt 的規定,這樣我們的爬蟲便可以適應更多的變化。
我們可以在請求文獻內容前進行進行一次 robots.txt 驗證,如果驗證通過我們再執行下載,并設置下載時間間隔。我們正好借此機會調整一下之前的代碼設計,盡可能減少功能之間的耦合 (download.py):
import requests from urllib.robotparser import RobotFileParser import time from urllib.parse import urlparse from filename import get_valid_filenamedef doi_parser(doi, start_url, useSSL=True):"""Parse doi to url"""HTTP = 'https' if useSSL else 'http'url = HTTP + '://{}/{}'.format(start_url, doi)return urldef get_robot_parser(robot_url):"""解析robots.txt"""rp = RobotFileParser()rp.set_url(robot_url)rp.read()return rp"""延時函數""" def wait(url, delay=3, domains={}):"""wait until the interval between twodownloads of the same domain reaches time delay"""domain = urlparse(url).netloc # get the domainlast_accessed = domains.get(domain) # the time last accessedif delay > 0 and last_accessed is not None:sleep_secs = delay - (time.time() - last_accessed)if sleep_secs > 0:time.sleep(sleep_secs)domains[domain] = time.time()def download(url, headers, proxies=None, num_retries=2):print('Downloading: ', url)try:resp = requests.get(url, headers=headers, proxies=proxies, verify=False)html = resp.textif resp.status_code >= 400:print('Download error: ', resp.text)html = Noneif num_retries and 500 <= resp.status_code < 600:return download(url, headers, proxies, num_retries-1)except requests.exceptions.RequestException as e:print('Download error', e)return Nonereturn htmldef download_pdf(result, headers, proxies=None, num_retries=2):url = result['onclick']url = 'https:{}'.format(url)print('Downloading: ', url)try:resp = requests.get(url, headers=headers, proxies=proxies, verify=False)if resp.status_code >= 400:print('Download error: ', resp.status_code)if num_retries and 500 <= resp.status_code < 600:return download(result, headers, proxies, num_retries-1)filename = get_valid_filename(result['title']) + '.pdf'print(filename)# ok, let's write it to filewith open(filename, 'wb') as fp:fp.write(resp.content)except requests.exceptions.RequestException as e:print('Download error', e)return Falsereturn Truedef sci_hub_crawler(doi_list, robot_url=None, user_agent='sheng', proxies=None,num_retries=2, delay=3, start_url='sci-hub.do', useSSL=True, get_link=None, nolimit=False):"""給定文獻doi列表,爬取對應文獻的 pdf 文件:param doi_list: doi列表:param robot_url: robots.txt在sci-bub上的url:param user_agent: 用戶代理,不要設為 'Twitterbot':param proxies: 代理:param num_retries: 下載重試次數:param delay: 下載間隔時間:param start_url: sci-hub 主頁域名:param useSSL: 是否開啟 SSL,開啟后HTTP協議名稱為 'https':param get_link: 抓取下載鏈接的函數對象,調用方式 get_link(html) -> html -- 請求的網頁文本所使用的函數在 scraping_using_%s.py % (bs4, lxml, regex) 內:param nolimit: 是否遵循 robots.txt 的約束,如果為True則不受其限制:return:"""headers = {'User-Agent': user_agent}HTTP = 'https' if useSSL else 'http'if not get_link:print('Crawl failed, no get_link method.')return Noneif not robot_url:robot_url = HTTP + '://{}/robots.txt'.format(start_url)try:rp = get_robot_parser(robot_url)except Exception as e:rp = Noneprint('get_robot_parser() error: ', e)domains={} # save the timestamp of accessed domainsdownload_succ_cnt: int = 0 # the number of pdfs that're successfully downloadedfor doi in doi_list:url = doi_parser(doi, start_url, useSSL)if rp and rp.can_fetch(user_agent, url) or nolimit:wait(url, delay, domains)html = download(url, headers, proxies, num_retries)result = get_link(html)if result and download_pdf(result, headers, proxies, num_retries):download_succ_cnt += 1else:print('Blocked by robots.txt: ', url)print('%d of total %d pdf success' % (download_succ_cnt, len(doi_list)))if __name__ == '__main__':from scraping_using_lxml import get_link_xpath, get_link_cssselectfrom scraping_using_bs4 import get_link_using_bs4from scraping_using_regex import get_link_using_regexfrom random import choicedois = ['10.1016/j.apergo.2020.103286', # VR'10.1016/j.jallcom.2020.156728', # SOFC'10.3964/j.issn.1000-0593(2020)05-1356-06'] # 飛行器get_links_methods = [get_link_xpath, get_link_cssselect, get_link_using_bs4, get_link_using_regex]get_link = choice(get_links_methods)print('use %s as get_link_method.' % get_link.__name__)print('obey the limits in robots.txt: ')sci_hub_crawler(dois, get_link=get_link, user_agent='sheng')print('no any limit: ')sci_hub_crawler(dois, get_link=get_link, user_agent='sheng', nolimit=True)print('Done.')運行結果:
use get_link_xpath as get_link_method. obey the limits in robots.txt: Blocked by robots.txt: https://sci-hub.do/10.1016/j.apergo.2020.103286 Blocked by robots.txt: https://sci-hub.do/10.1016/j.jallcom.2020.156728 Blocked by robots.txt: https://sci-hub.do/10.3964/j.issn.1000-0593(2020)05-1356-06 0 of total 3 pdf success no any limit: Downloading: https://sci-hub.do/10.1016/j.apergo.2020.103286 Downloading: https://sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true Implementing Virtual Reality technology for safety training in the precast_ prestressed concrete industry. Applied Ergonomics, 9.pdf Downloading: https://sci-hub.do/10.1016/j.jallcom.2020.156728 Downloading: https://sci-hub.do/downloads/2020-11-23/ac/tsvinkinberg2021.pdf?download=true Tsvinkinberg, V. A., Tolkacheva, A. S., Filonova, E. A., Gyrdasova, O. I., Pikalov, S. M., Vorotnikov, V. A., … Pikalova, E. Y. .pdf Downloading: https://sci-hub.do/10.3964/j.issn.1000-0593(2020)05-1356-06 error occurred: Document is empty 2 of total 3 pdf success Done.對代碼和結果的說明:
代碼可能有點長,但是其中很多函數在之前已經出現過了,大部分函數只作了很小部分的改動:比如,download() 和 download_pdf() 不再傳入 doi,而是傳入對應的 url。另外,新增了 doi_parser() 轉換函數,這樣就實現了解耦,能讓 download() 具有通用性;新增的 get_robot_parser() 以及 can_fetch() 函數實現了 robots.txt 的解析,并遵循其中的約束; wait() 函數設置了下載間隔時間
sci_hub_crawler() 集成了"根據給定的 DOI 列表批量(串行)爬取對應的 pdf 文件"的功能,其參數列表的說明在函數開頭標注了。如此一來,當我們爬取到 doi 列表后,只需要調用 sci_hub_crawler() 并睡上一覺就行了
主函數中我們使用了一個 get_links_methods 列表存儲了所有抓取方法,然后使用 random.choice() (偽)隨機選取了其中一個,傳給了 sci_hub_crawler() 的 get_link 參數,這其實就是多態性的一種體現 —— 同一種調用(get_link(html)),不一樣的方法。這類似于 C# 中的委托 (delegate) 或是 C/C++ 的函數指針。但要求 get_link_methods 中的所有函數參數列表一致,從實用性來看,要保證非默認參數的個數和順序相同。
從運行結果來看,似乎 robots.txt 中的約束比我們之前解讀的要強很多 —— 它不允許我們爬取資源,但我們仍然可以 “知不可為而為之” (設置 nolimit=True)。只不過為了降低可能的封禁隱患,我們可以讓下載間隔大一些(比如 5 - 10s,這樣按照兩分鐘一個的速率一夜也能爬個200+文件,這夠多了)。不過也別高估了這個 sci_hub_crawler(),它就是個串行爬蟲,想讓服務器崩潰可沒那么容易,所以我們還是可以放心爬 (大不了就是幾天的封禁嘛)
添加一個簡單的緩存類 Cache
有時候我們可能因為不可抗力 (比如斷網、死機等) 而不得不中止我們的爬取,設想這樣一個情況:我們要爬取1000個文件,然而我們在下載到第501個文件時出現了上述的意外,當一切恢復正常后,我們想要繼續從第 501 個文件處開始下載,怎么辦? 一種極簡的方法是:設立一個變量以記錄我們當前已經成功下載的文件個數,并且每當一個文件下載成功時,將此變量寫入一個文件 (比如 .txt),重啟下載時讀取該變量值,從它的下一個序號開始下載即可。
以上方法適用于我們的 DOI 列表項順序不變的情況,事實上對于我們這個小項目來說已經滿足要求了;但還有一種普適性更強的方法,那就是按鍵值對存儲已經下載的資源標識,這里我們可以選用 {文獻url: pdf_url} 作為資源標識,借用 Python3 標準庫中的 json 模塊來實現緩存數據加載和存儲。
由于我們的緩存在內存中以字典形式存儲,與此同時需要訪問外存,進行緩存讀寫,我們可以將緩存功能封裝在一個類中,并通過特殊成員函數 __getitem__() 和 __setitem__() 使得類對象的操作行為類似于字典對象。
下面構建一個 Cache 類 (cache.py):
import json import osclass Cache:def __init__(self, cache_dir):self.cache_dir = cache_dir # 緩存文件的路徑self.cache = self.read_cache() # 加載緩存數據,是個字典def __getitem__(self, url): # 例如,對于類對象cache,執行 cache[url] 將調用此方法if self.cache.get(url):return self.cache[url]else:return Nonedef __setitem__(self, key, value): # key -> url value -> pdf_url 執行 cache[url] = pdf_url 將調用此方法"""將{url: pdf_url} 追加到字典中,并寫入外存"""filename = self.cache_dirself.cache[key] = valueif os.path.exists(filename):with open(filename, 'r') as fp:if os.path.getsize(filename):cache = json.load(fp)else:cache = {}cache.update({key: value})with open(filename, 'w') as fp:json.dump(cache, fp, indent=0) # 加換行符def read_cache(self):"""加載json數據成為Python字典對象,至少也是個空字典"""try:filename = self.cache_dirif os.path.exists(filename):if os.path.getsize(filename):with open(filename, 'r', encoding='utf-8') as fp:return json.load(fp)else:return {}else:with open(filename, 'w', encoding='utf-8'):return {}except Exception as e:print('read_cache() error: ', e)return {}要使用此類,我們得修改 sci_hub_crawler(),下面僅展示更改的代碼 (download.py):
def sci_hub_crawler(doi_list, robot_url=None, user_agent='sheng', proxies=None,num_retries=2,delay=3, start_url='sci-hub.do', useSSL=True, get_link=None, nolimit=False, cache=None):"""...:param cache: 應傳入一個緩存類對象,在此代碼塊中我們應把它當作字典使用..."""...try:...for doi in doi_list:...if cache and cache[url]: # 如果緩存中存在對應 url,那么跳過后續下載步驟print('already downloaded: ', cache[url])download_succ_cnt += 1continueif rp and rp.can_fetch(user_agent, url) or nolimit:...if result and download_pdf(result, headers, proxies, num_retries):if cache:cache[url] = 'https:{}'.format(result['onclick']) # cache...正如函數開頭注釋所說,雖然cache是個Cache類的對象,但是由于類中的特殊函數(上文已經提及),實現了運算符重載,我們可以像使用字典一樣使用它。
下面我們可以簡單測試一下新增的緩存功能 (cache.py):
if __name__ == '__main__':from download import sci_hub_crawlerfrom scraping_using_lxml import get_link_xpathcache_dir = './cache.txt'dois = ['10.1016/j.apergo.2020.103286', # VR'10.1016/j.jallcom.2020.156728', # SOFC'10.3964/j.issn.1000-0593(2020)05-1356-06'] # 飛行器sci_hub_crawler(dois, get_link=get_link_xpath, user_agent='sheng', nolimit=True, cache=Cache(cache_dir))print('Done.')運行結果與說明:
初次運行,文件下載符合預期,并且在代碼的同級目錄下生成了 cache.txt 文件,內容如下:
{ "https://sci-hub.do/10.1016/j.apergo.2020.103286": "https://sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true", "https://sci-hub.do/10.1016/j.jallcom.2020.156728": "https://sci-hub.do/downloads/2020-11-23/ac/tsvinkinberg2021.pdf?download=true" }上述內容加載到內存后是一個 Python 字典,鍵是 sci-hub 上輸入 doi 后搜索所得頁面的 url,值是相應 pdf 資源的 url
第二次運行,由于文獻已經下載過了,除了第三個異常的鏈接外,其余文獻將不再執行下載,而是給出"已經下載"的提示:
already downloaded: https://sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true already downloaded: https://sci-hub.do/downloads/2020-11-23/ac/tsvinkinberg2021.pdf?download=true Downloading: https://sci-hub.do/10.3964/j.issn.1000-0593(2020)05-1356-06 error occurred: Document is empty 2 of total 3 pdf success Done.雖然還存在很多可以改進的地方,但現在是時候打住了,現在的版本已經符合要求了 (再優化就寫不完了)。
3.3 回到 Web of Science,提取搜索頁的 DOI 列表
至此,我們已經翻過了最高的山,剩余工作很簡單 —— 抓取 DOI 就完事了,它與 3.2 節的不同之處:
-
網站不同,意味著元素選擇會有所改變
-
只有文本抓取,沒有二進制數據流的下載過程
是不是非常簡單?我們要做的是抓取 html 文本中的 DOI,然后用列表存起來,還可以把它寫入磁盤。這些操作我們在 3.2 節已經見過了。但是 —— 你怎么獲得搜索結果中所有的文獻鏈接呢 (搜索結果往往分布在多個分頁里) ?其實我們在 2.1 節已經討論過了這個問題,并且給出了解決方案,筆者在此畫個示意圖,展現一下 Web of Science 搜索結果的層級結構:
該示意圖其實展示了一個比較通用的爬蟲模型 —— 鏈接爬蟲 (Link Crawler),它可以通過一個源鏈接,跟蹤頁面中的其他鏈接,使得爬蟲表現得更像普通用戶 [1],降低封禁風險。(一頁頁地瀏覽,并且按順序訪問文獻,的確符合 “普通用戶” 的行為)
筆者這里不再對鏈接爬蟲作過多展開,原因有二:其一,筆者爬取 Web of Science 的過程中沒被封禁過,而且也沒找到該網站的 robots.txt,再加上這是串行爬取,訪問時也就省去了普通用戶的控件操作時間,對網站服務器的負載貢獻不大;其二,這種方法相對較慢,為了獲取 doi,它還需要額外花時間爬取鏈接。
事實上,針對這個網站有更高效的方法。
2020-12-11 晴
3.3.1 方法一:修改 doc 屬性值快速構建 url,然后從中爬取 doi
這是筆者點進一篇文獻的網站,觀察 url 鏈接發現的一種方法,此方法不需要用到分頁,可以直接獲取每個文獻鏈接。我們看一下第 1 頁第 1 篇文獻的 url:
http://apps.webofknowledge.com/full_record.do?product=UA&search_mode= GeneralSearch&qid=30&SID=8FZeNUIigweW9fYyFJn&page=1&doc=1試著解析一下這個 url:
http://apps.webofknowledge.com/ →\rightarrow→ Web of Science 主頁鏈接
xxx.do →\rightarrow→ 是個網頁后臺程序,剛點搜索彈出的頁面便是 Search.do、切換分頁時為 Summary.do,打開具體某一文獻時為 full_record.do
?attr1=value1&attr2=value2&... →\rightarrow→ 問號后面接一個或多個用 & 分隔開來的變量,并設定一定值,從而實現動態鏈接,也就是說對于不同的屬性以及屬性值,會生成不同的網頁。那我們來看看上面那個鏈接跟了些啥參數吧:
product。這個不用管,所有頁面都一樣
search_mode。 看名字就是知道,指搜索模式,這個也不用管
qid、SID。不知道啥意思,但不可少,而且同一個搜索結果下所有文獻網站的 qid 和 SID 都一樣,所以我們保持原樣即可
page。分頁號碼,對應的便是不同分頁,可能有用。(但事實上這種分頁與結果列表本身沒有關系,只是刻意限定了每頁的結果數目而已,所以很可能也不用管)
doc。文獻搜索序號,與分頁號無關,當 page 和 doc 共存時 doc “說得算”,你會發現即使沒有分頁號也能正常打開目標網頁,說明分頁號page不重要,重要的是文獻搜索序列號doc!!!
如此一來,我們得到了如下三個子問題,并且都很好解決:
url 轉換,給定一個搜索結果源鏈接 (必須是文獻鏈接而不是搜索頁或分頁鏈接),其格式為 http://apps.webofknowledge.com/full_record.do?attr1=value1&attr2=...&attrn=valuen&doc=num, 要獲取搜索列表中第 iii 篇文獻網頁,將 url 末尾參數 doc 改變,使得 &doc=i 即可,此時鏈接變為:http://apps.webofknowledge.com/full_record.do?attr1=value1&attr2=...&attrn=valuen&doc=i
抓取搜索結果總數:<span id=“hitCount.top”>21,322</span>,注意搜索結果總數中的逗號,要把它轉變為整數。不過,筆者偷下懶,把這項任務交給用戶,人眼"識別"結果總數,并傳到接口的相應參數中。
抓取文獻網頁中的 DOI:標簽特征在 2.2 節已經解析過,只需采用 3.2.2 節的一種抓取方法即可 (筆者使用XPath選擇器:'//span[text()="DOI:"]/following::*[1]')[0].text())
<p class="FR_field"> <span class="FR_label">DOI:</span> <value>10.1016/j.electacta.2020.137142</value> </p>下面是筆者 抓取一定數目搜索結果的 DOI 并構成列表 的 Python3 實現 (doi_crawler.py):
from download import download import re from lxml.html import fromstringdef url_changer(source_url):"""獲取文獻網站url的模式"""url = re.findall(r'''(.*)&doc''', source_url)[0]doc = '&doc='return url + docdef get_doi(html):"""根據獲取到的html獲得其中的doi并返回"""try:tree = fromstring(html)doi = tree.xpath('//span[text()="DOI:"]/following::*[1]')[0].textreturn doiexcept Exception as e:print('get_doi() error: ', e)return Nonedef doi_crawler(pattern_url, headers=None, number=500):""" 獲得搜索結果中第 [1, number] 的 doipass the following parameter:param pattern_url: 搜索結果內任意一篇文獻的url,不是分頁或者搜索結果頁的!:param number: doi獲取數目,不要超過頁面最大結果數"""if headers is None:headers = {'User-Agent': 'sheng'}base_url = url_changer(pattern_url)dois = []for i in range(1, number + 1):url = base_url + str(i)html = download(url, headers)doi = get_doi(html)if doi:dois.append(doi)return doisdef save_doi_list(dois, filename):"""將doi列表項以[filename].txt保存到當前文件夾中,"""filepath = filename[:128] + '.txt'try:with open(filepath, 'w') as fp:for doi in dois:fp.writelines(doi + '\n')except Exception as e:print('save error: ', e)def read_dois_from_disk(filename):"""從磁盤文件[filename].txt中按行讀取doi,返回一個doi列表"""dois = []try:filepath = filename + '.txt'with open(filepath, 'r') as fp:lines = fp.readlines()for line in lines:dois.append(line.strip('\n'))return doisexcept Exception as e:print('read error: ', e)return Noneif __name__ == '__main__':import timesource_url = 'http://apps.webofknowledge.com/full_record.do?product=UA&' \'search_mode=GeneralSearch&qid=2&SID=6F9FiowVadibIcYJShe&page=1&doc=2'start = time.time()dois = doi_crawler(source_url, number=10)save_doi_list(dois, 'dois')print('time spent: %ds' % (time.time()-start))print('now read the dois from disk: ')doi_list = read_dois_from_disk('dois')for doi in doi_list:print(doi)運行結果:
Downloading: http://apps.webofknowledge.com/full_record.do?...&doc=1 ... Downloading: http://apps.webofknowledge.com/full_record.do?...&doc=10 time spent: 9s now read the dois from disk: 10.1016/j.apcatb.2020.119553 ... 10.1016/j.ceramint.2020.08.241對代碼與結果的說明:
雖然有很多函數,但函數結構非常簡單,而且對函數參數和功能作了注釋,就不過多解讀了。讀者可以從主函數片段中得知主要的函數接口 (有 3 個,分別是 doi_crawler() 、save_doi_list() 、 read_dois_from_disk() )
XPath選擇器的構造參考了一位博主的博客 [2],鏈接:XPath 選取具有特定文本值的節點
筆者僅選取了搜索結果前 10 項,測試了多次,耗時在 9 - 15s 范圍內,也就是大約 1 秒 1 個 doi,不知道讀者能否接受這個速度呢 (反正筆者感覺還可以)
爬蟲受網絡因素影響,偶爾會爬取失敗,重試幾次就好了 (sci_hub_crawler() 也一樣)
與 3.2 節的 sci_hub_crawler() 不同,本節給用戶留了兩個小任務:① 提供一個文獻的鏈接;② 設定最大 doi 個數
3.3.2 方法二:結合 Web of Science 導出功能的"零封禁幾率"方法
上面的方法雖然通過找規律的方式省去了爬取文獻鏈接的過程,提高了效率,但并不能保證在進行大量爬取時會免受封禁。好在 Web of Science 網站提供了一個便利的功能 —— 導出選擇的選項。它通過一個按鈕控件的點擊事件觸發,如下圖所示:
????? ???????????? ???????筆者以 Unity3D 為主題,試著導出第 1 至 500 項,以 html 的格式保存文獻數據。然后我們打開此文件 (savedrecs.html,即 save document records),找尋 DOI 所在位置, 如下所示:
根據筆者觀察,幾乎每一個 <td> 標簽的屬性 valign 值是一致的,那么我們就只能根據文本 “DI” 來定位并選擇其下一個兄弟元素的方式來獲取目標 DOI 了。因此,筆者采用 XPath 選擇器,使用的選擇字符串與 3.3.1 節類似。
下面給出 根據導出的 html 記錄,抓取其中的 DOI 并返回列表 的 Python3 實現 (advanced_doi_crawler.py):
from lxml.html import fromstringdef get_doi(html):"""根據獲取到的html獲得其中的doi并返回"""results = []try:tree = fromstring(html)dois = tree.xpath('//td[text()="DI "]/following::*[1]')for doi in dois:results.append(doi.text)return resultsexcept Exception as e:print('get_doi() error: ', e)return Nonedef doi_crawler(filepath):"""html 導出文件的路徑"""try:with open(filepath, 'r', encoding='utf-8') as fp:html = fp.read()doi_list = get_doi(html)return doi_listexcept Exception as e:print('doi_crawler() error', e)return Noneif __name__ == '__main__':import timestart = time.time()filepath = './data.html'doi_list = doi_crawler(filepath)print('time spent: %ds' % (time.time() - start))print('%d doi records in total: ' % len(doi_list))for doi in doi_list:print(doi)print('Done.')運行結果:
time spent: 0s 206 doi records in total: 10.1016/j.apergo.2020.103286 #1 10.11607/ijp.6835 #2 ... 10.1016/j.proeng.2017.10.509 #206 Done.對代碼與結果的說明:
xpath() 返回的是一個列表,只不過之前的實踐我們經常只要其中第一項,這里存在多個匹配,所以我們全都要
doi_crawler() 中的文件讀取用的是 read(),而不是用 readlines()。前者一次讀取完;后者讀取所有行,保存在一個列表中 [3]
從結果看,500 條記錄中僅僅爬取了 206 個 DOI,這是正常的 —— Unity3D 的很多成果都是以會議形式發表的;
與 3.3.1 的方法相比,兩種方法的時間開銷完全不是一個級別的 —— 此方法 1s 之內即可完成;如果換作之前的方法,耗時將近 10 min
STEP4 組裝起來,形成終極接口:sci_spider()
我們分別用 3.2 節 和 3.3 節 制作了 sci_hub_crawler() 和 doi_crawler() (筆者用 3.3.2 節的),并作了簡單的測試,至少現在沒看到問題。那么把它們組合起來會不會引入新問題呢?實踐一下就知道了!
筆者先在此列出待調用函數的函數原型,作為流程梳理的參考:
def doi_crawler(filepath): pass # in advanced_doi_crawler.pydef sci_hub_crawler(doi_list, robot_url=None, user_agent='sheng', proxies=None,num_retries=2,delay=3, start_url='sci-hub.do', useSSL=True, get_link=None, nolimit=False, cache=None):pass # in download.pydef get_link_xpath(html):pass # in scraping_using_lxml.py注:盡管筆者可能在某一步驟使用了多種方法來實現,但此處筆者只選擇一種方案,其余方案就不再展示實現方法了,但思路都是一致的。具體來說,筆者抓取標簽內容使用的是 XPath 選擇器;"獲取 DOI 列表"采用的是 3.3.2 節的方法。
4.1 流程梳理 ?\star?
本節實際上是本爬蟲的使用說明書。
打開 Web of Science,搜索感興趣的內容,得到一個搜索結果列表
點擊 “導出為其他文件格式” 按鈕,記錄條數自選,記錄內容為作者、標題、來源出版物,文件格式選擇HTML,然后點擊"導出",記錄該 html 文件的 絕對路徑 filepath (也可以是相對路徑)
調用 doi_crawler(filepath),返回一個 doi 列表,將之命名為 doi_list
調用 sci_hub_crawler(doi_list, get_link=get_link_xpath, nolimit=True, cache=Cache(cache_dir)),如果不需要緩存,可以不傳參至 cache。另外說明的是,cache_dir 是緩存文件的路徑,一般用相對路徑即可;其余參數根據需要來調整
睡上一覺,等待結果
4.2 組裝起來,給它取個名字,就叫 “sci_spider” 好了
上面流程已經說的很清楚了,組裝起來不是什么難事,但需要注意:組裝的這些函數的參數列表需要合理地合并。
下面就是筆者組裝的情況 (sci_spider.py):
from download import sci_hub_crawler from scraping_using_lxml import get_link_xpath from cache import Cache from advanced_doi_crawler import doi_crawlerdef sci_spider(savedrec_html_filepath, robot_url=None, user_agent='sheng', proxies=None, num_retries=2,delay=3, start_url='sci-hub.do', useSSL=True, get_link=get_link_xpath,nolimit=False, cache=None):"""給定一個文獻索引導出文件 (來自 Web of Science),(按照DOI)下載文獻對應的 pdf文件 (來自 sci-hub):param savedrec_html_filepath: 搜索結果的導出文件 (.html),其中含有文獻記錄 (每一條記錄可能有doi,也可能沒有):param robot_url: robots.txt在sci-bub上的url:param user_agent: 用戶代理,不要設為 'Twitterbot':param proxies: 代理:param num_retries: 下載重試次數:param delay: 下載間隔時間:param start_url: sci-hub 主頁域名:param useSSL: 是否開啟 SSL,開啟后HTTP協議名稱為 'https':param get_link: 抓取下載鏈接的函數對象,調用方式 get_link(html) -> html -- 請求的網頁文本所使用的函數在 scraping_using_%s.py % (bs4, lxml, regex) 內,默認用xpath選擇器:param nolimit: do not be limited by robots.txt if True:param cache: 一個緩存類對象,在此代碼塊中我們完全把它當作字典使用"""print('trying to collect the doi list...')doi_list = doi_crawler(savedrec_html_filepath) # 得到 doi 列表if not doi_list:print('doi list is empty, crawl aborted...')else:print('doi_crawler process succeed.')print('now trying to download the pdf files from sci-hub...')sci_hub_crawler(doi_list, robot_url, user_agent, proxies, num_retries, delay, start_url,useSSL, get_link, nolimit, cache)print('Done.')if __name__ == '__main__':filepath = './data.html' # doi所在的原始 htmlcache_dir = './cache.txt' # 緩存路徑cache = Cache(cache_dir)sci_spider(filepath, nolimit=True, cache=cache)4.3 對第一次運行結果的分析與問題處理 ?\star?
我們運行 sci_spider.py 中的主函數代碼,結束后對結果進行分析。
4.3.1 運行結果分析
之前也看到了,一共有 206 個 DOI,這個下載量比較大了,檢查無誤后,我們現在嘗試運行一下:
trying to collect the doi list... doi_crawler process succeed. now trying to download the pdf files from sci-hub... ... # 下載過程省略 94 of total 206 pdf success Done. time spent: 1664s我們可以輕松地從運行結果中提取以下數據:
206 個 doi 中下載成功的有 94 個,占比 45.6%
總共用時為 1664 秒,即 27 分 44 秒,成功下載單個文件的用時為 17.7 秒
另外,我們看看磁盤上的變化:
cache.txt
我們注意到最后一個 url 對應第 95 行,而第一個文件從第 2 行開始,所以一共有 94 個 pdf 文件成功下載,從數量上看這是沒有錯的。
pdf 文件目錄
驚了,明明成功下載了 94 個,卻只有 71 個項目,難道被誰吃了嗎?確實,看看第一個文件名稱 —— 一個下劃線,這暗示著有些文獻沒抓到標題,標題為空字符,然后這個僅有的空字符被替換成了下劃線,從數目看,空標題的情況有 24 個,數量占比不小了,所以我們得對這些情況下的 html 文本再度分析一下。在此之前,我們再仔細看看運行窗口中那些下載失敗或標題為空的文件對應的輸出信息吧:
筆者發現了七種不同類型的錯誤信息輸出 (包括空標題),上述出錯的 url 筆者都一一點開過,對于更詳細的錯誤信息,筆者已經在上面作了注釋。下面重點關注兩個比較容易糾正且比較普遍的錯誤:
-
錯誤類型二:標題抓取為空
-
錯誤類型四:HTTP協議頭重復
下面和筆者一起逐個解決~
4.3.2 問題1:標題抓取為空 —— 用 DOI 作為名字
點開文獻網址,頁數有點多,加載片刻后如下圖:
如你所見,我們的目標區域內容為空,那我們得想想別的辦法了?即使不能保證有意義,但最起碼得給它個不一樣的名字,免得造成文件覆蓋而丟失,那我們最容易想到的就是用 DOI 作為名字啦。
代碼做以下調整:
download.py
def download_pdf(result, headers, proxies=None, num_retries=2, doi=None): ···try:...if len(result['title']) < 5: # 處理標題為空的情況filename = get_valid_filename(doi) + '.pdf'else:filename = get_valid_filename(result['title']) + '.pdf'...def sci_hub_crawler(doi_list, robot_url=None, user_agent='sheng', proxies=None,num_retries=2,delay=3, start_url='sci-hub.do', useSSL=True, get_link=None, nolimit=False, cache=None):...if result and download_pdf(result, headers, proxies, num_retries, doi):......筆者假定字符數小于 5 時就采用 doi 命名。 (標題至少也得 5 個字符吧)
4.3.3 問題2:HTTP協議頭重復 —— 添加判斷去重
點進去一看,有些 onclick 內容中的鏈接自帶HTTP協議頭:
<a href="#" onclick="location.href= 'https://twin.sci-hub.do/6601/f481261096492fa7c387e58b490c15c6/llobera2017.pdf?download=true'"> ? save</a>為此我們需要在代碼中添加一層判斷,首先看看有無HTTP協議頭,如果沒有才添加,修改的代碼如下 (download.py):
def download_pdf(result, headers, proxies=None, num_retries=2, doi=None):url = result['onclick']components = urlparse(url)if len(components.scheme) == 0: # HTTP協議頭長度為 0,則添加協議頭url = 'https:{}'.format(url)print('Downloading: ', url)... # 小測試 if __name__ == '__main__':from scraping_using_lxml import get_link_xpathdois = ['10.1109/TCIAIG.2017.2755699', # HTTP協議頭重復'10.3390/s20205967', # 標題為空'10.1016/j.apergo.2020.103286' # 沒毛病] get_link = get_link_xpathsci_hub_crawler(dois, get_link = get_link, user_agent='sheng', nolimit=True)print('Done.')運行結果:
Downloading: https://sci-hub.do/10.1109/TCIAIG.2017.2755699 Downloading: https://twin.sci-hub.do/6601/f481261096492fa7c387e58b490c15c6/llobera2017.pdf?download=true A_tool_to_design_interactive_characters_based_on_embodied_cognition. _IEEE_Transactions_on_Computational_Intelligence_and_AI_in_G.pdf Downloading: https://sci-hub.do/10.3390/s20205967 Downloading: https://sci-hub.do/downloads/2020-10-31/dc/10.3390@s20205967.pdf?download=true 10.3390_s20205967.pdf Downloading: https://sci-hub.do/10.1016/j.apergo.2020.103286 Downloading: https://sci-hub.do/downloads/2020-12-01/29/joshi2021.pdf?download=true Implementing_Virtual_Reality_technology_for_safety_training_in_the_precast__ prestressed_concrete_industry._Applied_Ergonomics,_9.pdf 3 of total 3 pdf success Done.從運行結果可見,上述問題都已經修復,而且沒有帶來額外的問題 (至少看起來是這樣)。
4.3.4 最后的調試
下面我們刪去 cache.txt 和 下載的 pdf (只是測試用的,不要舍不得),再度運行 sci_spider.py,休息半個小時后看看結果:
trying to collect the doi list... doi_crawler process succeed. now trying to download the pdf files from sci-hub... ... 150 of total 206 pdf success Done. time spent: 2847s現在再看看數據,蕪湖,起飛 ~ :
206 個 doi 中下載成功的有 150 個,占比 72.8%
總共用時為 2847 秒,即 47 分 27 秒,成功下載單個文件的用時為 18.98 秒
筆者再此基礎上再運行了一次程序,用以測試緩存功能是否能正常運行,結果符合我們的預期:
trying to collect the doi list... doi_crawler process succeed. now trying to download the pdf files from sci-hub... ... already downloaded: https:https://twin.sci-hub.do/ 6634/7e804814554806b27952fd2974ae4ba1/radionova2017.pdf?download=true 150 of total 206 pdf success Done. time spent: 1367s至此項目結束。
爬蟲感想
筆者這次就分享這么多了,一共用了 6 天時間,一邊學,一邊寫博客,一邊碼代碼,花的時間比較長了。文章的長度遠遠超出我的預期,很多東西也就是順著思路寫的,沒怎么整理,筆者想盡可能地還原這個從零到一的過程,不知各位讀者覺得筆者是否做到了呢?
筆者寫的這個爬蟲十分簡陋,涉及的爬蟲知識也比較淺,爬蟲中對于一些問題的處理也很粗糙,但至少還算能正常工作,可以滿足一定程度的需求。畢竟,筆者接觸爬蟲也就是最近幾個星期,實踐過程中也從各個渠道學到了很多相關的知識,于個人而言已經很滿足了。
其實在項目執行初期,筆者還有幾個更大的想法,比如,并行下載、將緩存數據存至數據庫 (redis) 、可視化下載進度、做個窗體程序等。但限于時間和篇幅,筆者在此都沒有實現。另外,筆者發現,很多一開始的想法 (在 STEP1 和 STEP2 中提到的),可能到后面都用不上,其中原因的大多是當初調研時考慮不周全。但是,有誰能保證做一個從沒做過的項目時能夠預先進行完美設計呢?完美設計與否,最終還是要靠實踐來檢驗和打磨,代碼從簡單到復雜,再又回到另一個境界的簡單。
筆者起初打死都想不到,終極接口 sci_spider() 竟然有如此多的參數,看起來相當復雜;但是,它是筆者實踐過程中一步步搭建與優化得到的,就算某個代碼細節忘記了,也有辦法通過重新回顧此代碼而迅速拾起,這或許就是實踐與沒有實踐過的區別。
筆者是一個無語言論者 (雖然用 C++ 和 C# 比較多),但通過這次實踐,筆者真切感受到了 Python3 的優雅與強大 —— 它將我們從繁雜的語言細節中解放出來,讓我們能集中精力處理去思考問題本身的解決方案。當然,也不能一味地依賴語言帶來的強大功能,對于很多底層原理與細節,如有時間也應該去好好琢磨一下。
好了,這段爬蟲之旅到此就要畫上句號了。筆者做這個項目的初衷就是為了品嘗用技術解決具體問題的喜悅,現在確實很滿足。然而,凡事都有個主次,筆者還有很多優先級更高的學業任務需要完成,所以可能會有一段時間不碰爬蟲,很高興能分享我的實踐過程,也真心希望這些文字能給您帶來幫助~
資源 (見GitHub)
筆者已經把本次實踐的代碼上傳到 GitHub 上了,僅供學習用。如果各位只是想要使用的話,可以在 GitHub 上找到更好的爬蟲。筆者這個用到的知識很少,功能也很簡單,比較 low。
點此訪問筆者的 GitHub 資源
References
筆者把主要的參考文獻放在這里 (有些在文獻中給出了鏈接),有需要的可以自行查閱。
[1] [德] Katharine Jarmul, 等.用Python寫網絡爬蟲(第二版)[M].李斌, 譯.北京:人民郵電出版社, 2018, pp. 1-78
[2] 知否知否呀.XPath 選取具有特定文本值的節點[EB/OL].https://blog.csdn.net/lengchun10/article/details/41044119, 2014-11-12.
[3] 假裝自己是小白.Python中read()、readline()和readlines()三者間的區別和用法[EB/OL].https://www.cnblogs.com/yun1108/p/8967334.html, 2018-04-28.
[4] dcpeng.手把手教你如何在Pycharm中加載和使用虛擬環境[EB/OL].https://www.cnblogs.com/dcpeng/p/12257331.html, 2020-02-03.
[5] PilgrimHui.conda環境管理[EB/OL].https://www.cnblogs.com/liaohuiqiang/p/9380417.html, 2018-07-28.
[6] 奔跑中的兔子.爬蟲之robots.txt[EB/OL].https://www.cnblogs.com/benpao1314/p/11352276.html, 2019-08-14.
歡迎讀者朋友留言。 如有錯誤請務必批評指正,筆者在此給大佬們抱拳了~
總結
以上是生活随笔為你收集整理的【Python爬虫】从零开始爬取Sci-Hub上的论文(串行爬取)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Android如何打开未安装的apk,a
- 下一篇: 1000个手工绘制污渍笔刷