歡迎光臨
每天分享高質量文章

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):
   essay-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)
   #附帶 essay-header,全部是固定值
   s = requests.post(BASE_URL, essay-headers=essay-headers, params=params, data=data)

   return s.json()

這裡會附帶這些 essay-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):
   essay-headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE}
   url = DETAIL_URL.format(id)
   
   #這是一個 GET 請求
   #請求的 URL 是 https://www.lagou.com/jobs/職位 ID.html
   #附帶 essay-header,全部是固定值
   s = requests.get(url, essay-headers=essay-headers)

   #傳回的是一個 HTML 結構
   return s.text

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

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

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

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

這個 xpath 語法,獲取以下

標簽內的所有內容,傳回 [‘文字內容’, ‘文字內容’, ‘文字內容’]:

<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 AipNlp
client = 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-8

import time
import re
import urllib.parse

import requests
from lxml import etree

KEY = "爬蟲" #抓取的關鍵字
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 放在此處
#防止被封,若無法拉取任何資訊,首先考慮換 Cookie
COOKIE = "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 = None
USER_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):
   essay-headers = {"User-Agent": USER_AGENT, "Referer": REFERER, "Cookie": COOKIE}
   try:
       url = DETAIL_URL.format(id)
       print(url)
       s = requests.get(url, essay-headers=essay-headers)

       return s.text
   except Exception as e:
       print("fetch job detail fail. " + url)
       print(e)
       raise e

#抓取職位串列頁
def fetch_list(page_index):
   essay-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, essay-headers=essay-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()) + "\n")
           #把『任職資訊』資料統計到 stat 中
           for key in requirements:
               if key not in stat:
                   stat[key] = requirements[key]
               else:
                   stat[key] += requirements[key]

       page_index += 1
   return stat

segment = init_segment()
stat = scrapy_jobs(segment)

#將所有『任職資訊』根據提及次數排序,輸出前 10 位
import operator
sorted_stat = sorted(stat.items(), key=operator.itemgetter(1))
print(sorted_stat[-10:])

本文轉載自

知乎專欄:https://zhuanlan.zhihu.com/p/35140404

作者:鄧卓

《Python人工智慧和全棧開發》2018年07月23日即將在北京開課,120天衝擊Python年薪30萬,改變速約~~~~

*宣告:推送內容及圖片來源於網路,部分內容會有所改動,版權歸原作者所有,如來源資訊有誤或侵犯權益,請聯絡我們刪除或授權事宜。

– END –


更多Python好文請點選【閱讀原文】哦

↓↓↓

贊(0)

分享創造快樂