Python —— 一個『拉勾網』的小爬蟲

本文將展示一個 Python 爬蟲,其目標網站是『拉勾網』;題圖是其運行的結果,這個爬蟲通過指定『關鍵字』抓取所有相關職位的『任職要求』,過濾條件有『城市』、『月薪範圍』。並通過百度的分詞和詞性標註服務(免費的),提取其中的關鍵字(如題圖),這個爬蟲有什麼用了?

有那麼一個問題模板,xx 語言 / 方向 xx 月薪需要掌握什麼技能

對於這種問題,招聘網站上的信息大概是最為『公正客觀』,所以這個爬蟲的輸出可以『公正客觀』的作為求職者的技能樹發展指南......個屁;如果全盤相信招聘網上寫的,估計離涼涼就不遠了。其上面寫的東西一般都是泛泛而談,大概率是這樣的場景

先用 5 分鐘,把工作中用的各種系統先寫上去,比如有一個介面調用是 HDFS 寫文件,那就寫上『熟悉 Hadoop 生態和分散式文件系統優先』,這樣顯得工作比較高大上;一定不能讓人看出我們就是一個野雞公司

再用 5 分鐘,寫些 比如『有較強的學習能力』、『責任感強』之類面試官都不一定有(多半沒有)的廢話

最後 5 分鐘,改改錯別字,強調下價值觀之類的,搞定收工

所以這篇文章的目的,不是通過『抓取數據』然後通過對『數據的分析』自動的生成各種職位的『技能需求』。它僅僅是通過一個『短小』、『可以運行的』的代碼,展示下如何抓取數據,並在這個具體實例中,介紹幾個工具和一些爬蟲技巧;引入分詞有兩個目的 1)對分詞有個初步印象,嘗試使用新的工具挖掘潛在的數據價值 2)相對的希望大家可以客觀看待機器學習的能力和適用領域,指望一項技術可以解決所有問題是不切實際的

1 數據源

『拉勾網』

2 抓取工具

Python 3,並使用第三方庫 Requests、lxml、AipNlp,代碼共 100 + 行

  • 安裝 Python 3,Download Python
  • Requests: 讓 HTTP 服務人類 ,Requests 是一個結構簡單且易用的 Python HTTP 庫,幾行代碼就可以發起一個 HTTP 請求,並且有中文文檔
  • Processing XML and HTML with Python ,lxml 是用於解析 HTML 頁面結構的庫,功能強大,但在代碼里我們只需要用到其中一個小小的功能
  • 語言處理基礎技術-百度AI,AipNlp 是百度雲推出的自然語言處理服務庫。其是遠程調用後台介面,而不是使用本地模型運行,所以不能離線使用。之前寫過一篇文章介紹了幾個分詞庫 Python 中的那些中文分詞器,這裡為什麼選用百度雲的分詞服務,是因為經過對拉勾的數據驗證(其實就是拍腦袋),百度雲的效果更好。該服務是免費的,具體如何申請會在 4.4 描述
  • 以上 三個庫 都可以通過 pip 安裝,一行命令

3 實現代碼

見本文末尾 附

4 邏輯拆解

以下過程建議對比 Chrome 或 Firefox 瀏覽器的開發者工具

4.1 拉取『關鍵字』的相關職位列表

通過構造『拉勾網』的搜索 HTTP 請求,拉取『關鍵字』的相關職位列表 1)同時指定過濾條件『城市』和『月薪範圍』2)HTTP 響應的職位列表是 Json 格式,且是分頁結構,需要指定頁號多次請求才能獲取所有相關職位列表

def fetch_list(page_index): headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE} params = {"px": "default", "city": CITY, "yx": SALARY} data = {"first": page_index == 1, "pn": page_index, "kd": KEY} #這是一個 POST 請求,請求的 URL 是一個固定值 https://www.lagou.com/jobs/positionAjax.json #附帶的數據 HTTP body,其中 pn 是當前分頁頁號,kd 是關鍵字 #附帶的 Query 參數,city 是城市(如 北京),yx 是工資範圍(如 10k-15k) #附帶 header,全部是固定值 s = requests.post(BASE_URL, headers=headers, params=params, data=data) return s.json()

這裡會附帶這些 header,是為了避免『拉勾網』的反爬蟲策略。這裡如果移除 referer 或修改 referer 值,會發現得不到期望的 json 響應;如果移除 cookie,會發現過幾個請求就被封了。其返回 json 格式的響應

#列表 json 結構{ ... "content": { "pageNo": 當前列表分頁號 ... "positionResult": { ... resultSize: 該列表的招聘職位數量,如果該值為 0,則代表所有信息也被獲取 result: 數組,該頁中所有招聘職位的相關信息 ... }, } ...}#招聘職位信息 json 結構{ ... "companyFullName": "公司名稱", "city": "城市", "education": "學歷要求", "salary": "月薪範圍", "positionName": "職位名稱", "positionId": "職位 ID,後續要使用該 ID 抓取職位的詳情頁信息"}

通過遍歷返回 json 結構中 ["positionResult"]["result"] 即可得到該頁所有職位的簡略信息

4.2 拉取『某職位』的詳細信息

當通過 4.1 獲取某一頁職位列表時,同時會得到這些職位的 ID。通過 ID,可以獲取這些這些職位的詳細信息

def fetch_detail(id): headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE} url = DETAIL_URL.format(id) #這是一個 GET 請求 #請求的 URL 是 https://www.lagou.com/jobs/職位 ID.html #附帶 header,全部是固定值 s = requests.get(url, headers=headers) #返回的是一個 HTML 結構 return s.text

這個 URL 可以通過瀏覽器直接訪問,比如 爬蟲工程師招聘-360招聘-拉勾網

4.3 從『某職位』的詳細信息中提取『任職要求』

從獲取到的 HTML 中提取該職位的文字描述,這裡是使用 lxml 的 xpath 來提取

//dd[@class="job_bt"]/div/p/text()

這個 xpath 語法,獲取以下 <p> 標籤內的所有內容,返回 [文本內容, 文本內容, 文本內容]

<html>... <dd class="job_bt"> ... <div> ... <p>文本內容</p> <p>文本內容</p> <p>文本內容</p> ... </div> </dd>...</html>

xpath 的基礎語法學習,參考 XPath 教程。它和 css 選擇器語法可以認為是 爬蟲 必須掌握的基本知識

獲取到這些文本數組後,為了提取『任職要求』,使用了一個非常粗暴的正則表達式

w?[.、 ::]?(任職要求|任職資格|我們希望你|任職條件|崗位要求|要求:|職位要求|工作要求|職位需求)

標記文本數組中職位要求的開始,並將後續所有以符號 - 或 數字 開頭的文本認為為『任職要求』。這樣我們就從 爬蟲工程師招聘-360招聘-拉勾網 獲取到『任職要求』

有紮實的數據結構和演算法功底;工作認真細緻踏實,有較強的學習能力,熟悉常用爬蟲工具;熟悉linux開發環境,熟悉python等;理解http,熟悉html, DOM, xpath, scrapy優先;有爬蟲,信息抽取,文本分類相關經驗者優先;了解Hadoop、Spark等大數據框架和流處理技術者優先。

以上提取『任職要求』的方法存在一定的錯誤率,也會遺漏一些。這是因為『拉勾網』的『職位詳情』文本描述多樣性,以及粗暴的正則過濾邏輯導致的。有興趣的同學可以考慮結合實際進行改進

4.4 使用百度 AipNlp 進行分詞和詞性標註

分詞和詞性標註服務非常容易使用

from aip import AipNlpclient = AipNlp(APP_ID, API_KEY, SECRET_KEY)text = "了解Hadoop、Spark等大數據框架和流處理技術者優先。"client.lexer(text)

代碼中,除了調用該介面,會進一步對返回結構進行加工。具體代碼見本文末尾,在 segment 方法中。簡略用文字描述,把結果中詞性為其他專名和命令實體類型詞單獨列出來,其餘名詞性的詞也提取出來並且如果連在一起則合併在一起(這麼做,只是觀察過幾個例子後決定的;工程實踐中,需要制定一個標準並對比不同方法的優劣,不應該像這樣拍腦袋決定)。百度分詞服務的詞性標註含義 自然語言處理-常見問題-百度雲

『任職要求』經過分詞和詞性標註處理後的結果如下

Hadoop/Spark/http/爬蟲/xpath/數據框架/scrapy/信息/數據結構/html/學習能力/開發環境/linux/爬蟲工具/演算法功底/DOM/流處理技術者/python/文本分類相關經驗者

這樣我們就完成了這整套邏輯,通過循環請求 4.1,完成『關鍵字』的所有職位信息的抓取和『任職要求』的提取 / 分析

百度的分詞和詞性標註服務需要申請,申請後得到 APP_ID, API_KEY, SECRET_KEY 並填入代碼從來正常工作,申請流程如下,點擊鏈接 語言處理基礎技術-百度AI

點擊 立即使用,進入登錄頁面 百度帳號(貼吧、網盤通用)

點擊創建應用,隨便填寫一些信息即可

申請後,把 AppID、API Key、Secret Key 填入代碼

5 抓取結果

5 / 6 / 7 沒有『任職要求』輸出,是漏了還是真的沒有?

還是北京工資高,成都只有 1 個可能在 25k 以上的爬蟲職位

6 結語

  • 如果實在不想申請百度雲服務,可以使用其他的分詞庫 Python 中的那些中文分詞器;對比下效果,也許有驚喜
  • 示例實現了一個基本且完整的結構,在這基礎有很多地方可以很容易的修改 1)抓取多個城市以及多個薪資範圍 2)增加過濾條件,比如工作經驗和行業 3)將分詞和爬蟲過程分離,解耦邏輯,也方便斷點續爬 4)分析其他數據,比如薪資和城市關係、薪資和方向的關係、薪資和『任職要求』的關係等
  • Mac 上實現的,Windows 沒測過,理論上應該同樣沒問題。如果有同學爬過並願意給我說下結果,那實在太感謝了
  • 寫爬蟲,有個節操問題,不要頻次太高。特別這種出於興趣的代碼,裡面的 sleep 時間不要改小

附 代碼和部分注釋

#coding: utf-8import timeimport reimport urllib.parseimport requestsfrom lxml import etreeKEY = "爬蟲" #抓取的關鍵字CITY = "北京" #目標城市# 0:[0, 2k), 1: [2k, 5k), 2: [5k, 10k), 3: [10k, 15k), 4: [15k, 25k), 5: [25k, 50k), 6: [50k, +inf)SALARY_OPTION = 3 #薪資範圍,值範圍 0 ~ 6,其他值代表無範圍#進入『拉勾網』任意頁面,無需登錄#打開 Chrome / Firefox 的開發者工具,從中複製一個 Cookie 放在此處#防止被封,若無法拉取任何信息,首先考慮換 CookieCOOKIE = "JSESSIONID=ABAAABAACBHABBI7B238FB0BC8B6139070838B4D2D31CED; _ga=GA1.2.201890914.1522471658; _gat=1; Hm_lvt_4233e74dff0ae5bd0a3d81c6ccf756e6=1522471658; Hm_lpvt_4233e74dff0ae5bd0a3d81c6ccf756e6=1522471674; user_trace_token=20180331124738-a3407f45-349e-11e8-a62b-525400f775ce; LGSID=20180331124738-a34080db-349e-11e8-a62b-525400f775ce; PRE_UTM=; PRE_HOST=; PRE_SITE=; PRE_LAND=https%3A%2F%2Fwww.lagou.com%2F; LGRID=20180331124753-ac447493-349e-11e8-b664-5254005c3644; LGUID=20180331124738-a3408251-349e-11e8-a62b-525400f775ce; _gid=GA1.2.24217288.1522471661; index_location_city=%E6%88%90%E9%83%BD; TG-TRACK-CODE=index_navigation"def init_segment(): #按照 4.4 的方式,申請百度雲分詞,並填寫到下面 APP_ID = "xxxxxxxxx" API_KEY = "xxxxxxxxx" SECRET_KEY = "xxxxxxxxx" from aip import AipNlp #保留如下詞性的詞 https://cloud.baidu.com/doc/NLP/NLP-FAQ.html#NLP-FAQ retains = set(["n", "nr", "ns", "s", "nt", "an", "t", "nw", "vn"]) client = AipNlp(APP_ID, API_KEY, SECRET_KEY) def segment(text): 對『任職信息』進行切分,提取信息,並進行一定處理 try: result = [] #調用分詞和詞性標註服務,這裡使用正則過濾下輸入,是因為有特殊字元的存在 items = client.lexer(re.sub(s, , text))["items"] cur = "" for item in items: #將連續的 retains 中詞性的詞合併起來 if item["pos"] in retains: cur += item["item"] continue if cur: result.append(cur) cur = "" #如果是 命名實體類型 或 其它專名 則保留 if item["ne"] or item["pos"] == "nz": result.append(item["item"]) if cur: result.append(cur) return result except Exception as e: print("fail to call service of baidu nlp.") return [] return segment#以下無需修改,拉取『拉勾網』的固定參數SALARY_INTERVAL = ("2k以下", "2k-5k", "5k-10k", "10k-15k", "15k-25k", "25k-50k", "50k以上")if SALARY_OPTION < len(SALARY_INTERVAL) and SALARY_OPTION >= 0: SALARY = SALARY_INTERVAL[SALARY_OPTION]else: SALARY = NoneUSER_AGENT = "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/534.55.3 (KHTML, like Gecko) Version/5.1.5 Safari/534.55.3"REFERER = "https://www.lagou.com/jobs/list_" + urllib.parse.quote(KEY)BASE_URL = "https://www.lagou.com/jobs/positionAjax.json"DETAIL_URL = "https://www.lagou.com/jobs/{0}.html"#抓取職位詳情頁def fetch_detail(id): headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE} try: url = DETAIL_URL.format(id) print(url) s = requests.get(url, headers=headers) return s.text except Exception as e: print("fetch job detail fail. " + url) print(e) raise e#抓取職位列表頁def fetch_list(page_index): headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE} params = {"px": "default", "city": CITY, "yx": SALARY} data = {"first": page_index == 1, "pn": page_index, "kd": KEY} try: s = requests.post(BASE_URL, headers=headers, params=params, data=data) return s.json() except Exception as e: print("fetch job list fail. " + data) print(e) raise e#根據 ID 抓取詳情頁,並提取『任職信息』def fetch_requirements(result, segment): time.sleep(2) requirements = {} content = fetch_detail(result["positionId"]) details = [detail.strip() for detail in etree.HTML(content).xpath(//dd[@class="job_bt"]/div/p/text())] is_requirement = False for detail in details: if not detail: continue if is_requirement: m = re.match("([0-9]+|-)s*[.::、]?s*", detail) if m: words = segment(detail[m.end():]) for word in words: if word not in requirements: requirements[word] = 1 else: requirements[word] += 1 else: break elif re.match("w?[.、 ::]?(任職要求|任職資格|我們希望你|任職條件|崗位要求|要求:|職位要求|工作要求|職位需求)", detail): is_requirement = True return requirements#循環請求職位列表def scrapy_jobs(segment): #用於過濾相同職位 duplications = set() #從頁 1 開始請求 page_index = 1 job_count = 0 print("key word {0}, salary {1}, city {2}".format(KEY, SALARY, CITY)) stat = {} while True: print("current page {0}, {1}".format(page_index, KEY)) time.sleep(2) content = fetch_list(page_index)["content"] # 全部頁已經被請求 if content["positionResult"]["resultSize"] == 0: break results = content["positionResult"]["result"] total = content["positionResult"]["totalCount"] print("total job {0}".format(total)) # 處理該頁所有職位信息 for result in results: if result["positionId"] in duplications: continue duplications.add(result["positionId"]) job_count += 1 print("{0}. {1}, {2}, {3}".format(job_count, result["positionName"], result["salary"], CITY)) requirements = fetch_requirements(result, segment) print("/".join(requirements.keys()) + "
") #把『任職信息』數據統計到 stat 中 for key in requirements: if key not in stat: stat[key] = requirements[key] else: stat[key] += requirements[key] page_index += 1 return statsegment = init_segment()stat = scrapy_jobs(segment)#將所有『任職信息』根據提及次數排序,輸出前 10 位import operatorsorted_stat = sorted(stat.items(), key=operator.itemgetter(1))print(sorted_stat[-10:])

歡迎關注我的專欄

面向工資編程 —— 收錄 Java 語言的面試向文章?

zhuanlan.zhihu.com圖標編程的基礎和一些理論?

zhuanlan.zhihu.com圖標編程的日常娛樂 —— 用 Python 幹些有趣的事?

zhuanlan.zhihu.com圖標
推薦閱讀:

有哪些有趣又優美的編程語言?
目前市場上,你用過的最好用的文本編輯器是什麼?
偽·從零開始學Python - 2.1 面向過程的程序設計簡述
設計模式 0 章-概要

TAG:Python | 編程 | 分詞 |