# Scrapy 介紹
###### tags: `Scrapy`,`Crawler`,`Python`
```
主旨: Scrapy 學習知識文檔
更新日期: 2022-04-07
版本: V1.0
作者: Ya-Sheng Chen (Rock)
```
---
### Referance
- why we need to learning crawl?
- [The Best And Worst Moments Of My Journey When Crawling Websites](https://towardsdatascience.com/my-journey-of-crawling-website-bd3294322e3c)
- 資源網站:
- 介紹基礎設定、參數說明[tutorialspoint](https://www.tutorialspoint.com/scrapy/index.htm)
- 多IP [scrapy with tor](https://datawookie.dev/blog/2021/06/scrapy-rotating-tor-proxy/)
- 爬蟲既看[崔庆才 個人站點- 爬蟲教程](https://cuiqingcai.com/)
- 書籍:
[Learning Scrapy第一版.pdf <全英文>](https://oiipdf.com/dl/5c73b3ed5b63937c3f8b4c77)
- 文章:
- [教學連結](https://www.jianshu.com/p/6c9baeb60044)
- [我们从爬取1000亿个网页中学到了什么?](https://juejin.im/post/5b55901df265da0fb0186fe7)
- [Scrapy框架的使用之Scrapy通用爬虫](https://juejin.im/post/5b026d53518825426b277dd5)
- 視覺化:
[Scrapy Shell GUI](https://blog.scrapinghub.com/building-spiders-made-easy-gui-for-your-scrapy-shell)
- Tips:
- [5 Tips To Create A More Reliable Web Crawler](https://towardsdatascience.com/my-journey-of-crawling-website-bd3294322e3c)
- [Prevent Getting block](https://www.scrapehero.com/how-to-prevent-getting-blacklisted-while-scraping/)
- Proxy:
- [多IP <收費>](https://infatica.io/)
- [tor](https://www.torproject.org/)
- [Tor IP changing and web scraping](https://dm295.blogspot.com/2016/02/tor-ip-changing-and-web-scraping.html)
- Git資源:
- [Sky<GUI網站爬蟲>](https://github.com/kootenpv/sky)
---
# 0. 前言
Scrapy算得上是Python世界中最常用的爬蟲框架了,它也是最難學習的框架,很多初學者時常不清楚Scrapy應該如何入手,雖然不好上手,其架構的思路、執行的效能和可擴展的能力都非常出眾。
## 0.1 Scrapy的優點
* 提供**內置的http緩存**,以加速本地開發。
* 提供自動節流調節機制,並且具有遵守 **robots.txt的設置能力**。
* 可以**定義爬行深度**的限制,以避免爬蟲進入死循環連結。
* 會**自動保留對話**
* 執行**自動HTTP基本認證**,不需要明確保存狀態。
* 可以**自動填寫登錄表單**。
* Scrapy 有一個內置的中間件,可以**自動設置請求中引用的(reffer) Head**。
* **支持通過3xx響應的重導向**,也可以通過HTML元刷新。
* **避免**被網站使用 **(noscript)meta重導向困住**,以檢測有沒有JS支持的頁面。
* 默認**使用Css選擇器**或**Xpath編寫解析器**。
* 可以通過 **Splash** 或 **任何其他技術(ex:selenium)** 呈現JavaScript頁面。
* 擁有強大的社群支持和豐富的插件來擴展其功能。
* **提供**了**通用Spider**來抓取常見的格式,如點地圖、CSV和XML。
* 內置 **支援**以**多種格式**(JSON, CSV, XML, JSON-lines) 導出收集的數據並將其儲存在多個後端(FTP、S3、本地文件系統)中。
## 0.2 Scrapy架構

1. **Spider**發送最初的請求(`Requests`)給Engine。
2. **Engine**在`Scheduler`調度一個請求(Requests),並要求下一次`Requests`做爬取。
3. **Scheduler**回傳下一個`Requests`給`Engine`。
4. **Engine**透過`Downloader Middlewares`發送請求給`Downloader`。
5. 只要頁面結束下載,**Downloader**產生一個`Response`透過`Downloader Middlewares`傳送給`Engine`。
6. **Engine**收到來自`Downloader`的`Response`並透過`Spider Middlewares`發送給`Spider`處理。
7. **Spider**處理`Response`並爬取的項目(`item`)和新的請求(Requests),透過`Spider Middlewares`回傳給`Engine`。
8. **Engine**發送處理的項目(`item`)給Item `Pipelines`接著發送處理的請求(`Requests`)到`Scheduler`要求下一個可能的爬蟲請求。
## 0.3 元件介紹:
1. **Scrapy Engine**:
>整個框架的核心負責處理整個系統的資料流與事件。
2. **Scheduler**:
>排程器,接收`Engine`的請求放到佇列中並決定下一個要抓取的網址(預設也會去除重複的網址)。
3. **Downloader**:
>用於下載網頁內容(發送HTTP請求/接收HTTP回應),並將內容返回給`Spider`。
4. **Spiders**:
>從網頁中提取自己需要的項目。
5. **Item Pipeline**:
>用來持久化實體、驗證實體的有效性、清除不需要的資訊。當頁面被解析後發送到專案管道經過幾個特定的順序處理資料。
6. **Downloader middlewares**:
>`Scrapy Engine`和`Downloader`間的中介,負責處理`Scrapy Engine`與`Downloader`之間的`Requests`及`Response`。
## 0.4 文件目錄結構
:::info
|---`project_folder`
| |--`__init__.py` *專案定義*
| |--`items.py` *物件定義*
| |--`pipelines.py` *管道定義*
| |`settings.py` *配置文件*
| |`spiders` *爬蟲文件夾*
| |__ `__init__.py` *默認的爬蟲代碼文件*
|__ `scrapy.py` *scrapy運行配置文件*
:::
---
# 1. 要擊敗敵人需要先瞭解敵人
爬蟲是就像是去網站取得對方的資料,就很像攻防戰一般,如何能以最有效率的方式取得資料,是爬蟲的重要指標。
想要打敗敵人就要知道敵人,所以我們必須了解網站架構,在網頁架構中,有兩種結構 1. `Xpath` **(xml tree 的結構路徑)** 2. `CSS` **(網頁版面風格的物件風格)** ,通常我們會利用者兩種來定位我們需要爬取的資料。
那接下來我們就來了解如何編寫Xpath
---
## 1.1 Xpath 的編寫
### Xpath 基本語法
| Expression | Description |
| ---------- | ---------------------------------------- |
| nodename | 取得`<nodename>`所有節點 |
| / | 從主目錄開始取得 |
| // | 模糊取得錄境內所有符合節點名稱的節點內容 |
| \. | 所在位置節點 |
| \.\. | 上層節點 |
| \@ | 取得屬性 |
`eaxmple`
| Expression | Description |
| ---------- | ---------------------------------------- |
| bookstore | 選擇所有名字為`bookstore`的節點 |
| /bookstore | 選擇 `root`節點中名字為`bookstore`的節點 `note: 如果節點以/為起點,則找尋路徑為絕對路徑`|
| bookstore/book | 選擇`bookstore`中名字為`book`的節點內容 |
| //book |取得所有`book`名稱的節點內容,不管在哪一層級 |
|bookstore//book| 選擇`bookstore`中名字為`book`的所有`book`節點內容|
| //@lang |取得所有名字為`lang`的所有屬性內容 |
### 指定特定位置或內容
| Expression | Description |
| ------------------------------ | ------------------------------- |
| /bookstore/book[1] | 取的第一個位置為1 與名字為books |
| /bookstore/book[last()] | 取得最後一個名字為`books` 位置內容 |
| /bookstore/book[last()-1] |取得最後一的前一個位置,名字為`books` 位置內容|
| /bookstore/book[postition()<3] | 取得`books`層級位置小於3的內容 |
| //title[@lang] | 取得所有`title`中,屬性有`lang`的內容 |
| //title[@lang = 'en'] | 取得所有`title`中,屬性有`lang`的內容,並`lang`='en' |
|/bookstore/book[price>35.00]| 取得`books`層級中價格大於35.00 |
|/bookstore/book[price>35.00]/title| 取得所有`title`, `books`層級中價格大於35.00|
### 模糊節點查詢
| Expression | Description |
| ---------- | ----------- |
| * | 取得任何節點內容 |
| @* | 取得任何節點屬性 |
| node() | 取得所有節點 |
### 多項查詢
| Expression | Description |
| ---------- | ----------- |
| //book/title \| //book/price |取得所有`book`中`title`和`price`節點內容|
| //title \|//price | 取得所有`title`和`price`節點內容 |
| /bookstore/book/title \| //price |取得`bookstore`中`book`中`title`內容和所有`price`節點內容|
參考資源:
- [w3schools](https://www.w3schools.com/xml/xpath_syntax.asp)
- [Xpath 詳細說明文檔](https://hal.inria.fr/hal-01612689/document)
#### contains & start-with()


範例:
```
取得所有<a>@href 中 出現.txt 字的 /@href <注意:此用法只取締一個符合的結果>
fe = '//a[contains(@href,".txt")]/@href'
```
# Itemloaders
利用Itemloaders 取代Extract() & xpath()
```
def parse(self, response):
l = ItemLoader(item=PropertiesItem(), response=response)
l.add_xpath('title', '//*[@itemprop="name"][1]/text()')
l.add_xpath('price', './/*[@itemprop="price"]'
'[1]/text()', re='[,.0-9]+')
l.add_xpath('description', '//*[@itemprop="description"]'
'[1]/text()')
l.add_xpath('address', '//*[@itemtype='
'"http://schema.org/Place"][1]/text()')
l.add_xpath('image_URL', '//*[@itemprop="image"][1]/@src')
return l.load_item()
```
`其他處理功能`
| 功能 | 說明 |
| ---------------------------------------------- | -------------------------------- |
| Join() | 合併多個結果 |
| MapCompose(unicode.strip) | 除去空格 |
| MapCompose(unicode.strip, unicode.title) | 除去空格,字首字母大寫 |
| MapCompose(float) | 將字串轉為數字 |
| MapCompose(lambda x: x.replace(',',''), float) | 將字串轉化為數字,逗號替換為空格 |
| MapCompose(lambda x: urlparse.urljoin(response.url, x)) | 使用response.url為開頭,並與相對URL與之合併為絕對URL |
範例 :
```
def parse(self, response):
link.add_xpath('title'. '//*[@itemprop="name"][1]/text()')
link.add_xpath('price', './/*[@itemprop="price"][1]/text()'), re='[,0-9]+'
link.add_xpath('description', '//*[@itemprop="description"][1]/text()', MapCompose(unicode.strip),join()
link.add_xpath('address', '//*[@itemtype="http://schema.org/Place"][1]/text()', MapCompos(unicode.strip)
link.add_xpath('image_URL', '//*[@itemprop]="image"[1]/@src', MapCompose(lambda x: urlparse.urljoin(response.url, x)))
```
# URL
利用文件當作URL來源
```
start_URL = [i.strip() for i in open('todo.URL.txt').readlines()]
```
---
# Item
想到要如何儲存我們爬取的資訊,通常大家都會直接想到Python的dict,不過使用字典的缺點就是:
1. 爬取的資料為**非結構化**,無法一目瞭然的檢視數據
2. 缺乏對字串名字的檢測,容易因工程師的筆誤而出錯
3. 不便於未來使用(例如:API串接)
透過使用ITEM將我們爬取的內容結構化
## Item & Field
可以將Item & Field 想像成
Item = table
Field = columns
透過定義Class 創建Table並定義field(ColumnName)
`定義class`
```
from scrapy import Item,Field
class BookItem(Item):
name = Field()
price= field()
```
```
>>>book = BookItem(name='Needful Things',price=45.0)
>>>book
{'name'='Needful Things','price'=45.0}
```
## 拓展 Item
有時我們想利用原本設定的結構化Item再定義新的Field,透過繼承增加。
`定義class`
```
Class ForeignBook(BookItem):
translator =Field()
```
```
>>>book2 = ForeignBook(name='Needful Things',price=45.0,translator='Jant')
>>>book2
{'name'='Needful Things','price'=45.0,'translator'='Jant'}
```
# Splash
參考資源:
- [splash document](https://readthedocs.org/projects/splash/downloads/pdf/stable/)
## 介紹
Splash 是一個JavaScript渲染服務。透過Twisted和QT5的基底,可以讓其併行化處理發揮的淋漓盡致。
### 優點
* 並行處理多個網頁
* 獲得HTML處理結果或屏幕擷取
* 採用Adblock plus的規則消除廣告相關的圖片,以加快網頁渲染速度
* 在頁面上下文內執行定義的JavaScript腳本
* 通過Lua執行腳本
* 在Splash-Jupyter Notebooks內用Lua腳本開發Splash應用
* 獲取渲染HAR格式內容的詳細訊息
#### Scrapy與Selenium+WebDriver相比,優點有以下幾點:
* Splash作為JS渲染服務是基於Twisted和QT5開發的輕量瀏覽器引擎,並且提供直接的HTML API。快速、輕量的特點使其容易進行分散式開發。
* Splash & Scrapy融合,兩者互相兼容彼此的特點,效率較好。
* 處理速度更優於Selenium
### 透過docker 起splash的instance服務去解析javascrpit頁面
#### 安裝splash
1. 使用docker安裝splash
`docker pull scrapinghub/splash`
2. 起服務
`sudo docker run -it -p 8050:8050 --rm scrapinghub/splash`
3. 安裝scrapy-splash
`pip install scrapy-splash`
4. 配置設定
檔案配置splash服務(以下操作全部在`settings.py`):
- 1)新增splash伺服器地址:
```
SPLASH_URL = 'localhost:8050'
```
- 2)將splash middleware新增到DOWNLOADER_MIDDLEWARE中:
```
DOWNLOADER_MIDDLEWARES = {
'scrapy_splash.SplashCookiesMiddleware': 723,
'scrapy_splash.SplashMiddleware': 725,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}
```
- 3)Enable SplashDeduplicateArgsMiddleware:
```
SPIDER_MIDDLEWARES = {
'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,
}
```
- 4)Set a custom DUPEFILTER_CLASS:
```
DUPEFILTER_CLASS = 'scrapy_splash.SplashAwareDupeFilter'
```
- 5)a custom cache storage backend:
```
HTTPCACHE_STORAGE = 'scrapy_splash.SplashAwareFSCacheStorage'
```
### Splash Shell
透過Shell 可以利用splash 預覽網頁渲染後的狀況,並透過Shell 測試取得相關資料。
```
scrapy shell 'http://localhost:8050/render.html?url=<url>?menu=1&nav=1&subnav=0#link_0&timeout=10&wait=0.5'
```
### Lua_script
Splash 主要腳本透過`lua` 語言撰寫腳本
- [script Doc連結](https://splash.readthedocs.io/en/stable/scripting-tutorial.html)
- [Lua 連結](http://devdoc.net/python/scrapy-splash.html)
滾動頁面範例:
`滾動網頁`
```
function scroll_to(splash, x, y)
local js = string.format(
"window.scrollTo(%s, %s);",
tonumber(x),
tonumber(y)
)
return splash:runjs(js)
end
function get_doc_height(splash)
return splash:runjs([[
Math.max(
Math.max(document.body.scrollHeight, document.documentElement.scrollHeight),
Math.max(document.body.offsetHeight, document.documentElement.offsetHeight),
Math.max(document.body.clientHeight, document.documentElement.clientHeight)
)
]])
end
function scroll_to_bottom(splash)
local y = get_doc_height(splash)
return scroll_to(splash, 0, y)
end
function main(splash)
-- e.g. http://infiniteajaxscroll.com/examples/basic/page1.html
local url = splash.args.url
assert(splash:go(url))
assert(splash:wait(0.5))
splash:stop()
for i=1,10 do
scroll_to_bottom(splash)
assert(splash:wait(0.2))
end
splash:set_viewport("full")
return {
html = splash:html(),
png = splash:png{width=640},
har = splash:har(),
}
end
```
登入範例:
```
function main(splash, args)
assert(splash:go(args.url))
assert(splash:wait(0.5))
local search_input = splash:select('input[name=email]')
search_input:send_text("args.username")
local search_input = splash:select('input[name=pass]')
search_input:send_text("args.password")
assert(splash:wait(0.5))
local login_button = splash:select('button[name=login]')
login_button:click()
assert(splash:wait(3))
return {
url = splash:url(),
html = splash:html(),
cookies = splash.args.cookies,
png = splash:png(),
har = splash:har(),
}
end
```
登入 yield 範例
```
yield scrapy_splash.SplashRequest(
url=self.login_url,
endpoint="execute",
args={
"lua_source": lua_script,
"username": username, # 在Lua腳本中,可透過args.user_name於腳本中取用
"password": password
},
callback=self.second_auth,
)
```
---
# Item pipline
修改`piplines.py`
## Null值處理
確保資料insert時出現Null資料,如果發現就不要儲存或丟棄。
```
from scrapy.exceptions import DropItem
class DeleteNullTitlePipeline(object):
def process_item(self, item, spider):
title = item['title']
if title:
return item
else:
raise DropItem('found null title %s', item)
```
並啟用 ==Function== `DeleteNullTitlePipeline` :
將任務新增至`setting.py`
```
ITEM_PIPELINES = {
'myFirstScrapyProject.pipelines.MyfirstscrapyprojectPipeline': 300,
'myFirstScrapyProject.pipelines.DeleteNullTitlePipeline': 200,
}
```
以數字大小為優先執行,越大越晚執行
再執行程式碼:
`scrapy crawl ptt -o ptt.json`
若收到空值則會看到爬蟲的log出現警告:
```
[scrapy.core.scraper] WARNING: Dropped: ('found null title %s', {'author': '-', 'date': '10/23', 'href': None, 'push': None,'title': None})
{'author': '-', 'date': '10/23', 'href': None, 'push': None, 'title': None}
```
## 重複值處理
範例為:如果發現title一樣就丟棄該item
```
class DuplicatesTitlePipeline(object):
def __init__(self):
self.article = set()
def process_item(self, item, spider):
title = item['title']
if title in self.article:
raise DropItem('duplicates title found %s', item)
self.article.add(title)
return(item)
```
將任務新增至`setting.py`
```
ITEM_PIPELINES = {
'myFirstScrapyProject.pipelines.MyfirstscrapyprojectPipeline': 300,
'myFirstScrapyProject.pipelines.DeleteNullTitlePipeline': 200,
'myFirstScrapyProject.pipelines.DuplicatesTitlePipeline': 400,
}
```
---
# 資料儲存
## Mongo
**安裝pymongo api**
```
pip install pymongo
```
**連結mongo**
```
class MongoDBPipeline:
def open_spider(self, spider):
db_uri = spider.settings.get('MONGODB_URI', 'mongodb://localhost:27017')
db_name = spider.settings.get('MONGODB_DB_NAME', 'ptt_scrapy')
self.db_client = MongoClient('mongodb://localhost:27017')
self.db = self.db_client[db_name]
def process_item(self, item, spider):
self.insert_article(item)
return item
def insert_article(self, item):
item = dict(item)
self.db.article.insert_one(item)
def close_spider(self, spider):
self.db_clients.close()
```
新增任務至`setting.py`
```
MONGODB_URI = 'mongodb://localhost:27017'
MONGODB_DB_NAME = 'ptt_scrapy'
ITEM_PIPELINES = {
'myFirstScrapyProject.pipelines.MongoDBPipeline': 400,
}
```
---
# 備註
使用模組中的模板`basic`創建了一个爬蟲`basic`:
`properties.spiders.basic`
## 問題解決Tips
| 問題 | 方案 |
| -------------------------------------------------- | ------------------------------------- |
| 和抓取的網站有關 | 修改爬蟲 |
| 在特定區域修改或儲存Item,可能在整個項目中使用 | 寫一個ItemPipeline |
| 再特地欲修改或丟棄請求或回應,可能在整個項目中使用 | 寫一個爬蟲中間件,middleware |
| 執行請求回應,如:支援自定義登入或特別處理cookies | 寫一個下載中間件,Download_Middleware |
| 其他問題 | 寫一個擴展 |
---
## 爬蟲遇到的困難
Level 1 js
Level 2 iframe
Level 3 no url
level 4 block turn page
# 新增Proxy 自動變換IP
## 先建立Proxy IP爬蟲取的Proxy名單
本範例使用[US-PROXY](https://www.us-proxy.org/)取得免費Proxy名單
`Proxy_Spider.py`
```
import json
import scrapy
import pandas as pd
import share_config as shc
class Proxy_Spider(scrapy.Spider):
name = "Proxy_Spider"
allowed_domains = 'https://www.us-proxy.org/'
def start_requests(self):
url = 'https://www.us-proxy.org/'
yield scrapy.Request(
url =url,
headers =shc.google_headers,
callback =self.pharse,
dont_filter=True,
)
def pharse(self, response):
r = response
# 取得站內Proxy表格
xp_selector = r.xpath('/html/body/section[1]/div/div[2]/div/table')
html_txt = xp_selector.get()
df = pd.read_html(html_txt)[0]
df['Scheme'] = df['Https'].apply(lambda x: 'https' if x=='yes' else 'http')
combine_http = lambda x: "{scheme}://{ip}:{port}".format(scheme= x['Scheme'],
ip = x['IP Address'],
port = x['Port'])
df['Proxy'] = df[['Scheme','IP Address','Port']].apply(combine_http, axis=1)
df = df.fillna('Null')
df_dict = df.to_dict('records')
df_dict = json.dumps(df_dict, indent=4)
with open('./proxy.json', 'w') as f:
f.write(df_dict)
```
## 建立Proxy_middleware
`middlewares.py`
```
import os
import json
import random
from scrapy import signals
from collections import defaultdict
from scrapy.exceptions import NotConfigured
from scrapy.downloadermiddlewares.httpproxy import HttpProxyMiddleware
class RandomProxyMiddleware(HttpProxyMiddleware):
def __init__(self, auth_encoding="latin-1", proxy_list_file=None):
if (not proxy_list_file) or (not os.path.exists(proxy_list_file)):
raise NotConfigured
self.auth_encoding = auth_encoding
self.proxies = defaultdict(list)
with open(proxy_list_file) as f:
proxy_list = json.load(f)
for proxy in proxy_list:
scheme = proxy["Scheme"]
url = proxy["Proxy"]
self.proxies[scheme].append(self._get_proxy(url, scheme))
@classmethod
def from_crawler(cls, crawler):
auth_encoding = crawler.settings.get("HTTPPROXY_AUTH_ENCODING", "latin-1")
proxy_list_file = crawler.settings.get("PROXY_LIST_FILE")
return cls(auth_encoding, proxy_list_file)
def _set_proxy(self, request, scheme):
creds, proxy = random.choice(self.proxies[scheme])
request.meta["proxy"] = proxy
if creds:
request.headers["Proxy-Authorization"] = b"Basic" + creds
```
`setting.py`
```
DOWNLOADER_MIDDLEWARES = {
'projectname.middlewares.RandomProxyMiddleware':745
}
# Proxy 檔案位置
PROXY_LIST_FILE = './proxy.json'
```
## 測試
建立測試Spider
`Test_Proxy.py`
```
import scrapy
class ProxyExampleSpider(scrapy.Spider):
name = "Test_Proxy"
# start_urls = ['https://httpbin.org/ip']
def start_requests(self):
for i in range(10):
yield scrapy.Request('https://httpbin.org/ip', dont_filter=True)
def parse(self, response):
print(response.meta['proxy'])
print(response.text)
```
- run
```
scrapy crawl Test_Proxy
```
- result:
<img src="https://i.imgur.com/SbTg0Xv.png" width=600>
> 看到ip 有在變就是成功了!