本文範例程式, hahow_courses.json

本文講解兩件事:

  1. 如何爬取 hahow 所有已開課課程資料
  2. 計算募資價/上線價/課程長度/學生數的統計資料 (平均值),及前三者與學生數的相關性 (是否定價越低/長度越長則學生數越多?)

Pycone 松果城市 (網站, 粉絲頁) 是致力於對初學者友善的 Python 教學團隊,除了已經開的初心者課程爬蟲課程外,陸續還有新課程在籌備中。而一門課的定價該怎麼定,大家往往有不同的意見,有人認為技術有價,不能破壞行情,且越少見的課應該越貴;有人認為較低定價可以吸引更多學生。與其靠經驗或直覺,不如讓數據說話!我們何不直接分析 hahow 上程式類課程的平均價格及各項係數與學生數的相關性?

爬取 hahow 所有已開課課程資料

身為一個懶人,寫爬蟲前第一件事當然是看 hahow 有沒有提供打包下載的課程資料或可存取的公開 API,簡單搜尋之後沒有收穫,只好從 hahow 的課程列表下手。在課程列表的網頁你會發現,這個網頁並不會一次回傳所有課程,而是隨著瀏覽器捲軸下拉,逐漸顯示更多課程。通常這種網頁多是透過 AJAX 與網站主機做非同步的溝通與資料傳輸,打開開發者工具瀏覽一下之後,很快找到了可能的 API

2017-07-25-1

2017-07-25-2

把該網址的 response 貼到 online JSON parser 驗證,果然就是課程資料

2017-07-25-3

接著繼續觀察畫面捲動時是透過什麼 API 取得更多課程資訊,最後確定了:

  1. 一開始先透過 GET https://api.hahow.in/api/courses?limit=12&status=PUBLISHED 取得最初的 12 筆資料 (經測試一次最多可以取 30 筆)
  2. 接著一樣透過 GET 回傳目前最後一筆課程的 id 與募資時間,取得接下來 12 筆課程資料,直到沒有資料為止

不得不說 hahow 工程師的 API 寫得滿好的,很簡單易用 (雖然他們並沒有要給大家用 XD),因此對應的爬蟲程式邏輯也很簡單

def crawl():
    # 初始 API: https://api.hahow.in/api/courses?limit=12&status=PUBLISHED
    # 接續 API: https://api.hahow.in/api/courses?latestId=54d5a117065a7e0e00725ac0&latestValue=2015-03-27T15:38:27.187Z&limit=30&status=PUBLISHED
    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                             'AppleWebKit/537.36 (KHTML, like Gecko) '
                             'Chrome/59.0.3071.115 Safari/537.36'}
    url = 'https://api.hahow.in/api/courses'
    courses = list()
    resp_courses = requests.get(url + '?limit=30&status=PUBLISHED', headers=headers).json()
    while resp_courses:  # 有回傳資料則繼續下一輪擷取
        time.sleep(3)  # 放慢爬蟲速度
        courses += resp_courses
        param = '?latestId={0}&latestValue={1}&limit=30&status=PUBLISHED'.format(
            courses[-1]['_id'], courses[-1]['incubateTime'])
        resp_courses = requests.get(url + param, headers=headers).json()
	# 將課程資料存下來後續分析使用
    with open('hahow_courses.json', 'w', encoding='utf-8') as f:
        json.dump(courses, f, indent=2, sort_keys=True, ensure_ascii=False)
    return courses

將所有課程擷取下來後,我們看到每一筆課程資料內容如下:

{
    "_id": "58744feda8aae907000d06c0",
    "categories": [
      "55de81ac9d1fa51000f94770",
      "55de81929d1fa51000f94769"
    ],
    "coverImage": {
      "_id": "588421e46ecf3a0700b7a31d",
      "url": "https://hahow.in/images/588421e46ecf3a0700b7a31d"
    },
    "incubateTime": "2017-02-09T05:45:14.673Z",
    "metaDescription": "你想自動擷取網站上的資料嗎?你學了 Python 卻不知道該從什麼程式開始練習嗎?這堂課就是為你準備的!本課程會循序漸進地說明如何撰寫 Python 網頁爬蟲,從環境設定開始,涵蓋網頁解構、資料擷取與儲存,及多項實戰演練,讓你在學習過程中及對於學習成果都有滿滿的成就感。",
    "numSoldTickets": 514,
    "owner": {
      "_id": "58744a86a8aae907000d0684",
      "name": "Jun-Wei Lin",
      "profileImageUrl": "https://hahow.in/images/58744c3ca8aae907000d0697",
      "username": "junwei"
    },
    "preOrderedPrice": 990,
    "price": 1890,
    "proposalDueTime": "2017-03-11T00:00:00.000Z",
    "reviewing": false,
    "status": "PUBLISHED",
    "successCriteria": {
      "numSoldTickets": 50
    },
    "title": "Python 網頁爬蟲入門實戰",
    "totalVideoLengthInSeconds": 15290,
    "type": "COURSE",
    "uniquename": "python-web-crawler"
}

各欄位的名稱都很直覺,唯一就是課程的分類 (categories) 代碼意義不明,此時只要觀察一下各類課程的連結就可以知道代碼

2017-07-25-4

計算各項係數之統計資料與相關性

收集資料是為了分析資料並進一步回答問題。我們的問題是:程式類課程的平均價格、課程長度及學生數為多少?各項係數是否與學生數有相關性?相較於之前的文章中直接寫程式計算數據,這邊我們改用 numpy 來計算,只要將感興趣的資料分別存成 list,就能夠用 numpy 直接計算統計資料

with open('hahow_courses.json', 'r', encoding='utf-8') as f:
    courses = json.load(f)
	
# 取出程式類課程的募資價/上線價/學生數,並顯示統計資料
pre_order_prices = list()
prices = list()
tickets = list()
lengths = list()
for c in courses:
    if '55de81ac9d1fa51000f94770' in c['categories']:
        pre_order_prices.append(c['preOrderedPrice'])
        prices.append(c['price'])
        tickets.append(c['numSoldTickets'])
        lengths.append(c['totalVideoLengthInSeconds'])
		
print('程式類課程共有 %d 堂' % len(prices))  # 23
print('平均募資價:', np.mean(pre_order_prices))  # 719.09
print('平均上線價:', np.mean(prices))  # 1322.57
print('平均學生數:', np.mean(tickets))  # 483.22
print('平均課程分鐘:', np.mean(lengths)/60)  # 515.12

corrcoef = np.corrcoef([tickets, pre_order_prices, prices, lengths])
print('募資價與學生數之相關係數: ', corrcoef[0, 1])  # 0.18
print('上線價與學生數之相關係數: ', corrcoef[0, 2])  # 0.36
print('課程長度與學生數之相關係數: ', corrcoef[0, 3])  # 0.65

我們可以看到目前 23 堂程式類課程的平均募資價 (720) 與上線價 (1320),而令人意外的是募資價與學生數並沒有太大的相關性 (不是募資價越低學生就越多),反倒是課程長度與學生數呈現滿強的正相關。而我們在之前的文章已經提過,相關性並不代表因果關係,同時很明顯地,其他非數據的因素如講師名氣、文案內容、影片生動度等對於吸引學生也非常重要,因此這些數據只是幫助決策的參考資訊。


本文範例程式, example.json

這篇文章會示範簡單的統計分析 (平均值與相關係數) 與資料視覺化 (histogram 與 scatter plot),最後會淺談資料科學的三個面向作為本系列文章總結。

藉由之前的範例程式,我們已經有了今天 PTT Beauty 板所有貼文的一些資訊。假設我們想進一步認識或分析手上的數據,或對資料維度的相關性作些假設,就需要統計分析與視覺化的幫助。例如我們對貼圖數與推文數有興趣,且假設「文章內有越多貼圖會得到越多推」,第一步先將資料從 example.json 讀入之後,馬上就能得知它們的極值:

with open('example.json', 'r', encoding='utf-8') as f:
    data_list = json.load(f)
    images = []
    pushes = []
    for d in data_list:
        images.append(d['num_image'])
        pushes.append(d['push_count'])

print('圖片數:', images, 'Max:', max(images), 'Min:', min(images))
print('推文數:', pushes, 'Max:', max(pushes), 'Min:', min(pushes))

# 圖片數: [3, 7, 1, 12, 9, 1, 2, 13, 0, 5, 27, 5, 1, 8, 0, 1, 14, 2, 3, 2, 1, 25, 3, 14, 27, 2] Max: 27 Min: 0
# 推文數: [18, 20, 0, 0, 3, 6, 2, 12, 1, 13, 11, 5, 0, 20, 1, 7, 6, 2, 2, 0, 0, 32, 10, 13, 9, 2] Max: 32 Min: 0

平均值與相關係數

有了原始資料的 list, 平均值的計算也很容易,

def mean(x):
    return sum(x) / len(x)

print('平均圖片數:', mean(images), '平均推文數:', mean(pushes))

# 平均圖片數: 7.230769230769231 平均推文數: 7.5

接著我們想知道是否「文章內有越多貼圖會得到越多推」,一個方式是計算相關係數,相關係數是共變異數 (covariance) 除以標準差 (standard deviation) 的乘積,公式可參考此處。因此,除了平均值之外,我們還需要計算偏差值 (deviation), 變異數 (variance) 與內積 (dot) 的函式

def de_mean(x):
    x_bar = mean(x)
    return [x_i - x_bar for x_i in x]


def variance(x):
    deviations = de_mean(x)
    variance_x = 0
    for d in deviations:
        variance_x += d**2
    variance_x /= len(x)
    return variance_x


def dot(x, y):
    dot_product = sum(v_i * w_i for v_i, w_i in zip(x, y))
    dot_product /= (len(x))
    return dot_product

有了相關函式後,相關係數可依公式計算

def correlation(x, y):
    variance_x = variance(x)
    variance_y = variance(y)
    sd_x = math.sqrt(variance_x)
    sd_y = math.sqrt(variance_y)
    dot_xy = dot(de_mean(x), de_mean(y))
    return dot_xy/(sd_x*sd_y)

print('相關係數:', correlation(images, pushes))

# 相關係數: 0.5258449106844523

相關係數是 -1 到 1 之間的值,越接近 1 代表兩個維度越接近線性正相關,反之則為線性負相關。這個例子中的 0.5258 代表一定程度的正相關,看到這邊你一定有疑問:推文數怎麼可能被貼圖數決定?難道我貼一堆海綿寶寶圖也會被推爆嗎?當然不可能,而這個例子也引出了你可能聽過的說法:相關不代表因果 (correlation is not causation)。事實上,我們解讀相關係數時必須多方考慮,如果 x 與 y 高度正相關,可能代表:

  • x 導致 y
  • y 導致 x
  • x, y 互為因果
  • 另有他因 z 導致 x, y (例如高 GDP 同時導致高平均壽命與高基礎網路頻寬,平均壽命與基礎網路頻寬並沒有因果關係)
  • x, y 根本無關,只是巧合 (例如一個地區的手機銷售量與律師人數?)

資料視覺化

除了直接計算統計數據,將資料視覺化也是幫助我們認識資料的好辦法,例如我想知道推文數的分布,可以試著畫出 histogram, 把小於 10 推, 20 推, 30 推的文章數量以條狀圖顯示

def decile(num):  # 將數字十分位化
    return (num // 10) * 10

from collections import Counter
histogram = Counter(decile(push) for push in pushes)
print(histogram)
# Counter({0: 17, 10: 6, 20: 2, 30: 1})

# 畫出 histogram
from matplotlib import pyplot as plt

plt.bar([x-4 for x in histogram.keys()], histogram.values(), 8)
plt.axis([-5, 35, 0, 20])
plt.title('Pushes')
plt.xlabel('# of pushes')
plt.ylabel('# of posts')
plt.xticks([10 * i for i in range(4)])
plt.show()

2016-12-31-1

另外,也可以考慮直接畫出資料的散佈圖 (scatter plot),觀察相關性

plt.scatter(images, pushes)
plt.title('# of image v.s. push')
plt.xlabel('# of image')
plt.ylabel('# of push')
plt.axis('equal')
plt.show()

2016-12-31-2

從 scatter plot 我們可以稍微看出圖片數與推文數的正相關性

結語:資料科學的三個面向

從這系列的文章,相信你已經看出所謂的資料科學與資料分析,其實可概分為三個面向:

  • 資料處理,寫程式,架系統,對應資料的來源、儲存、處理、查詢等,
  • 統計分析,數學,各種機器學習模型與技巧
  • Domain knowledge,也就是到底要對著資料問什麼問題

所以如果你對資料科學有興趣,你可能是對架構系統有興趣(提供乾淨資料),或對於玩資料有興趣(喜歡統計分析,精通機器學習,或是有足夠的領域知識發掘好問題),也可以思考以你的背景從哪一塊切入會比較適合。


本系列完整範例爬蟲程式

這篇文章會說明如何將各文章內的圖片下載到本機端,並計算、儲存圖片數。經過之前的步驟,我們已經有了文章列表,其格式是:

articles = [
    {'push_count': 8, 'title': '[正妹] 韓國瑜的女兒', 'href': '/bbs/Beauty/M.1482411674.A.855.html'}
    {'push_count': 5, 'title': '[正妹] 佐々木優佳里 Happiness', 'href': '/bbs/Beauty/M.1482414319.A.C09.html'}
    {'push_count': 13, 'title': '[正妹] 甜美笑容 第一二五彈', 'href': '/bbs/Beauty/M.1482416491.A.656.html'}
    ...
]

因此,我們的步驟是:

  1. 連線到網站,取得該文章網頁 (get_web_page())
  2. 找到文章內的圖片網址們 (parse())
  3. 在本機新增以文章標題為名的資料夾,將圖片存到本機 (save())
  4. 紀錄圖片數量;繼續巡訪下一篇文章直到沒有文章為止

程式碼如下,非常簡單:

PTT_URL = 'https://www.ptt.cc'

for article in articles:
    page = get_web_page(PTT_URL + article['href'])
    if page:
        img_urls = parse(page)
        save(img_urls, article['title'])
        article['num_image'] = len(img_urls)        

以下分別說明各步驟。

取得文章網頁

這部分之前已經說明過,只是文章的網址換一下而已。要注意的是 PTT 網頁內文章的 href 屬性是相對路徑,因此連線時要加上完整網址名稱 (PTT_URL)

找到文章內的圖片網址們

這部分一樣是用 BeautifulSoup 的 find_all() 來完成。我們先假設圖片網址一定是 “http://i.imgur.com” 開頭,用 Chrome 開發者工具檢視網頁區塊後,我們知道我們要找的是 <div id=”main-content”> 區塊內所有的 <a> 標籤,且 href 屬性是 “http://i.imgur.com” 開頭的連結:

def parse(dom):
    soup = BeautifulSoup(dom, 'html.parser')
    links = soup.find(id='main-content').find_all('a')
    img_urls = []
    for link in links:
        if link['href'].startswith('http://i.imgur.com'):
            img_urls.append(link['href'])
    return img_urls

看到這邊你一定有疑問:imgur 網站圖片的網址不一定是 http 開頭,也可能是 https 開頭;網址也可能是 m.imgur.com 或 imgur.com,例如以下網址都是同一張圖片的合法網址:

test_urls = [
    'http://i.imgur.com/A2wmlqW.jpg',
    'http://i.imgur.com/A2wmlqW',  # 沒有 .jpg
    'https://i.imgur.com/A2wmlqW.jpg',
    'http://imgur.com/A2wmlqW.jpg',
    'https://imgur.com/A2wmlqW.jpg',
    'https://imgur.com/A2wmlqW',
    'http://m.imgur.com/A2wmlqW.jpg',
    'https://m.imgur.com/A2wmlqW.jpg'
]

但我們的程式只能辨認出前兩種網址。當然你可以增加字串值與條件判斷去辨認更多種格式的網址,但較簡潔的方法是透過正規表示式 (Regular Expression) 指定字串的格式。例如能辨識出以上全部格式的正規表示式為:

'^https?://(i.)?(m.)?imgur.com'

”^” 表示字串開頭,字元緊接著 “?” 表示該字元可出現 0 或 1 次,所以 “^https?” 表示的是 “http” (s 出現 0 次) 或 “https” (s 出現 1 次) 開頭的字串,同理 (i.)? 表示 “i.” 可以出現 0 或 1 次。我們用 re.match() 判斷字串是否符合所定義的正規表示式:

import re
for url in test_urls:
    print(re.match('^https?://(i.)?(m.)?imgur.com', url))  # 符合則回傳 SRE_Match Object, 不符合則回傳 None
# <_sre.SRE_Match object; span=(0, 18), match='http://i.imgur.com'>
# <_sre.SRE_Match object; span=(0, 18), match='http://i.imgur.com'>
# <_sre.SRE_Match object; span=(0, 19), match='https://i.imgur.com'>
# <_sre.SRE_Match object; span=(0, 16), match='http://imgur.com'>
# <_sre.SRE_Match object; span=(0, 17), match='https://imgur.com'>
# <_sre.SRE_Match object; span=(0, 17), match='https://imgur.com'>
# <_sre.SRE_Match object; span=(0, 18), match='http://m.imgur.com'>
# <_sre.SRE_Match object; span=(0, 19), match='https://m.imgur.com'>

因此,我們將 parse() 改寫為:

def parse(dom):
    soup = BeautifulSoup(dom, 'html.parser')
    links = soup.find(id='main-content').find_all('a')
    img_urls = []
    for link in links:
        if re.match(r'^https?://(i.)?(m.)?imgur.com', link['href']):
            img_urls.append(link['href'])
    return img_urls

將圖片存到本機端

有了圖片網址,我們會創造一個以文章標題為名的資料夾,並將圖片下載到該資料夾內。在此要注意的有 3 點:

  1. 我們擷取了 imgur.com 網址的各種形式,但下載圖片時用的網址必須是 i.imgur.com 開頭,因此要把 m.imgur.com 換成 i.imgur.com,或把 imgur.com 補成 i.imgur.com
  2. 網址結尾不一定有 .jpg,為了順利下載,記得補上 .jpg。這些字串處理過程,就是資料淨化與清理的工作。
  3. 因為文章標題可能會有作業系統不支援的字元,所以 os.makedirs() 可能會失敗,失敗時就無法創造資料夾,並印出 exception。要處理這個問題,一個解法是用正規表示式過濾系統不支援的字元,在此先略過。
def save(img_urls, title):
    if img_urls:
        try:
            dname = title.strip()  # 用 strip() 去除字串前後的空白
            os.makedirs(dname)
            for img_url in img_urls:
                if img_url.split('//')[1].startswith('m.'):
                    img_url = img_url.replace('//m.', '//i.')
                if not img_url.split('//')[1].startswith('i.'):
                    img_url = img_url.split('//')[0] + '//i.' + img_url.split('//')[1]
                if not img_url.endswith('.jpg'):
                    img_url += '.jpg'
                fname = img_url.split('/')[-1]
                urllib.request.urlretrieve(img_url, os.path.join(dname, fname))
        except Exception as e:
            print(e)

到這邊為止,你的程式已經可以下載 PTT Beauty 板今日文章的圖片,並且有了今天每一篇文章的標題、推文數、圖片數、文章連結等資訊,你可以把資訊存成 json 檔案如下:

import json

with open('data.json', 'w', encoding='utf-8') as f:
    json.dump(articles, f, indent=2, sort_keys=True, ensure_ascii=False)

這個簡單的範例還有許多可改進的地方,例如:處理文章標題的特殊字元,只有推文數多的文章才下載圖片、略過推文的圖片、支援更多圖床網址、多執行緒下載圖片等,但已經展示了一些基礎爬蟲技巧與概念。下一篇文章會說明如何做簡單的資料分析 (統計資料與畫圖)。


本節 beautifulsoup 範例程式, Beauty 板爬蟲範例程式

網頁 = 由標籤 (tag) 所組成的階層式文件

你在瀏覽器看到的美觀網頁,主要由三個部分構成: HTML (網頁的骨架結構)、CSS (網頁的樣式) 與 JavaScript (在瀏覽器端執行,負責與使用者互動的程式功能)。對於網頁或爬蟲的初學者來說,最重要的觀念是了解:網頁就是由各式標籤 (tag) 所組成的階層式文件,要取得所需的網頁區塊資料,只要用 tag 與相關屬性去定位資料所在位置即可。例如以下是一個簡單的網頁及其原始碼:

2016-12-22-1

<html>
  <head>
    <title>我是網頁標題</title>
    <style>
    .large {
      color:blue;
      text-align: center;
    }
    </style>
  </head>
  <body>
    <h1 class="large">我是變色且置中的抬頭</h1>
    <p id="p1">我是段落一</p>
    <p id="p2" style="">我是段落二</p>
    <div><a href='http://blog.castman.net' style="font-size:200%;">我是放大的超連結</a></div>
  </body>
</html>

HTML 文件內不同的標籤 (例如 <title>, <h1>, <p>, <a> 有著不同的語義,表示建構網頁用的不同元件,且標籤可以有各種屬性 (例如 id, class, style 等通用屬性, 或 href 等專屬屬性),因此我們可以用標籤 + 屬性去定位資料所在的區塊並取得資料。關於網頁架構還有另外一件事,就是它是階層式文件,例如以上的網頁架構可以如下表示:

2016-12-22-2

雖然在我們的範例中不會用階層結構去定位資料區塊,但知道這件事有助於你閱讀及理解網頁文件。

BeautifulSoup 入門

BeautifulSoup 是好學易用,用來解構並擷取網頁資訊的 Python 函式庫。給定以上的網頁文件,

html_doc = """
<html>
  <head>
    <title>我是網頁標題</title>
    <style>
    .large {
      color:blue;
      text-align: center;
    }
    </style>
  </head>
  <body>
    <h1 class="large">我是變色且置中的抬頭</h1>
    <p id="p1">我是段落一</p>
    <p id="p2" style="">我是段落二</p>
    <div><a href='http://blog.castman.net' style="font-size:200%;">我是放大的超連結</a></div>
  </body>
</html>
"""

先創建一個 BeautifulSoup 物件,將網頁讀入

from bs4 import BeautifulSoup
soup = BeautifulSoup(html_doc, 'html.parser')
print(soup)
# <html>
# <head>
# <title>我是網頁標題</title>
# <style>
# .large {
#   color:blue;
#   text-align: center;
# }
# </style>
# </head>
# <body>
# <h1 class="large" style="">我是變色且置中的抬頭</h1>
# <p id="p1">我是段落一</p>
# <p id="p2" style="">我是段落二</p>
# <div><a href="http://blog.castman.net" style="font-size:200%;">我是放大的超連結</a></div>
# </body>
# </html>

接著就可以用 find(), find_all() 搭配 tag 名稱及屬性去定位資料區塊

soup.find('p')            # 回傳第一個被 <p> </p> 所包圍的區塊
# <p id="p1">我是段落一</p>

soup.find('p', id='p2')   # 回傳第一個被 <p> </p> 所包圍的區塊且 id="p2"
# <p id="p2" style="">我是段落二</p>

soup.find(id='p2')        # 回傳第一個 id="p2" 的區塊
# <p id="p2" style="">我是段落二</p>

soup.find_all('p')        # 回傳所有被 <p> </p> 所包圍的區塊
# [<p id="p1">我是段落一</p>, <p id="p2" style="">我是段落二</p>]

soup.find('h1', 'large')  # 找尋第一個 <h1> 區塊且 class="large"
# <h1 class="large" style="">我是變色且置中的抬頭</h1>

find() 只回傳第一個找到的區塊,而 find_all() 會回傳一個 list, 包含所有符合條件的區塊。傳入的引數第一個通常是 tag 名稱,第二個引數若未指明屬性就代表 class 名稱,也可以直接使用 id 等屬性去定位區塊。定位到區塊後,可以取出其屬性與包含的字串值

paragraphs = soup.find_all('p')
for p in paragraphs:
    print(p['id'], p.text)
# p1 我是段落一
# p2 我是段落二

a = soup.find('a')
print(a['href'], a['style'], a.text)
# http://blog.castman.net font-size:200%; 我是放大的超連結

print(soup.find('h1')['class'])  # 因為 class 可以有多個值,故回傳 list
# ['large']

如果你要取得的屬性不存在,直接使用屬性名稱會出現錯誤訊息,因此若你不確定屬性是否存在,可以改用 get() 方法

print(soup.find(id='p1')['style'])      # 會出現錯誤訊息, 因為 <p id="p1"> 沒有 style 屬性
print(soup.find(id='p1').get('style'))  # None

其他詳細用法可參考 BeautifulSoup 的官方文件

使用 Chrome 的開發者工具找到資料區塊的 tag 及屬性

假設你有一個想爬的網頁,要怎麼知道資料區塊所在的標籤及屬性呢?在此我們使用 Chrome 的開發者工具,以 Ptt Web 版 Beauty 板首頁為例,用 Chrome 連上 https://www.ptt.cc/bbs/Beauty/index.html , 接著按下 F12 或從選單啟動開發者工具

2016-12-22-3

下方會跑出開發者工具的操作區,點選左上角的箭頭按鈕後,再點擊網頁上你想要定位的資料區塊,該區塊的 HTML 碼就會顯示在下方。當然你也可以直接檢視網頁原始碼或檢視上一篇教學中用 get_web_page() 所取得的網頁文件,但善用開發者工具可以加速你的搜尋。

2016-12-22-4

2016-12-22-5

PTT Beauty 板範例實戰

檢視網頁原始碼後我們知道,網頁上的每一篇貼文都是由 <div class=”r-ent”> 的區塊包圍起來,裡面分別由 <div class=”nrec”> 區塊顯示推文數,<div class=”title”> 區塊及 <a> 區塊顯示文章連結及文章標題,<div class=”date”> 區塊顯示發文日期

2016-12-20-2

因此,若已經取得網頁文件,我們可以用 find_all() 找出所有<div class=”r-ent”> 區塊,並逐一巡訪,取得資料:

def get_articles(dom, date):
    soup = BeautifulSoup(dom, 'html.parser')

    articles = []  # 儲存取得的文章資料
    divs = soup.find_all('div', 'r-ent')
    for d in divs:
        if d.find('div', 'date').string == date:  # 發文日期正確
            # 取得推文數
            push_count = 0
            if d.find('div', 'nrec').string:
                try:
                    push_count = int(d.find('div', 'nrec').string)  # 轉換字串為數字
                except ValueError:  # 若轉換失敗,不做任何事,push_count 保持為 0
                    pass

            # 取得文章連結及標題			
            if d.find('a'):  # 有超連結,表示文章存在,未被刪除
                href = d.find('a')['href']
                title = d.find('a').string
                articles.append({
                    'title': title,
                    'href': href,
                    'push_count': push_count
                })
    return articles

使用 get_articles() 及上一篇教學的 get_web_page(),取得今日文章資訊

page = get_web_page('https://www.ptt.cc/bbs/Beauty/index.html')
if page:
    date = time.strftime("%m/%d").lstrip('0')  # 今天日期, 去掉開頭的 '0' 以符合 PTT 網站格式
    current_articles = get_articles(page, date)
    for post in current_articles:
        print(post)
# {'push_count': 8, 'title': '[正妹] 韓國瑜的女兒', 'href': '/bbs/Beauty/M.1482411674.A.855.html'}
# {'push_count': 5, 'title': '[正妹] 佐々木優佳里 Happiness', 'href': '/bbs/Beauty/M.1482414319.A.C09.html'}
# {'push_count': 13, 'title': '[正妹] 甜美笑容 第一二五彈', 'href': '/bbs/Beauty/M.1482416491.A.656.html'}
# {'push_count': 0, 'title': '[帥哥] 佐々木小次郎', 'href': '/bbs/Beauty/M.1482417495.A.733.html'}
# {'push_count': 14, 'title': '[正妹] 短髮有可愛 FB女孩Round 255', 'href': '/bbs/Beauty/M.1482419748.A.D25.html'}
# {'push_count': 7, 'title': '[正妹] 筧美和子', 'href': '/bbs/Beauty/M.1482419973.A.32C.html'}
# {'push_count': 58, 'title': '[正妹] 教你公民好嗎', 'href': '/bbs/Beauty/M.1482420690.A.AE7.html'}
# {'push_count': 9, 'title': '[正妹] 熟女了', 'href': '/bbs/Beauty/M.1482420814.A.021.html'}
# {'push_count': 10, 'title': '[正妹] 佐々木琴子', 'href': '/bbs/Beauty/M.1482421163.A.C42.html'}
# {'push_count': 0, 'title': '[正妹] 野生日本賽車女神', 'href': '/bbs/Beauty/M.1482421895.A.F6B.html'}

這樣就取得今天全部的 Beauty 板文章了嗎?聰明的你一定想到了:如果不只首頁,前一頁還有今天的文章怎麼辦?這就留給各位自行練習了 (提示:找到前一頁的連結,連線並取得該頁資料後,一樣用 get_articles 爬取文章資料),我們會在教學結束後的範例提供完整程式碼。下一篇文章會說明如何連結到 current_articles 內的文章,抓圖並計算每一篇文章的貼圖數。


本節完整範例程式請點此處

requirement.txt

範例: PTT Beauty 板今日圖片下載器

PTT Beauty 板今日圖片下載器,會把表特板今天所有文章的圖片下載到本機端,同時儲存一些文章資訊。本系列文章藉由會實作這個範例,說明 Python 網頁爬蟲與資料分析的入門技巧。

套件安裝

首先請確定你的電腦已經安裝 Python 3 以及 pip (本文使用的環境是 Python 3.5.2 與 pip 9.0.1)

> python --version
Python 3.5.2

> pip --version
pip 9.0.1 from c:\virtualenv\ptt-beauty-py35-64\lib\site-packages (python 3.5)

接著安裝所需套件,你可以依照 requirement.txt 中所列的套件一一安裝,也可以一次全部安裝

pip install -r requirement.txt

接著在命令列輸入以下指令,若沒有任何訊息出現則代表套件安裝成功

python -c "import requests; import bs4; import matplotlib"

與網站 Server 溝通並取得網頁資料

PTT Web 版 Beauty 板首頁 https://www.ptt.cc/bbs/Beauty/index.html 在瀏覽器看起來是這樣的

2016-12-20-1

要透過 Python 取得該頁資料,我們使用 requests 套件的 requests.get() 方法, 首先定義 get_web_page() 函式

def get_web_page(url):
    resp = requests.get(
        url=url,
        cookies={'over18': '1'}
    )
    if resp.status_code != 200:
        print('Invalid url:', resp.url)
        return None
    else:
        return resp.text

requests.get() 需要提供網址作為引數, 而 cookies={'over18': '1'} 是 PTT 網站有些板會詢問你是否已滿 18 歲, 因此將回答先存在 cookie 中一併傳給 server. requests.get() 的結果是 request.Response 物件, 我們可以先透過該物件的 statu_code 屬性取得 server 回覆的狀態碼 (例如 200 表示正常, 404 表示找不到網頁等), 若狀態碼為 200, 代表正常回應, 再透過 text屬性取得 server 回覆的網頁內容. 若狀態碼異常則回覆 None.

定義好 get_web_page() 函式之後, 就能呼叫它來取得網頁內容:

page = get_web_page('https://www.ptt.cc/bbs/Beauty/index.html')
if page:
	print(page)

結果為

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>看板 Beauty 文章列表 - 批踢踢實業坊</title>
(...略)
</head>
<body>
<div id="topbar-container">
	<div id="topbar" class="bbs-content">
		<a id="logo" href="/">批踢踢實業坊</a>
		<span>&rsaquo;</span>
		<a class="board" href="/bbs/Beauty/index.html"><span class="board-label">看板 </span>Beauty</a>
		<a class="right small" href="/about.html">關於我們</a>
		<a class="right small" href="/contact.html">聯絡資訊</a>
	</div>
</div>
(...略)
		<div class="r-ent">
			<div class="nrec"><span class="hl f2">3</span></div>
			<div class="mark"></div>
			<div class="title">
				<a href="/bbs/Beauty/M.1482277860.A.10B.html">[正妹] 文學女孩</a>
			</div>
			<div class="meta">
				<div class="date">12/21</div>
				<div class="author">dan025</div>
			</div>
		</div>
		<div class="r-ent">
			<div class="nrec"></div>
			<div class="mark"></div>
			<div class="title">
				<a href="/bbs/Beauty/M.1482285364.A.D29.html">[正妹] 烏茲別克</a>
			</div>
			<div class="meta">
				<div class="date">12/21</div>
				<div class="author">panzer1224</div>
			</div>
		</div>
(...略)

回傳的內容的確是瀏覽器所看到的內容,而且以第一篇貼文為例,我們可以看到它包含了推文數、文章連結、文章標題、貼文日期等我們所需要的資訊。下一篇文章會說明如何使用 BeautifulSoup 套件解構網頁內容,將資料取出。

2016-12-20-2