使用structure_spider多請求組合抓取結構化數據
大家好,好久沒寫爬蟲相關的文章了,今天給大家帶來了一個二次開發框架ShichaoMa/structure_spider。方便快速整合多種請求並返回擁有一定層次結構的數據。
之所以研究這個課題,是因為之前在我使用我開發的框架web-walker進行amazon抓取時,經常會遇到以下問題:
- 抓取一個amazon官網上的商品,商品擁有多個尺碼,顏色(每個兩個組合稱之為單品),每個單品的屬性(尺碼,顏色,價格,是否有貨等等)往往是不同的,而商品頁面無法得到單品信息,需要前往單品頁面抓取每個單品的屬性然後返回,放到商品的欄位skus中。
- 單品有時候是缺貨的(amazon自營缺貨),這時需要前往三方賣家去抓取相關屬性。
- 有時有很多頁三方賣家,我們需要查找到出售此單品的所有賣家(比價),需要發送多次請求來獲取全部賣家的商品信息。
因此需要形成的數據結構可能是這樣的:
{"商品id": "xxxx","標題", "xxxx","描述": "xxx","圖片鏈接": ["xxx","xxx"],...."skus": { "單品id": "", "價格": "$12", "尺寸": "10", "顏色": "red", "是否有貨": false, "運費": "$10", "其它賣家": [ { "價格": "$13", "運費": "free", "商家": "asb", "信用": "4", }, { "價格": "$13", "運費": "free", "商家": "fd", "信用": "4", }, { "價格": "$13", "運費": "free", "商家": "fds", "信用": "4", }, { "價格": "$13", "運費": "free", "商家": "asd", "信用": "4", }, { "價格": "$13", "運費": "free", "商家": "fdsf", "信用": "4", }, { "價格": "$13", "運費": "free", "商家": "fdasaf", "信用": "4", }, .... ] }}
那麼,如果我們抓取一個完整的商品信息,至少需要發送三種請求:
- 商品請求,1個
- 單品請求,1-600個(我在amazon見過最多的一個鞋子有600個單品)
- 三方請求,1-10個(見過最暢銷的鞋也就十頁第三方賣家的樣子)
由此形成了一個樹形結構,抓取一個完整的商品數據請求總數為1x2x3。
那麼如何通過最簡單明了的代碼,實現一個商品的抓取呢?
structure_spider就是在這種背景下產生的,通過簡單的幾個enrich函數,實現請求樹的遍歷。並最終返回特定層次的結構化數據。
下面通過抓取豆瓣電影來向大家演示他的用法。
用例相當複雜,慎讀!
需要知識:
- scrapy: item_loader的使用
- xpath,正則表達式的使用
首先讓我們進行簡單的需求分析
如果將一部電影看成一個商品,那麼通過商品頁可以抓取的內容有,標題,電影規格,評分,簡介等。
我們希望進一步抓取電影的影人,圖片,短評,問題,影評。
影人:
現在通過商品頁無法獲取全部的影人,只能得到6個和與之相關的非常簡單的信息。因此,我要點擊`全部`,跳轉到全部影人頁面去抓取。
圖片:
同上。
短評:
點開短評以後,我們發現短評有很多,所以分頁了。
那麼我們怎麼抓取全部短評呢?方法是我在跳到短評頁後,抓取當前頁的短評,繼續翻頁,抓取下一頁短評,直到翻到最後一頁為止。
問題:
點開問題後,我們發現問題列表頁是這樣的。
在列表頁並不能像短評那樣,能直接抓取到一個短評的完整信息,而是需要繼續點進問題。
同時問題列表會存在和短評一樣的翻頁。
讓我們進入一個問題。
我們問題下擁有很多回答。回答可能還會存在分頁。一起抓走。
咦!回答下面好像好有回應哎
很明顯回應是非同步請求載入的。繼續發請求。
影評:
全部影評頁面是這樣的
同樣擁有翻頁。
點進一個影評的頁面是這樣的
同樣擁有評論,但好像是比抓取問題簡單了一層,評論沒有回應的功能(其實有,不過回應也算評論)。
好了,需要分析完了,我們要抓取的數據結構如下:
{ "標題": "", "規格": "", "影人": [ { "名稱": "xx", "身份": "xx", "代表作": [xx, xx], "飾": "xx", }, ], "短評": [ { "作者": "xx", "內容": "xx", "時間": "xx", "評分": "xx", "贊同": "xx", } ], "簡介": "", "問題": [ { "回答": [ "作者": "xx", "內容": "xx", "時間": "xx", "回應": [ { "作者": "xx", "內容": "xx", "時間": "xx", } ], "贊同": "xx", ], "作者": "xx", "內容": "xx", "時間": "xx", "標題": "xx", } ], "推薦": ["xx", "xx",], "圖片": { "壁紙": ["xx"], "海報": ["xx"], "劇照": ["xx"], }, "影評": [ { "作者": "xx", "評論": [ { "作者": "xx", "內容": "xx", "時間": "xx", "回復給誰": "xx", "回復的內容": "xx", } ], "內容": "xx", "時間": "xx", "反對": "xx", "評分": "xx", "標題": "xx", "贊同": "xx", } ], "得分": "x",}
好,讓我開始寫代碼
首先,安裝structure-spider
cn@ubuntu:~/projects$ pip install structure-spider
生成項目
cn@ubuntu:~/projects$ startproject myappNew structure-spider project myapp, using template directory /home/cn/.pyenv/versions/3.6.0/lib/python3.6/site-packages/structor/templates/project, created in: /home/cn/projects/myappYou can start the spider with: cd myapp scrapy crawl douban
自帶了我之前寫好的douban spider
打開douban_item.py
這裡面定義了所有需要的item類
item類的定義方式如下
- 根item的公共父類為BaseItem。用來提供最基本的信息,根 item必須繼承BaseItem, child item可以不繼承於BaseItem。
- item中定義所有item屬性prop = Field(...)。
- Field定義如下:
- input_processor: processor函數,參見Scrapy Item Processor
- output_processor:默認為TakeFirst(),可以重寫該processor。
- default:當prop值為空時為該欄位提供默認值。
- order:對prop進行排序,有些prop依賴於之前的prop,這種情況下,對這兩個屬性進行排序是有必要的,默認order=0。
- skip: 是否在item中略過此prop,默認skip=False。
在douban spider中,我們定義了FilmItem, CommentBaseItem, QuestionItem, AnswerItem, ReviewItem
等等,好像出現了什麼問題,我們剛才分析需求時,需要發送的請求各種各樣,有抓取全部影人的請求,有抓取全部短評的請求,有抓取全部圖片的請求,有抓取全部問題的請求,有抓取問題詳情的請求,有抓取問題詳情下全部回答的請求,有抓取回答中的全部回應的請求....
可是為什麼只定義了4種item(CommentBaseItem是自定義基類)呢,按理說遠遠不夠呀
請記住一點,對於剛才的數據結構,葉子節點請求不需要定義item,比如短評,影人,圖片請求。其中並沒有產生新的結構,所以不需要定義item來存放他們。
而對於問題,描述,問題下的回答,由於這三種請求不是葉子節點。所以需要定義item。
好了,分析完了item,再分析spider,打開douban_spider.py
get_base_loader是必須提供的靜態函數,返回根item對應的item_loader對象
@staticmethod def get_base_loader(response): return CustomLoader(item=FilmItem())
enrich_data也是必須提供的第一個渲染函數,用來使用第一次響應渲染根item,發現新的請求,所有enrich函數都需要使用enrich_wrapper裝飾。
@enrich_wrapper def enrich_data(self, item_loader, response): self.logger.debug("Start to enrich_data. ") item_loader.add_value("product_id", response.url, re=r"subject/(d+)/") item_loader.add_xpath("title", //h1/span/text()) item_loader.add_xpath("info", //div[@id="info"]) item_loader.add_xpath("score", //strong[@class="ll rating_num"]/text()) item_loader.add_xpath("recommendations", //div[@class="recommendations-bd"]/dl/dd/a/text()) item_loader.add_xpath("description", //div[@id="link-report"]/span/text()) nodes = list() celebrities_url = xpath_exchange(response.xpath(//div[@id="celebrities"]/h2/span/a/@href)) if celebrities_url: nodes.append(("celebrities", item_loader, {"url": response.urljoin(celebrities_url)})) related_pic_urls = response.xpath(//div[@id="related-pic"]/h2/span[@class="pl"]/a/@href).extract() if len(related_pic_urls) >= 2: related_pic_url = related_pic_urls[-2] else: related_pic_url = "" if related_pic_url: nodes.append(("related_pics", item_loader, {"url": related_pic_url})) comments_url = xpath_exchange(response.xpath(//div[@id="comments-section"]//h2/span/a/@href)) if comments_url: nodes.append(("comments", item_loader, {"url": comments_url})) questions_url = xpath_exchange(response.xpath(//div[@id="askmatrix"]/div/h2/span/a/@href)) if questions_url: nodes.append(("questions", item_loader, {"url": questions_url})) reviews_url = xpath_exchange(response.xpath(//section[@class="reviews mod movie-content"]//h2/span/a/@href)) if reviews_url: nodes.append(("reviews", item_loader, {"url": response.url.split("?")[0] + reviews_url})) response.meta["item_collector"].extend(nodes)
通過閱讀函數我們發現,在添加了幾個簡單屬性後,我們開始進一步發現接下來需要發送的請求url,並將其連同屬性名,item_loader,組成三元組添加到一個列表中。最後,將他們添加到response.meta["item_collector"]中。
在該函數返回後,程序會從剛才添加的url中pop出最後添加的那一個url,組成請求,返迴響應。並調用enrich_`prop`方法,因此我們得知,下一個enrich方法是enrich_reviews
@enrich_wrapper def enrich_reviews(self, item_loader, response): self.logger.debug("Start to enrich_reviews. ") nodes = list() next_url = xpath_exchange(response.xpath(//span[@class="next"]/a/@href)) if next_url: nodes.append(("reviews", item_loader, {"url": response.urljoin(next_url)})) reviews = response.xpath(//div[@class="review-list"]/div/div[@class="main review-item"]) for review in reviews: nodes.append( ("review", CustomLoader(item=ReviewItem()), {"url": xpath_exchange(review.xpath(header/h3/a/@href))})) response.meta["item_collector"].extend(nodes)
該請求返回的頁面是全部影評頁面,這個頁面沒有有用的屬性信息,我們只能通過他得到每個影評的詳情頁鏈接和下一頁影評鏈接。我們之前了解到,加入nodes中的url會先入後出。因此我們希望每個review抓取完畢後,再抓取下一頁的鏈接。因此把下一頁的鏈接放在前面。
讓我們仔細看這行代碼
("review", CustomLoader(item=ReviewItem()), {"url": xpath_exchange(review.xpath(header/h3/a/@href))}))
我們創建了一個新的item_loader,並使用了ReviewItem作為接下來的數據容器
根據先入後出原則,接下來請求的響應需要調用enrich_review方法。
@enrich_wrapper def enrich_review(self, item_loader, response): self.logger.debug("Start to enrich_review. ") item_loader.add_xpath("title", //h1/span/text()) item_loader.add_xpath("content", //div[@id="link-report"]/div) item_loader.add_xpath("author", //div[@class="article"]/div/div/header/a/span/text()) item_loader.add_xpath("datetime", //div[@class="article"]/div/div/header/span[@class="main-meta"]/text()) item_loader.add_xpath("score", //div[@class="article"]/div/div/header/span[contains(@class, "rating")]/@class, re=r"allstar(d+)") item_loader.add_xpath("upvotes", //div[@class="main-ft"]/div/div/button[1]/text(), re=r"(d+)") item_loader.add_xpath("downvotes", //div[@class="main-ft"]/div/div/button[2]/text(), re=r"(d+)") comments = response.xpath(//div[@id="comments"]/div[@class="comment-item"]) comment_list = list() for comment_div in comments: comment = dict() comment["author"] = xpath_exchange(comment_div.xpath(div//div[@class="header"]/a/text())) comment["datetime"] = xpath_exchange(comment_div.xpath(div//div[@class="header"]/span/text())) comment["content"] = xpath_exchange(comment_div.xpath(div/p/text())) comment["reply-quote"] = xpath_exchange(comment_div.xpath(div/div[@class="reply-quote"]/span[@class="all"]/text())) comment["reply"] = xpath_exchange(comment_div.xpath(div/div[@class="reply-quote"]/span/a/text())) comment_list.append(comment) item_loader.add_value("comments", comment_list)
該item_loader就是剛才的 CustomLoader(item=ReviewItem()),我們為他添加屬性。
同時我們可以為他添加評論。評論中不涉及新的數據結構,所以評論僅僅使用dict就可以實現。
當所有review被pop完畢後。那麼就到了最初在enrich_data中放入到item_collector中優先順序僅次於reviews的questions,我們可以知道該請求的響應要調用enrich_questions函數
@enrich_wrapper def enrich_questions(self, item_loader, response): self.logger.debug("Start to enrich_questions. ") nodes = list() next_url = xpath_exchange(response.xpath(//span[@class="next"]/a/@href)) if next_url: nodes.append(("questions", item_loader, {"url": response.urljoin(next_url)})) qustions = response.xpath(//div[@class="questions"]/div[@class="item"]) for question in qustions: nodes.append(("question", CustomLoader(item=QuestionItem()), {"url": xpath_exchange(question.xpath(h3/a/@href))})) response.meta["item_collector"].extend(nodes)
和reviews一樣,該響應返回的僅是一個question列表。我們還需要繼續往下走。
enrich_question是question詳情頁的回調方法
@enrich_wrapper def enrich_question(self, item_loader, response): self.logger.debug("Start to enrich_question. ") item_loader.add_xpath("title", //div[@class="article"]/h1/text()) item_loader.add_xpath("content", //div[@id="question-content"]/p/text()) item_loader.add_xpath("author", //div[@class="article"]/p[@class="meta"]/a/text()) item_loader.add_xpath("datetime", //div[@class="article"]/p[@class="meta"]/text(), lambda values: values[-1]) answer_url = response.url.split("?")[0] + "answers/?start=0&limit=20" response.meta["item_collector"].add(("answers", item_loader, {"url": answer_url}))
在添加完必要信息後,我們把answers對應的url三元組放入item_collector中。下一個enrich函數是enrich_answers,你懂得。
@enrich_wrapper def enrich_answers(self, item_loader, response): data = safely_json_loads(response.body) nodes = list() for answer in data["answers"]: answer_item_loader = CustomLoader(item=AnswerItem()) answer_item_loader.add_value("upvotes", answer["useness"]) answer_item_loader.add_value("author", answer["user"]["name"]) answer_item_loader.add_value("datetime", answer["created_at"]) answer_item_loader.add_value("content", replace_entities(answer["content"])) num_of_comments = answer["num_of_comments"] if num_of_comments: answer_id = answer["id"] for start in range(0, num_of_comments, 20): reply_url = "%sanswers/%s/comments/?start=%s" % (response.url.split("?")[0], answer_id, start) nodes.append(("replies", answer_item_loader, {"url": reply_url})) else: item_loader.add_value("answers", answer_item_loader.load_item()) response.meta["item_collector"].extend(nodes)
在進行完必須信息填充後,由於answer下有reply這樣新的結構,我們只能組建answer_item_loader,接著,我們開始抓取answer對應的reply,這裡將rely每20一頁,將所有rely分類頁全部放到了item_collector中。對於沒有reply的answer,直接load_item並將其添加到父item_loader中。
@enrich_wrapper def enrich_replies(self, item_loader, response): self.logger.debug("Start to enrich_replies. ") data = safely_json_loads(response.body) item_loader.add_value("replies", data["comments"])
enrich_replies 接收的響應是ajax請求返回的。所以我未做處理直接將其添加到item_loader中。
寫到這裡,代碼解釋部分基本上也該結束了。剩餘的不過是上面的重複。讓我們開始運行一下吧。
由於抓取流行電影會產生大量請求,而豆瓣面對大量請求時,會要求提供驗證碼,而驗證碼的處理不是這篇文章的知識點。所以我們選一個比較冷門的電影抓取一下。
cn@ubuntu:~/projects/myapp$ echo "https://movie.douban.com/subject/26746377/" > a.txtcn@ubuntu:~/projects/myapp$ feed -uf a.txt -s douban -c test
結果如下:
大致的情況就是這樣了。
我使用了mongo pipline,如果報錯證明你沒有安裝mongo,將mongo換成其它的即可。
使用豆瓣做為例子是為了讓大家可以學習該框架的使用方法,而不是一定要抓取豆瓣。通過這個框架,我們可能輕鬆的實現各種網站組合請求的抓取。
終於寫完了,感謝閱讀。
github: ShichaoMa/structure_spider
個人主頁:夏洛之楓的個人博客
歡迎學習和交流!
推薦閱讀:
※坑爹的Scrapy在Py3.5下的處理(Anaconda環境)
※從零開始寫Python爬蟲 --- 爬蟲應用:IT之家熱門段子(評論)爬取
※【實戰視頻教程】使用scrapy爬知乎live信息
※從零開始寫Python爬蟲 --- 爬蟲實踐:螺紋鋼數據&Cookies
※互聯網金融爬蟲怎麼寫-第一課 p2p網貸爬蟲(XPath入門)