有聽過 HTTP 跟 RESTful API 嗎?你知道 HTTP 協定的主要設計者及 REST 架構的發明人是哪裡畢業的博士嗎?UC Irvine

有聽過 DNS 嗎?你知道 DNS 的共同發明人是哪裡畢業的博士嗎?UC Irvine

你知道 Kobe Bryant 曾經被目擊到在那間學校的體育館自主練習?你知道全美唯一校內附設網咖的大學是哪一家? UC Irvine

你知道 Blizzard, Broadcom, WD 這些公司的總部,以及 BenQ 跟 Toshiba 的北美總部在哪裡嗎?Irvine (好啦 BenQ 其實在隔壁的 Costa Mesa)

你知道哪個城市連續 11 年蟬聯全美最安全城市?哪個城市總是在全美最宜居城市最適合養小孩城市最佳戶外活動城市榜上有名?當然還是 Irvine

所以來 UCI 還需要什麼理由嗎 :)

好啦,正題開始,這篇文章是給打算出國唸書的人,為什麼要到 UCI 念 CS/CE/HCI,尤其是作軟體工程研究的理由。當然如果你已經錄取了大家都叫得出名字的傳統名校,我不認為這篇文章能夠說服你 (或是當初的我自己) 來 UCI,但如果你的落點在這附近,或你申請時沒有考慮過 UCI,這篇文章可以提供一些資訊。由於我是 Informatics Dept. 下的 Software Engineering program 博士生, 我會對自己的 program 著墨最多,也會提及其他 CS 相關領域的情況。

硬指標:研究發表

博士生的主要工作就是做研究及發 paper, 在 CS 領域, 發 top conference paper 又比 journal paper 重要 (或至少一樣重要)。當然許多大師說過 Paper is cheap, 我們應該專注在改變世界、影響人類生活的 idea. 我完全贊同,但是我們不是大師,身為學術圈浮游生物的我們,還是只能功利點多發幾篇 paper, 才能畢業跟找到工作,因此選擇一個 productive 的環境就很重要。如果你已經確定有興趣的研究領域,我推薦要選校的人直接看 csrankings.org 的排名跟教授名單。大學排名百百種,csrankings.org 的特色就是它是依照完全的硬指標:在頂級會議的發表篇數,比如說 AI 各領域的頂級會議: ICML, CVPR, AAAI 等,發表多的學校就排名前面,完全跟其他因素如學校名聲,師生比,校園規模無關。在這邊你會看到 UCI 的 overall ranking in the US 是 23,應該比其他機構排出來的名次還前面一些;更令人驚訝的是,在軟體工程領域,UCI 是全美第 2!代表這邊的教授非常積極在軟工的頂級會議 (ICSE/FSE) 發 paper,相對地對你畢業也會較有幫助,這是一般的綜合排名看不到的資訊;另外,UCI Informatics Dept. 除了一小群軟體工程的教授之外,我感覺整個系都在做人機互動/人機介面的研究,所以你會看到 UCI 在 HCI 領域的發表也高居全美第 4。除了 SE 與 HCI,我們 ICS school 在其他 CS 相關領域例如 Machine learning 或 Bioinformatics 的研究也都是有名氣的。不管你對哪個 CS 相關領域有興趣,選校時都可以參考這個網站。

Software Engineering program/faculty at UCI

UCI 的軟體工程 program,不管是在台灣或美國都相對少見,能夠把軟體工程獨立成一個 program, 代表這裡不管是相關課程的廣度,或 faculty 的數量及研究深度都超過其他學校。舉例來說,軟體工程在台灣大多就是一堂大學部的選修課,當然有少數學校像是中央軟工所可能會有比較多課程,但 UCI SE program 的 curriculum 可是洋洋灑灑將近 30 堂課(當然有許多是與 CS 合開),很多課都是我在台灣的資工系沒看過的。再者,這邊軟工研究群的五個教授, 就有兩個是 ICSE 2018 Technical paper 的 program committee,加上前一段提過的頂級會議發表量,都證明了這邊 faculty 的研究影響力,如果你需要發 paper,來這邊抱大腿是個好選擇,常言道:站在浪頭上,豬也能飛上天。

找 intern 及就業情況

出國唸書的人以碩士生為大宗,因為我是博士生,並沒有第一個暑假就要急著找 intern 的壓力,加上我又是系邊 (i.e., 系上邊緣人),對於碩士生找 intern 及就業情況了解得不多,但還是能夠提供一些我周遭朋友的個案:

SE program 的博士生方面,我們 lab 今年要畢業的博士生去過兩次 Google intern, 已拿到 return offer; 明年要畢業的博士生去過 Intel intern;J 教授今年畢業的學生去過 MS intern, 畢業後要回 MS, 另一個博士生這個暑假去 Google intern; R 教授的博士生今年去了 Uber intern; L 教授的學生今年去了一間 startup 做 intern,他太太也是 UCI CS 的碩士畢業生,在學時有做過 intern,現在在 startup 工作;其他 CS 博士生方面,一個做 embedded system 的朋友連續兩年暑假在 startup intern,也拿到了 return offer,另一個做 deep learning 的朋友也在 MS, 百度做過 intern. 碩士生方面,一個韓國朋友今年在 Amazon intern,一個台灣朋友在 Sandisk intern,以上的個案至少有一半以上是拿學生簽證,沒有身份的。當然,我已經說過我是系邊,所以找到 intern/工作的人數絕對比我知道的多得多,但是我也認為找工作是個人造化,就算我提供再多個案,跟你/我能不能找到工作也不會有太多關聯,因為找 CS 相關工作的 SOP 都已經很制式化了(刷題/修履歷/找內推/海投),也跟你是否有身份,業界景氣,還有運氣有很大的關係,但至少 UCI 的學位是有能見度的,我們的 career fair 規模頗大,Google, Facebook 也都有來辦 info session.

結語

軟體工程不是個熱門的研究領域,如果你也對軟體工程研究有興趣,希望這篇文章能提供你多一個選擇;如果你是要出國念 CS/CE/HCI,UCI 不管是在生活機能、研究環境或就業機會,都不會讓你失望。


170827

之前的文章已經提過,我對於這邊的資格考筆試非常苦惱,因為它不是考你數學或演算法等專業科目,反倒像作文比賽,考你建立答題框架與論述的能力。簡單來說,我們有一份 reading list,裡面有約 50 篇軟體工程各領域的經典文獻,你必須先讀完(可以透過修兩個 quarter 的討論課來讀及與同學教授討論),然後在考試時回答一些大哉問的問題,表現出你有架構問題及引用文獻的能力。考試分成早上下午各 3 小時,早上回答兩題必答題,下午回答兩題 8 選 2 的選答。考試結果有 PhD Pass, Master Pass 及 Fail 三種 (沒錯,UCI 的 Software Engineering program 碩士畢業也要考這個筆試,或是選擇寫論文)。今年我回答的題目如下:

  1. 敏捷式開發流程已經征服了全世界,為什麼?既然我們崇尚「快速產出雛型再根據反饋修改」的流程,那幾十年來在軟體開發流程,分析,架構,測試等子領域上的軟體工程研究,究竟有什麼意義?有哪些研究為實務打下了基礎?又有哪些研究是「沒用」的?
  2. 不同的軟體工程研究方法 (質化與量化的各種方法,如問卷、數據分析、理論推導、訪談、個案研究等),各有什麼優缺點?舉出幾個例子說明哪些研究子領域特別適合哪些研究方法 (例如 Usability 的研究特別適合用訪談或問卷,Formal verification 特別適合用理論推導等);舉出一個在文獻上很少搭配在一起的研究領域及研究方法,但你覺得應該要搭配的理由。
  3. 有許多的軟體架構框架 (e.g., Spring, Django, Android) 被提出用來增加工程師的生產力,但同時也引入了新的問題,例如框架的 bug, 安全漏洞,及誤用等,你覺得這些問題的原因何在?有哪些研究領域能夠幫助 framework 的設計者與使用者緩解這些問題?
  4. 研究者對於什麼是好的研究方法 (或說什麼才叫做知識) 有不同的立場。有些人認為:根據可觀察及量測的事實現象推演而來的,才叫做知識,推演過程及結果不因人而異;有些人認為:知識形成的過程不能抽離應用該知識的人與環境,什麼叫做「有用」是見仁見智的。討論這兩種看法的哲學立場,並舉出一個可以同時應用這兩種立場的研究領域。

每個問題有一個半小時,如果是你,會怎麼回答這些問題?光是第一題就要你質疑自己作研究的意義,其他題目則是研究方法與哲學立場的探討。坦白說,對於 Native speaker 且有許多實務經驗的人來說,可能不太需要熟讀文獻就能行雲流水的寫出想法,但我只能戰戰兢兢的準備。

讀這些東西有用嗎?這剛好呼應了考試的題目:什麼叫做「有用」?怎麼定義有沒有用? 一方面來說,我之前有講過這個考試對自己的研究領域不一定有幫助,而且早期的文獻 (8, 90 年代) 多半還在爭論「軟體工程」作為一門 engineering discipline 的正當性,當時的爭論在今天答案已經很明顯。但另一方面,這樣的考試對於非英文母語的我來說,是練習讀寫及思考的大好機會,這也是研究的基礎能力;再者有些文獻是我的研究領域一輩子也不會讀到的,讀這些 paper 目前看來沒用,但總是增加了知識廣度,哪一天可能會派上用場。因此我還是很高興有機會準備這個考試。


本文範例程式, 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)

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