本教學改編自 SICSS Singapore 2023 工作坊代碼,分為兩部分: Part A 爬取餐廳搜尋頁列表(10 個字段), Part B 爬取每間餐廳的詳情頁和全部用戶評論。 注意:書籤頁為 JS 動態渲染,改用靜態 HTML 的搜尋頁。OpenRice 需要登入 Cookie 才能正常訪問。
Cookie: → 4. 複製整行值,貼入 Cell 3 的 COOKIE_STRhttps://www.openrice.com/zh/hongkong/restaurants?page={N}| 字段 | XPath Expression | 語法重點 |
|---|---|---|
| 餐廳名稱 | .//div[@class='poi-name poi-list-cell-link']/text() | 精確 class 匹配 |
| 餐廳連結 | .//a[@class='poi-list-cell-desktop-right-link-overlay']/@href | @href 屬性提取 |
| 地址 | .//div[@class='poi-list-cell-desktop-right-top-wrapper-main']/div[2]/text() | 位置索引 [2] |
| 地區 | .//span[contains(@class,'poi-list-cell-line-info-link')][1]//text() | contains() + 位置 predicate [1] |
| 菜式 | .//span[contains(@class,'poi-list-cell-line-info-link')][2]//text() | contains() + 位置 predicate [2] |
| 人均消費 | .//span[contains(@class,'poi-list-cell-line-info-link')][3]//text() | contains() + 位置 predicate [3] |
| 笑臉數 | .//div[@class='smile icon-wrapper big-score']//div[@class='text']/text() | descendant // 搜尋 |
| 哭臉數 | .//div[@class='cry icon-wrapper']//div[@class='text']/text() | descendant // 搜尋 |
| 收藏數 | .//div[@class='tbb-count']/text() | 精確 class 匹配 |
| 標籤 | .//span[@class='desktop-poi-tag']/text() | 多節點提取 |
# 安裝所需套件
!pip install jsonlines # 讀寫 JSON Lines 格式(每行一個 JSON 對象)
!pip install tqdm # 顯示進度條,讓你知道爬了多少
!pip install lxml # 快速 XML/HTML 解析器,支援 XPathimport requests # 發送 HTTP 請求
import time # sleep() 控制請求間隔
import csv # 儲存為 CSV 格式
import jsonlines # 讀寫 .jsonl 文件
from tqdm import tqdm # 進度條
from lxml import etree # XPath 解析引擎
from google.colab import files # Colab 自動下載# ═══════════════════════════════════════════════════════
# 如何獲取 Cookie(必讀):
# 1. 用 Chrome 登入 https://www.openrice.com
# 2. 按 F12 → Network 分頁 → 刷新頁面
# 3. 點擊任意 openrice.com 請求 → Headers → 找 "Cookie:"
# 4. 複製整行 Cookie 值,貼到下方 COOKIE_STR
# ═══════════════════════════════════════════════════════
COOKIE_STR = "PASTE_YOUR_COOKIE_HERE" # ← 貼入你的 Cookie
def process_cookie(cookie_str):
"""
將瀏覽器 Cookie 字符串轉換為 Python dict
Input: "key1=val1; key2=val2; key3=val3"
Output: {"key1": "val1", "key2": "val2", "key3": "val3"}
為何需要:requests.get() 接受 dict 格式,不接受原始字符串
"""
real_cookie = {}
for each_kv in cookie_str.split(';'):
each_kv = each_kv.strip()
if '=' in each_kv:
# 只在第一個 '=' 分割,避免值中含有 '=' 的情況
k = each_kv.split('=')[0].strip()
v = '='.join(each_kv.split('=')[1:]).strip()
real_cookie[k] = v
return real_cookie
COOKIES = process_cookie(COOKIE_STR)
# HTTP Headers:模擬真實 Chrome 瀏覽器,避免被封鎖
HEADERS = {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'accept-language': 'zh-HK,zh;q=0.9,en;q=0.8',
'referer': 'https://www.openrice.com/zh/hongkong/restaurants',
}
# 設定參數
OPENRICE_BASE_URL = 'https://www.openrice.com' # 用於拼接相對 URL
SEARCH_URL = 'https://www.openrice.com/zh/hongkong/restaurants' # 餐廳搜尋頁
MAX_PAGES = 5 # 爬幾頁(每頁 15 間餐廳)
import random
def sleep_random(min_s=1.0, max_s=2.5):
"""隨機休息 min_s 到 max_s 秒,模擬人類瀏覽行為,防止被反爬封鎖"""
time.sleep(random.uniform(min_s, max_s))
print("✅ Cookie 設定完成")# ═══════════════════════════════════════════════════════
# 目標 URL:
# https://www.openrice.com/zh/hongkong/restaurants?page=N
# 注意:書籤頁(bookmarkrestaurant.htm)為 JS 動態渲染,
# requests 無法獲取數據,改用靜態 HTML 的搜尋頁
# ═══════════════════════════════════════════════════════
print("=" * 60)
print("Part A: 爬取 OpenRice 餐廳搜尋列表")
print(f"頁數: 1 到 {MAX_PAGES}(每頁約 15 間餐廳)")
print("=" * 60)
bookmark_records = [] # 儲存結果列表
for page_num in tqdm(range(1, MAX_PAGES + 1), desc="搜尋頁"):
sleep_random() # 每次請求前先休息,防止反爬
# 構建分頁 URL(page 參數控制頁碼)
url = f'{SEARCH_URL}?page={page_num}'
response = requests.get(url, headers=HEADERS, cookies=COOKIES)
# 將 HTML 解析為 lxml 元素樹,才能執行 XPath
html = etree.HTML(response.text)
# ─── XPath 1:選取所有餐廳卡片 ──────────────────────────────
# 精確匹配 class 名稱(含兩個 class 值)
cards = html.xpath("//div[@class='poi-list-cell poi-list-cell-desktop-container']")
print(f" 第 {page_num} 頁:找到 {len(cards)} 個餐廳卡片")
for card in cards:
rec = {}
# ─── XPath 2:餐廳名稱 ───────────────────────────────────
# 精確匹配含兩個 class 的 div
name_nodes = card.xpath(".//div[@class='poi-name poi-list-cell-link']/text()")
rec['name'] = name_nodes[0].strip() if name_nodes else ''
# ─── XPath 3:餐廳詳情頁連結 ────────────────────────────
# 使用 overlay link(覆蓋整個卡片的透明連結)
url_nodes = card.xpath(".//a[@class='poi-list-cell-desktop-right-link-overlay']/@href")
rec['url'] = OPENRICE_BASE_URL + url_nodes[0] if url_nodes else ''
# ─── XPath 4:地址(第 2 個 div 子元素)────────────────
addr_nodes = card.xpath(".//div[@class='poi-list-cell-desktop-right-top-wrapper-main']/div[2]/text()")
rec['address'] = addr_nodes[0].strip() if addr_nodes else ''
# ─── XPath 5:地區(第 1 個 info-link)────────────────
# 用 contains() 因為 class 可能含多個值
# 用 //text() 提取所有後代文字節點(包括 <span> 子元素內的文字)
# 過濾空白後取最後一個非空文字(跳過圖示 span 的空文字)
district_nodes = [t.strip() for t in card.xpath(".//span[contains(@class,'poi-list-cell-line-info-link')][1]//text()") if t.strip()]
rec['district'] = district_nodes[-1] if district_nodes else ''
# ─── XPath 6:菜式(第 2 個 info-link)─────────────────
cuisine_nodes = [t.strip() for t in card.xpath(".//span[contains(@class,'poi-list-cell-line-info-link')][2]//text()") if t.strip()]
rec['cuisine'] = cuisine_nodes[-1] if cuisine_nodes else ''
# ─── XPath 7:人均消費(第 3 個 info-link)──────────────
price_nodes = [t.strip() for t in card.xpath(".//span[contains(@class,'poi-list-cell-line-info-link')][3]//text()") if t.strip()]
rec['price'] = price_nodes[-1] if price_nodes else ''
# ─── XPath 8:笑臉數(好評)─────────────────────────────
# 使用 descendant // 搜尋,找到 big-score 容器內的 text div
smile_nodes = card.xpath(".//div[@class='smile icon-wrapper big-score']//div[@class='text']/text()")
rec['smile_count'] = smile_nodes[0].strip() if smile_nodes else '0'
# ─── XPath 9:哭臉數(差評)─────────────────────────────
cry_nodes = card.xpath(".//div[@class='cry icon-wrapper']//div[@class='text']/text()")
rec['cry_count'] = cry_nodes[0].strip() if cry_nodes else '0'
# ─── XPath 10:收藏數 ────────────────────────────────────
bookmark_nodes = card.xpath(".//div[@class='tbb-count']/text()")
rec['bookmark_count'] = bookmark_nodes[0].strip() if bookmark_nodes else '0'
# ─── XPath 11:餐廳標籤(多個)──────────────────────────
# 提取所有 desktop-poi-tag span 的文字,用逗號連接
tag_nodes = card.xpath(".//span[@class='desktop-poi-tag']/text()")
rec['tags'] = ', '.join([t.strip() for t in tag_nodes if t.strip()])
if rec['name']: # 只保留有名稱的記錄
bookmark_records.append(rec)
print(f"\n✅ Part A 完成 — 共收集 {len(bookmark_records)} 間餐廳")PART_A_CSV = 'openrice_restaurants.csv'
# encoding='utf-8-sig' 加入 BOM(Byte Order Mark)
# 確保 Excel 在 Windows 上正確顯示中文字符
if not bookmark_records:
print("⚠️ 無數據,請先執行 Cell 4")
else:
fieldnames = ['name', 'url', 'address', 'district', 'cuisine', 'price',
'smile_count', 'cry_count', 'bookmark_count', 'tags']
with open(PART_A_CSV, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
writer.writerows(bookmark_records)
print(f"✅ 已儲存 {len(bookmark_records)} 間餐廳到 {PART_A_CSV}")
# ── 自動下載 CSV(三種方式,依序嘗試)────────────────────
def colab_download(filename):
"""
在 Google Colab 中可靠地下載文件
方法 1:files.download()(標準方式)
方法 2:JavaScript 直接觸發瀏覽器下載(更可靠)
方法 3:顯示下載連結(備用,手動點擊)
"""
import base64, os
from IPython.display import display, Javascript, HTML
# 方法 2:用 JavaScript 直接觸發下載(繞過彈出視窗攔截)
with open(filename, 'rb') as f:
data = base64.b64encode(f.read()).decode('utf-8')
# 注意:用 .format() 而非 f-string,避免與 Python f-string 的大括號衝突
js = """
var link = document.createElement('a');
link.href = 'data:text/csv;base64," + data + "';
link.download = '" + filename + "';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
"""
display(Javascript(js))
print(f"📥 已觸發下載:{filename}")
# 方法 3:同時顯示備用下載連結(若瀏覽器攔截可手動點擊)
html_link = '<a href="data:text/csv;base64,' + data + '" download="' + filename + '" style="color:#4ade80;font-family:monospace">⬇️ 點此手動下載 ' + filename + '</a>'
display(HTML(html_link))
colab_download(PART_A_CSV)