Photo by Andrey Tikhonovskiy on Unsplash

手把手python爬蟲教學(三): 多執行緒、正則表達式、資料庫

LUFOR129
17 min readSep 16, 2020

今天來介紹一下爬蟲常見的小技巧,有了 多執行緒與正則表達式可以讓你爬蟲爬的更加順心與快速,最後我們再稍微提一下如何與資料庫結合。我們這篇文章就來爬取mobile 01的遊戲區討論文章吧 !

進入電腦討論區內有相當多的文章,每篇文章又包含樓主與回文,我們今天要把所有的樓主與回文的內容文字爬下來,約莫爬取10大頁左右(一大頁有30篇討論串,共300篇討論串)

詳細爬取細節可以參考我的爬蟲教學(一),這裡就不多做介紹了。這次爬的文件要存進資料庫,所以我會習慣先把爬到的東西轉為間單的物件,格式如下:

class ReplyBlock:
def __init__(self,author,content):
self.author = author
self.content = content
class PostPage:
def __init__(self,author,content,title):
self.author = author
self.content = content
self.title = title
self.replys = []

def getReply(self,author,content):
self.replys.append(ReplyBlock(author,content))

就是一個Post_page包含作者、內文、標題與需多的回應文。那爬取的程式如下:

結果在我的電腦上總計需要消耗約1分的時間左右

一、保存資料庫

這裡介紹簡單的sqlite,sqlite是一個輕量型的關聯式資料庫常被用在嵌入式、本地/客戶端儲存資料。那麼我們來安裝window版吧!

下載上面那兩個,並解壓。把所有解壓丟到同一個資料夾內,並記住路徑。

設定環境變數

打開win終端機後輸入sqlite3應該可以出現東西。

.exit退出

詳細指令參考: https://www.runoob.com/sqlite/sqlite-commands.html

[補充] linux自帶sqlite3,輸入sqlite3來確認,沒有就sudo apt-get install sqlite3 libsqlite3-dev。

python內建sqlite3,可以測試一下:

沒報錯代表ok

簡單介紹一下sqlite3 的command line用法

# sqlite基本不區分大小寫,並用;代表結束# 進入sqlite3 特定db (test)
sqlite3 test.db
# 不特定 db (sqlite3 command line)
sqlite3
# sqlite3 command line 內進入特定db
> .open test.db
# 查詢目前db
> .databases
# 查詢目前table表
> .tables
# 建立table表
> CREATE TABLE Post_page(
...> ID INT PRIMARY KEY NOT NULL,
...> AUTHOR TEXT,
...> TITLE TEXT,
...> CONTENT TEXT);
# 刪除table
> DROP TABLE Post_page;
# 查看表訊息
> schema Post_page
# 插入表
> INSERT INTO Post_page(ID,AUTHOR,TITLE,CONTENT) VALUES (1,'作者','標題','內文');
# 查詢
> SELECT * FROM Post_page

更多詳細內容可以參見

以上這些複雜操作,我們可以用python sqlite3來執行(execute)他。

建立資料庫:

import sqlite3# 連接到資料庫,沒有則會創建
conn = sqlite3.connect('e:/program/sqlite/db/mobile.db')
# db 指標
cursor = conn.cursor()
def createTable():
cursor.execute('''
CREATE TABLE Post_page(
ID INTEGER PRIMARY KEY AUTOINCREMENT,
AUTHOR TEXT,
TITLE TEXT,
CONTENT TEXT
);
''')
cursor.execute('''
CREATE TABLE Reply_block(
ID INTEGER PRIMARY KEY AUTOINCREMENT,
AUTHOR TEXT,
CONTENT TEXT,
Post_page_ID INT,
FOREIGN KEY(Post_page_ID) REFERENCES Post_page(ID)
);
''')

conn.commit()
def dropTable():
cursor.execute('''
DROP TABLE Post_page;
''')
cursor.execute('''
DROP TABLE Reply_block;
''')
conn.commit()

插入資料:

def insertDB(post):
Postpage_temp = 'INSERT INTO Post_page(AUTHOR,TITLE,CONTENT) VALUES(?,?,?);'
Replyblock_temp = 'INSERT INTO Reply_block(AUTHOR,CONTENT,Post_page_ID) VALUES(?,?,?);'
cursor.execute(Postpage_temp,(post.author,post.title,post.content))

replys = [(reply.author,reply.content,cursor.lastrowid) for reply in post.replys]
cursor.executemany(Replyblock_temp,replys)
print("finish insert "+post.title)
conn.commit()

那麼我們就可以改寫最上面的程式變成插入資料庫了。

約莫跑了70秒左右

二、 正則表達式

正則表達式regular express是文本過濾一個非常非常重要的東西。不只是python,幾乎所有高階語言都會支持正則,那麼就來做一個簡單的正則表達介紹吧! 在爬蟲中應用可以針對要提取的文字做預先篩選或是等爬完後再做篩選都是不錯的選擇。

正則表達式可以在 https://regex101.com/ 做練習。基本上規則我整理如下,記住最重要的是()[]{},各有各的不同意思:

^ : 整句開頭
$ : 整句結尾
給full match用的
因此 ^XXX$ 就是整段必須是XXX才可匹配[] : 匹配中括號中其中一個字符

[abc] : 匹配a or b or c 其中之一
[a-zA-Z] : 匹配a~zA~Z
[^a-zA-Z] : 在中括號內的 ^ 代表取反

{} 設定匹配個數
[a-zA-Z]{3} : 匹配a~zA~Z 3個
{3,} : 3個含以上
{3,6} : 3~6個

. : 表示匹配任意字符 (除了enter)
\d : 任意數字 相當於 [0-9]
\D : 任意字符除了數字

因此[\d\D] 相當於 .

\w : [a-zA-Z0-9_] 任意aA數字
\W : [^a-zA-Z0-9_]

\s : 匹配特殊字符 [\r\n\t\f\v]
\S : 非特殊字符

? : 匹配0次~1次相當於{0,1}
* : 0次~無窮次 {0,}
+ : 1次~無窮次 {1,}

匹配郵件帳號
^[a-zA-Z0-9]\w*@gmail\.com$

^[a-zA-Z0-9] 是開頭不得為_
\w* 後面字串出現0~無窮次
\. 是跳脫符
@gmail\.com 為結尾

正則寫的好,清理文件沒煩惱; 正則寫不好,if-else寫到老。那麼小刮()是幹嘛的呢? 小括弧用做group,group是抓出字串內的匹配物,在小刮()裡面?有時代表特殊還涵義喔,詳細如下。

group是抓出字串內符號
s55555@mail.nuk.edu.tw
用 ()抓出
(^[a-zA-Z0-9]\w+)@mail\.nuk\.edu\.tw$
抓出 s55555 group 1 , group是full match
(?<fit>^[a-zA-Z0-9]\w+)@mail\.nuk\.edu\.tw$
在小括號開頭?<XX> 是命名組
result = "s55555@mail.nuk.edu.tw".match(/(?<fit>^[a-zA-Z0-9]\w+)@mail\.nuk\.edu\.tw$/)result.group[1] = s59654655
result.groups.fit = s59654655
group 可以在當前regex引用
引用 \數字
ex: 1212
正常: ^\d\d\d\d
我們想要找到兩兩成雙成對,後面pair等於前面pair
^(\d\d)\1 : \1相當於group1,而group 1 為\d\d
\k<group> 為複用
^(?<fit>\d\d)\k<fit> :命名為fit後接下來要接一樣的東西,所以可以匹配1212
**高級運用**:
EX: foobar,fooboo
foo(?=bar) : 想要找到bar前面的foo ?= Positive Lookhead
foo(?!bar) : 想要找到foo後面不等於bar,所以會匹配到fooboo的foo Negative Lookhead
EX: barfoo,boofoo
(?<=bar)foo : 要找foo但要出現在bar後面 Positve Lookbehind
(?<!bar)foo : 要找foo但不出現在bar後面 Negative Lookbehind

那麼來實際看看python如何利用regular express吧。先來準備我們要匹配的字串,我們可以從建立好的資料庫內撈出:

import sqlite3# 連接到資料庫,沒有則會創建
conn = sqlite3.connect('e:/program/sqlite/db/mobile.db')
# db 指標
cursor = conn.cursor()
content_sql = "SELECT Post_page_ID,CONTENT FROM Reply_block"
rows = cursor.execute(content_sql)
for row in rows:
print(row)

出來會是(foreign_key,content)的tuple,如下:

python 的re該如何操作呢? 主要分成以下幾個method

  • match是從字串開頭開始匹配,如果匹配失敗就回傳none。
  • search是掃描字串做匹配,返回第一個成功匹配,失敗則none。
  • sub 替換被匹配的字串
  • findall 所有匹配成功的字串陣列
  • split 依照受匹配字串做分割字串

我們來利用match找出開頭是引用他人的內容字串吧! (mobile引用他人會試XXX wrote:)

import recontent_sql = "SELECT Post_page_ID,CONTENT FROM Reply_block"
rows = cursor.execute(content_sql)
# \w 為任意數字
# + {1,}一到多
# wrote: 固定字
# 前面加個r 代表 reg express
for row in rows:
if(re.match(r'\w+ wrote:',row[1])):
print(row)
結果

如果我想匹配出現youtube網址的連結呢?(https://www.youtube.com/XX)。可以用search匹配字串內的網址。然後我們將他提取出來。

content_sql = "SELECT Post_page_ID,CONTENT FROM Reply_block"
rows = cursor.execute(content_sql)
# 就是watch後面接任一字但是必須用英文結尾
# group(0)提取
reg_search = 'https:\/\/www\.youtube\.com\/watch.+\w'
for row in rows:
if(re.search(reg_search,row[1])):
print(re.search(reg_search,row[1]).group(0))
結果

更多內容可以參考自:

三、多執行緒 Threading

說到python的多執行緒,其實是個假多執行緒。python的執行受到python GIL(Global Interpreter Lock),這讓python在執行時只能有一個執行續,所以其實python爬蟲跑多執行緒通常時間會差不多甚至上升(python多執行緒是上下快速切換,不是平行運行,切換要時間)。

但是,でも! 如果有學過作業系統會知道,CPU在進行IO操作時會命令會命令硬碟做寫入讀取,此時thread切出去換其他thread執行。IO是電腦一個比較緩慢的操作,加入multithread速度會獲得提升。

那python thread網路上有人寫得很好了,參考這個:

大致就是,threading.Thread是建立一個thread,在裡面target放入要執行的程式後。再start即可。用了thread後 main thread會繼續向下執行,直到join合併主thread與次要thread。那我們的爬蟲可以獨立出去寫成一個function,並在最後放上我們的IO (insertDB):

def getPagedata(link,posts):
# 開啟每一個討論串
page_resp = requests.get("https://www.mobile01.com/"+link,headers=headers)
page_html = etree.HTML(page_resp.text)
title = page_html.xpath(title_xpath)[0].strip()
# 每一樓層
blocks = page_html.xpath(block_xpath)
firstAuthor = blocks[0].xpath(author_xpath)[0].strip()
firstContents = blocks[0].xpath(content_xpath)
firstContent = ''
for c in firstContents:
firstContent += c.strip()
post = PostPage(firstAuthor,firstContent,title) for idx in range(1,len(blocks)):
author = blocks[idx].xpath(author_xpath)[0].strip()
contents = blocks[idx].xpath(content_xpath)
content = ''
for c in contents:
content += c.strip()
post.getReply(author,content) posts.append(post)
insertDB(post)

另外因為sqlite3默認不支援多執行緒,所以要稍稍修改,加入防止check thread。

conn = sqlite3.connect('e:/program/sqlite/db/mobile.db',check_same_thread = False)

此外在寫入時要防止寫入頭(cursor)互相衝突,所以寫一個lock,來防止衝突。

lock = threading.Lock()
def insertDB(post):
Postpage_temp = 'INSERT INTO Post_page(AUTHOR,TITLE,CONTENT) VALUES(?,?,?);'
Replyblock_temp = 'INSERT INTO Reply_block(AUTHOR,CONTENT,Post_page_ID) VALUES(?,?,?);'
try:
lock.acquire(True)
cursor.execute(Postpage_temp,(post.author,post.title,post.content))
replys = [(reply.author,reply.content,cursor.lastrowid) for reply in post.replys]
cursor.executemany(Replyblock_temp,replys)
print("finish insert "+post.title)
conn.commit()
finally:
lock.release()

最後多執行緒成功,比起原來的70秒左右進步到50秒左右。

程式好讀版:

參考資料:

--

--