請啟用JavaScript來查看內容

[Python爬蟲實例] PChome 線上購物 (上篇)

前言

在之前的 Python 網路爬蟲實例講解過蝦皮購物,而今日要來講解同樣在台灣非常多人使用的「PChome 線上購物」。

實際操作後發現,PChome 在一個頁面中針對不同部分的資料有送出不同的請求,整理成一篇會太長,因此我將分為上下篇來介紹。
上篇也就是你正在看的本篇,主要講解在 PChome 搜尋頁面的請求,而下篇是說明商品頁面的請求。


* 「PChome 線上購物」與「PChome 24h購物」兩者的商品搜尋都是導到同樣的頁面,因此實際上是一樣的。

備註:此文僅教育學習,切勿用作商業用途,個人實作皆屬個人行為,本作者不負任何法律責任

線上購物 (來源:Pexels)
線上購物 (來源:Pexels)

套件

此次 Python 爬蟲主要使用到的套件:

安裝

1
pip install requests

搜尋商品

進入 PChome 線上購物 網頁,先開啟瀏覽器的 開發人員工具 (F12 或 Ctrl + Shift + i),回到網頁左上角輸入想要搜尋的關鍵字,點擊"找商品"。

首頁左上角搜尋欄位
首頁左上角搜尋欄位

畫面跳轉到搜尋結果頁面後,開發人員工具切換到"Network" > "XHR",會發現其中一個請求包含著搜尋結果,而 prods 欄位內分別列出了每一項商品。

"Network" > "XHR" 頁面
"Network" > "XHR" 頁面

切換到"Headers"頁面可以查看請求的網址與方法。

"Headers" 頁面
"Headers" 頁面

我們可以開一個新分頁,將此請求網址貼上前往,查看回傳的資料是否正確、長什麼樣子。

請求路徑與參數

請求網址:https://ecshweb.pchome.com.tw/search/v3.3/all/results?q=%E9%8D%B5%E7%9B%A4&page=1&sort=sale/dc
請求方法:GET

商品搜尋頁面有多項篩選條件,從下圖可以看到有關鍵字、商店類別、排序、取貨方式、價格範圍、依館別顯示等等。

搜尋頁面 篩選功能
搜尋頁面 篩選功能

而這些篩選條件正好對應網址後方帶的參數,我實際一個一個測試後,整理出來如下:

關鍵字
放在 q 參數內。

商店類別
放在網址路徑內,可參考下方程式。

數值意思
all全部
24h24h購物
24b24h書店
vdr廠商出貨
tourPChome旅遊

排序
sort 參數。

數值意思
sale/dc有貨優先
rnk/dc精準度
prc/dc價錢由高至低
prc/ac價錢由低至高
new/dc新上市

取貨方式

數值意思
cvs=all超商取貨
ipost=Yi 郵箱取貨

價格範圍
price 參數。
我測試的結果是兩者都要,也就是要包含最大金額與最小金額的限制。
{price_max}-{price_min}

頁數
page 參數。
回傳資料預設是 20 筆商品,如果要繼續往後抓取,需要帶入頁數參數。

回傳資料

它以 JSON 格式回傳,商品會在 prods 欄位內,每一個商品資料如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
    "Id": "DGCD1E-A900AIK2Q",
    "cateId": "DGCA3Q",
    "picS": "/items/DGCD1EA900AIK2Q/000002_1582170821.jpg",  
    "picB": "/items/DGCD1EA900AIK2Q/000001_1617156434.jpg",
    "name": "SanDisk USB Type-C™ 雙用隨身碟128GB (公司貨)",
    "describe": "具備usb type-c與type-a旋轉式2合1金屬隨身碟。...",
    "price": 629,
    "originPrice": 629,
    "author": "",
    "brand": "",
    "publishDate": "",
    "sellerId": "",
    "isPChome": 1,
    "isNC17": 0,
    "couponActid": [],
    "BU": "ec"
}

以上有許多欄位,這邊挑幾個已知道意思的來說明:

欄位代表意思
Id商品ID
cateId分類ID
picS商品圖片(縮圖)
picB商品圖片(主圖)
name商品名稱
describe商品描述
price商品價格
originPrice商品原始價格
author(書籍)作者
brand(書籍)出版社
publishDate(書籍)出版日期
isNC17是否限制級

商品圖片分為"picS"與"picB",查看後我猜測"picS"是在搜尋頁面的圖片,而"picB"是位於商品本身頁面的。

前方再加上 https://d.ecimg.tw 即圖片完整網址。
https://d.ecimg.tw/items/DGCD1EA900AIK2Q/000002_1582170821.jpg

"商品網址"是商品ID前方加上 https://24h.pchome.com.tw/prod/,以上方為例就是:
https://24h.pchome.com.tw/prod/DGCD1E-A900AIK2Q

範例程式

"搜尋商品"部分範例程式如下,完整程式碼在文章最後會附上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def search_products(self, keyword, max_page=1, shop='全部', sort='有貨優先', price_min=-1, price_max=-1, is_store_pickup=False, is_ipost_pickup=False):
    """搜尋商品

    :param keyword: 搜尋關鍵字
    :param max_page: 抓取最大頁數
    :param shop: 賣場類別 (全部、24h購物、24h書店、廠商出貨、PChome旅遊)
    :param sort: 商品排序 (有貨優先、精準度、價錢由高至低、價錢由低至高、新上市)
    :param price_min: 篩選"最低價" (需與 price_max 同時用)
    :param price_max: 篩選"最高價" (需與 price_min 同時用)
    :param is_store_pickup: 篩選"超商取貨"
    :param is_ipost_pickup: 篩選"i 郵箱取貨"
    :return products: 搜尋結果商品
    """
    products = []
    all_shop = {
        '全部': 'all',
        '24h購物': '24h',
        '24h書店': '24b',
        '廠商出貨': 'vdr',
        'PChome旅遊': 'tour',
    }
    all_sort = {
        '有貨優先': 'sale/dc',
        '精準度': 'rnk/dc',
        '價錢由高至低': 'prc/dc',
        '價錢由低至高': 'prc/ac',
        '新上市': 'new/dc',
    }

    url = f'https://ecshweb.pchome.com.tw/search/v3.3/{all_shop[shop]}/results'
    params = {
        'q': keyword,
        'sort': all_sort[sort],
        'page': 0
    }
    if price_min >= 0 and price_max >= 0:
        params['price'] = f'{price_min}-{price_max}'
    if is_store_pickup:
        params['cvs'] = 'all'   # 超商取貨
    if is_ipost_pickup:
        params['ipost'] = 'Y'   # i 郵箱取貨

    while params['page'] < max_page:
        params['page'] += 1
        data = self.request_get(url, params)
        if not data:
            print(f'請求發生錯誤:{url}{params}')
            break
        if data['totalRows'] <= 0:
            print('找不到有關的產品')
            break
        products.extend(data['prods'])
        if data['totalPage'] <= params['page']:
            break
    return products

商品販售狀態

如果你還想要"商品販售狀態”,會發現此欄位不包含在上方"搜尋商品"請求的回傳資料中,我們要再從其他的地方找找。

商品狀態按鈕
商品狀態按鈕

試試用"立即訂購"之類的關鍵字在開發人員工具中搜尋,在某個搜尋到的 JavaScript 檔案發現到"orderNow"、"soldOut"等字眼。

searchplus JS
searchplus JS

再進一步以"soldOut"搜尋,在另外一個 JavaScript 檔案發現到此資訊。

button JS
button JS

雖然它是 JS 檔,但你只要把網址後方的 &_callback=jsonpcb_button 刪掉,它就會以 JSON 格式回傳了。

請求路徑與參數

請求網址路徑很大一串,經過我刪減、測試,其實最少只需要如下:

請求網址:https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod/button&id=DRAD1K-A900AWN96
請求方法:GET

如果需要,可以一次帶入多個商品一次請求,只需將商品 ID 以 , 連接,例如:
https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod/button&id=DRAD1K-A900AWN96,DSAA6I-A900B3488
就不需要分多次請求了。

也可以透過 fields 參數來指定你想要那些欄位,例如我只要商品ID、商品數量、商品狀態:
https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod/button&id=DRAD1K-A900AWN96&fields=Id,Qty,ButtonType

回傳資料

它一樣以 JSON 格式回傳:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[{
    "Seq": 25977002,
    "Id": "DCAD6E-A900B1YM4-000",
    "Store": "DCAD6E",
    "Price": {"M": 0, "P": 550, "Prime": ""},
    "Qty": 7, 
    "ButtonType": "ForSale",
    "SaleStatus": 1,
    "Group": "DCAD6E-A900B1YM4",
    "isPrimeOnly": 0,
    "SpecialQty": 0
}]

ButtonType 的值可以看出此商品是否還有貨,像是 ForSale 代表此商品有出售、可以購買;SoldOut 代表此商品已售完等等。

範例程式

"商品販售狀態"部分範例程式如下,完整程式碼在文章最後會附上。

這邊我設計成傳入單個商品的字串(String),或多個商品的字串陣列(List)都可以,比較彈性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def get_products_sale_status(self, products_id):
    """取得商品販售狀態

    :param products_id: 商品 ID
    :return data: 商品販售狀態資料
    """
    if type(products_id) == list:
        products_id = ','.join(products_id)
    url = f'https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod/button&id={products_id}'
    data = self.request_get(url)
    if not data:
        print(f'請求發生錯誤:{url}')
        return []
    return data

商品規格種類

除了"商品狀態"不在原本的回傳資料中,還有某些商品有不同的規格,這部分也是要另外抓。

商品規格種類
商品規格種類

一樣透過搜尋,找到了一個請求的路徑。

查找"商品規格種類"路徑
查找"商品規格種類"路徑

請求路徑與參數

請求網址:https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod/spec&id=DYARL9-A900AZTJT&_callback=jsonpcb_spec
請求方法:GET

id 就是給你要找的商品 ID。
跟上一節"商品販售狀態"一樣,如需一次請求多個商品,只需將商品 ID 以 , 連接,例如:
https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod/spec&id=DYARL9-A900AZTJT,DYARLA-A900AZTJJ&_callback=jsonpcb_spec

回傳資料

這邊有個小小麻煩的點,如果像"商品販售狀態"一樣把後方 _callback=json... 去除,反而它就不回傳資料了,但加了這樣會使得回傳資料前後會有一段 JS 語法,所以我們要先將其字串移除後,才是正確的 JSON 格式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
try {
    jsonpcb_spec({
        "DYARIO-A900B0P77": [{
            "Seq": 25866719,
            ...(省略)...
            "PreOrdDate": ""
        }]
    });
} catch (e) {
    if (window.console) {
        console.log(e);
    }
}

* 題外話:我原本是想用 str.lstrip()str.rstrip() 去移除,但遇到了點問題,所以後來改用字串切割的方式。


去除前後 JS 語法後回傳資料範例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
    "DYARL9-A900AZTJT": [
        {
            "Seq": 25787942,
            "Id": "DYARL9-A900AZTJT-001",
            "Name": "防摔!四角加厚空壓殼 iPhone 12 / i12 手機殼 保護殼 手機套 軟殼 保護套 防撞",
            "Spec": "透藍",
            "Group": "DYARL9-A900AZTJT",
            "Pic": {
                "B": null,
                "S": null
            },
            "Qty": 0,
            "isPreOrder24h": 0,
            "PreOrdDate": ""
        },
        {
            "Seq": 25787944,
            "Id": "DYARL9-A900AZTJT-003",
            "Name": "防摔!四角加厚空壓殼 iPhone 12 / i12 手機殼 保護殼 手機套 軟殼 保護套 防撞",
            "Spec": "透粉",
            "Group": "DYARL9-A900AZTJT",
            "Pic": {
                "B": null,
                "S": null
            },
            "Qty": 6,
            "isPreOrder24h": 0,
            "PreOrdDate": ""
        }
    ]
}

範例程式

"商品規格種類"部分範例程式如下,完整程式碼同樣在文章最後會附上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def get_products_specification(self, products_id):
    """取得商品規格種類

    :param products_id: 商品 ID
    :return data: 商品規格種類
    """
    if type(products_id) == list:
        products_id = ','.join(products_id)
    url = f'https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod/spec&id={products_id}&_callback=jsonpcb_spec'
    data = self.request_get(url, to_json=False)
    # 去除前後 JS 語法字串
    data = json.loads(data[17:-48])
    return data

搜尋商品分類

至於位於搜尋頁面的左側,這一大串的"分類"該從何而來?

搜尋頁面的商品分類
搜尋頁面的商品分類

找到了一個請求的路徑。

"搜尋商品分類"路徑
"搜尋商品分類"路徑

請求路徑與參數

請求網址:https://ecshweb.pchome.com.tw/search/v3.3/all/categories?q=鍵盤
請求方法:GET

q 後方就是接你是用什麼關鍵字搜尋商品的。

回傳資料

因為資料量比較大,我挑一小部分將解其結構:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[
  {
    "Id": "D",
    "name": "24h購物",
    "qty": 12552,
    "nodes": [
      {
        "Id": "DSAU",
        "name": "DIY電腦",
        "qty": 1418,
        "nodes": [
          {
            "Id": "DSAUF4",
            "qty": 189
          },
          {
            "Id": "DSAUF5",
            "qty": 176
          },
          {
            "Id": "DSAUFA",
            "qty": 154
          }
        ]
      }
    ]
  }
]

它是這樣由最大項分類一層一層下來,每一層會有 Idnameqty 等參數,代表此分類的"ID"、"名稱"、"商品數量"。

但你可能會發現,怎麼到了最下面一層(第三層)卻沒了"名稱(name)"?!
該不會…對😅沒錯,它還要再透過另一個請求去取得,這部分會在下一章節介紹。

範例程式

"搜尋商品分類"部分範例程式如下,很簡單,也沒什麼參數,完整程式碼同樣在文章最後會附上。

1
2
3
4
5
6
7
8
9
def get_search_category(self, keyword):
    """取得搜尋商品分類(網頁左側)

    :param keyword: 搜尋關鍵字
    :return data: 分類資料
    """
    url = f'https://ecshweb.pchome.com.tw/search/v3.3/all/categories?q={keyword}'
    data = self.request_get(url)
    return data

至於此分類該如何用來當搜尋商品的篩選條件,這部分就留給你們親自嘗試了,不會很複雜 (真的!)。

搜尋商品子分類名稱

從上一章節,我們發現到商品分類的請求資料中,沒有包含最下面一層(第三層)的"name(名稱)"欄位。

搜尋商品子分類名稱
搜尋商品子分類名稱

請求路徑與參數

請求網址:https://ecapi-pchome.cdn.hinet.net/cdn/ecshop/cateapi/v1.5/store&id=DSAUF4
請求方法:GET

id 後方接在上一章節"搜尋商品分類"中回傳的子分類。
一樣,如需一次請求多個商品,只需將子分類 ID 以 , 連接,例如:
https://ecapi-pchome.cdn.hinet.net/cdn/ecshop/cateapi/v1.5/store&id=DSAUF4,DSAUF5,DSAUFA&fields=Id,Name

此請求路徑還有個 fields 參數,可指定回傳欄位,例如我只要"ID"跟"名稱":
https://ecapi-pchome.cdn.hinet.net/cdn/ecshop/cateapi/v1.5/store&id=DSAU&fields=Id,Name

回傳資料

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[
    {
        "Id": "DSAUF4",
        "Name": "→10代Core i7八核",
        "OriginId": "",
        "Sort": 7,
        "is24h": 1,
        "isVip": 0,
        "isPick": 0,
        "isNC17": 0,
        "Extra": {
            "Amount": "",
            "Fee": "",
            "Intro": ""
        },
        "isNFC": 0
    }
]

範例程式

"搜尋商品子分類名稱"部分範例程式如下,完整程式碼同樣在文章最後會附上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def get_search_categories_name(self, categories_id):
    """取得商品子分類的名稱(網頁左側)

    :param categories_id: 分類 ID
    :return data: 子分類名稱資料
    """
    if type(categories_id) == list:
        categories_id = ','.join(categories_id)
    url = f'https://ecapi-pchome.cdn.hinet.net/cdn/ecshop/cateapi/v1.5/store&id={categories_id}&fields=Id,Name'
    data = self.request_get(url)
    return data

完整程式碼

附上完整程式碼:pchome_spider01.py
(對超連結右鍵 > 另存連結為)

延伸練習

  1. 在「搜尋頁面的商品分類」章節有介紹到商品所屬的分類,一般操作可以點擊分類來篩選商品,那在程式裡我們該如何送出指定分類的請求呢?
    (小提示:注意觀察送出請求的路徑)

結語

這篇文章主要是說明在搜尋頁面的請求,而之後會再寫一篇文章講解商品頁面,敬請其待。

如果對文章有任何疑問或心得,歡迎在底下按讚、留言喔~😎




比別人多一點執著,你就會創造奇蹟。


🔻 如果覺得喜歡,歡迎在下方獎勵我 5 個讚~
分享

Jia
作者
Jia
軟體工程師