Python 與工具
這個分類下的問題與解答。點標題可以展開答案,也可以點右上角進到單題頁面。
使用電腦預設瀏覽器打開網頁
import webbrowser
urL='https://dev.chccd.com'
webbrowser.get('windows-default').open_new(urL)
使用Google Chrome瀏覽器打開網頁
import webbrowser
urL='https://dev.chccd.com'
chrome_path="C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
webbrowser.register('chrome', None,webbrowser.BackgroundBrowser(chrome_path))
webbrowser.get('chrome').open_new(urL)
將Python\temp.py包成執行檔
pyinstaller -F .\Python\temp.py -i .\Python\temp.ico --noconsole
錯誤訊息Failed to execute script pyi_rth_pkgres
pyinstaller -F .\Python\temp.py -i .\Python\temp.ico --hidden-import=pkg_resources.py2_warn --onefile --noconsole
錯誤訊息fake_useragent.errors.FakeUserAgentError: ('Data list is empty', None)
pyinstaller -F .\Python\temp.py -i .\Python\temp.ico --onefile --collect-datas=fake_useragent --noconsole
將temp.py包成執行檔並加密,金鑰設為234567
pyinstaller -F .\Python\temp.py --key 234567 -i .\Python\temp.ico --noconsole
加密會遇到錯誤訊息:
We need tinyaes to use byte-code obfuscation but we could not find it. You can install it with pip by running:
pip install tinyaes
安裝tinyaes套件即可。
打包相關指令
-F 就是打包成單獨的一個文件 -D 打包為一個文件夾 -w 就是窗口程序,不會跳出後面的黑框(cmd命令提示符) --key 使用tinyaes幫打包的程式加密 -d debug程序 -c 命令行程序,沒有窗口 -i 幫程式加上icon圖案 -n 產生文件的名稱
不要動系統內建的 Python
多數 Linux 發行版內建的 Python 是系統工具依賴的執行環境。直接用它安裝套件、或升級版本,可能導致系統管理工具失效。
原則:系統的 Python 用來跑系統的東西,你的專案用自己的環境。
虛擬環境是基本做法
虛擬環境讓每個專案有獨立的套件目錄,彼此不干擾。
# 建立 python3 -m venv .venv # 啟用(Linux / macOS) source .venv/bin/activate # 啟用(Windows) .venv\Scripts\activate # 離開 deactivate
為什麼一定要用
- 不同專案可能需要不同版本的套件
- 不會污染系統環境
- 可以完整記錄專案的相依套件
- 刪除整個目錄就等於清除環境,不留殘留
建議把 .venv 加入版本控制的忽略清單——它應該由設定檔重建,而不是直接納入版控。
套件管理
記錄相依套件
pip freeze > requirements.txt
在另一台機器重建
python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt
一個實務建議
pip freeze 會列出所有套件(含相依的相依),版本鎖得很死。
比較好的做法是手動維護一份只列出直接使用的套件,例如:
requests>=2.31 Pillow>=10.0 openpyxl>=3.1
需要完全重現時再另外產生鎖定版本的檔案。
版本的選擇
Python 每個版本大約有五年的支援期。正式使用的腳本建議選擇仍在支援期內的版本,並避免使用剛發布的最新版——部分套件可能還沒跟上。
實務上,選擇比最新版舊一到兩個小版本,通常是穩定與相容性的平衡點。
確認版本
python3 --version pip --version pip list
同一台機器有多個版本
可以並存,用明確的版本號呼叫:
python3.11 -m venv .venv311 python3.12 -m venv .venv312
不要修改系統的 python3 指向,那會影響系統工具。
專案的目錄結構
簡單的自動化腳本專案,建議這樣安排:
project/ ├── .venv/ # 虛擬環境(不納入版控) ├── scripts/ # 執行的腳本 ├── lib/ # 共用的函式 ├── config/ # 設定檔(敏感資訊不納入版控) ├── logs/ # 執行紀錄 ├── output/ # 產出檔案 ├── requirements.txt └── README.md
README 要寫清楚怎麼建置環境與執行——三個月後的自己會感謝你。
敏感資訊不要寫在程式碼裡
資料庫密碼、API 金鑰、主機帳號不應該直接寫在腳本中。
常見做法
import os
DB_PASSWORD = os.environ.get('DB_PASSWORD')
API_KEY = os.environ.get('API_KEY')
if not DB_PASSWORD:
raise SystemExit('缺少必要的環境變數 DB_PASSWORD')
或使用獨立的設定檔,並確實加入版本控制的忽略清單。
為什麼重要
- 避免密碼隨程式碼進入版本控制
- 不同環境可用不同設定
- 指令列中的密碼會出現在行程列表,可被同機的其他使用者看到
在伺服器上執行的注意事項
用絕對路徑
排程執行時的工作目錄可能與你預期的不同。腳本中的檔案路徑建議用絕對路徑,或明確設定工作目錄。
from pathlib import Path BASE_DIR = Path(__file__).resolve().parent LOG_FILE = BASE_DIR / 'logs' / 'run.log'
指定虛擬環境的直譯器
排程中不會自動啟用虛擬環境,要直接指定:
/path/to/project/.venv/bin/python /path/to/project/scripts/backup.py
權限
執行的身分要有讀寫相關目錄的權限。不要為了方便而用最高權限執行——腳本出錯時的破壞範圍會大很多。
常用的標準函式庫
很多需求不需要額外安裝套件:
| 模組 | 用途 |
|---|---|
pathlib | 檔案路徑處理,比字串拼接安全 |
csv | CSV 讀寫 |
json | JSON 處理 |
logging | 執行紀錄 |
argparse | 命令列參數 |
subprocess | 執行外部指令 |
datetime | 日期時間 |
shutil | 檔案複製與壓縮 |
能用標準函式庫解決的,就不要多裝套件——減少相依就減少維護負擔。
本文的範例以 Python 3 為例,實際的套件版本與 API 可能隨版本更新而異。在正式環境執行任何批次處理前,請先以少量資料測試並確實備份。
中文 CSV 的編碼問題
這是實務上最常遇到的坑。同一個檔案,用不同軟體開啟可能出現亂碼。
問題的來源
- Excel 在部分環境下預設用系統編碼開啟 CSV,而非 UTF-8
- 舊系統可能匯出 Big5 編碼
- 不同來源的檔案編碼不一致
讓 Excel 正確開啟的做法
在 UTF-8 檔案開頭加上位元組順序記號,Excel 就能正確辨識:
import csv
with open('output.csv', 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f)
writer.writerow(['標題', '內容', '日期'])
writer.writerow(['測試', '中文內容', '2026-08-05'])
關鍵是 encoding='utf-8-sig'——它會自動加上記號。
兩個必要的參數
newline=''——不加的話在某些系統會產生多餘的空行encoding——明確指定,不要依賴系統預設
讀取來源不明的檔案
def read_csv_auto(path):
encodings = ['utf-8-sig', 'utf-8', 'cp950', 'big5']
for enc in encodings:
try:
with open(path, newline='', encoding=enc) as f:
return list(csv.DictReader(f)), enc
except UnicodeDecodeError:
continue
raise ValueError('無法判斷編碼')
注意 cp950 要放在 big5 前面——它是 Big5 的擴充,涵蓋較多字元。
Big5 的字元缺漏
若需要輸出 Big5 給舊系統,部分字元可能無法轉換——例如某些罕用字、特殊符號、以及使用者造字。
text = '測試內容'
try:
data = text.encode('cp950')
except UnicodeEncodeError as e:
print(f'無法轉換的字元位置: {e.start}')
# 可改用替代字元或 HTML 實體
data = text.encode('cp950', errors='xmlcharrefreplace')
實務建議:轉換前先檢查哪些字元會有問題,決定是替換、改用實體標記,還是回報給來源修正。
用 DictReader 與 DictWriter
處理有標題列的 CSV 時,用字典的方式比索引可靠得多:
import csv
# 讀取
with open('input.csv', newline='', encoding='utf-8-sig') as f:
rows = list(csv.DictReader(f))
for row in rows:
print(row['title'], row['slug'])
# 寫出
fields = ['title', 'slug', 'content']
with open('output.csv', 'w', newline='', encoding='utf-8-sig') as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(rows)
好處是欄位順序改變時程式不會壞掉,也比 row[3] 這種寫法容易理解。
欄位中包含換行或逗號
CSV 模組會自動處理引號跳脫,不要自己用字串拼接產生 CSV。
# 不要這樣做
line = f'{title},{content}\n' # content 有逗號就爆了
# 應該用 csv 模組
writer.writerow([title, content])
內容含 HTML 或多行文字時,這一點特別重要。
Excel 檔案的處理
from openpyxl import load_workbook, Workbook
# 讀取
wb = load_workbook('data.xlsx', data_only=True)
ws = wb.active
for row in ws.iter_rows(min_row=2, values_only=True):
print(row)
# 寫出
wb = Workbook()
ws = wb.active
ws.append(['標題', '數量'])
ws.append(['測試', 100])
wb.save('output.xlsx')
data_only=True 會讀取公式的計算結果而非公式本身——多數情況這才是你要的。
注意事項
- 舊的
.xls格式需要不同的套件 - 大型檔案讀取時考慮使用唯讀模式以節省記憶體
- 儲存格格式與樣式需要另外處理
JSON 處理
import json
# 讀取
with open('data.json', encoding='utf-8') as f:
data = json.load(f)
# 寫出(中文不要被轉成跳脫序列)
with open('output.json', 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
ensure_ascii=False 很重要——不加的話中文會變成一堆跳脫字元,雖然功能正常但完全無法閱讀。
批次重新命名檔案
from pathlib import Path
import re
folder = Path('/path/to/images')
for f in folder.glob('*.jpg'):
# 轉小寫、空格改連字號、移除特殊字元
new_name = re.sub(r'[^a-z0-9\-.]', '',
f.name.lower().replace(' ', '-'))
if new_name != f.name:
target = f.with_name(new_name)
if target.exists():
print(f'略過(已存在): {new_name}')
continue
f.rename(target)
print(f'{f.name} -> {new_name}')
三個安全習慣
- 先跑一次「只印出不執行」的版本,確認結果符合預期
- 檢查目標檔名是否已存在,避免覆蓋
- 處理前備份
檔名對搜尋的影響見圖片的替代文字與檔名該怎麼寫。
資料清理的常見需求
import re
def clean_text(s):
if s is None:
return ''
s = str(s)
s = s.replace('\u00a0', ' ') # 不斷行空白
s = re.sub(r'[ \t]+', ' ', s) # 連續空白
s = re.sub(r'\n{3,}', '\n\n', s) # 過多空行
return s.strip()
不斷行空白是最常見的隱形問題——它看起來像空格但不是,會造成比對失敗或版面異常。
處理大檔案
不要一次把整個檔案載入記憶體:
with open('large.csv', newline='', encoding='utf-8-sig') as f:
for row in csv.DictReader(f):
process(row) # 逐列處理
需要輸出時同樣逐列寫入,記憶體用量就與檔案大小無關。
本文的範例以 Python 3 為例,實際的套件版本與 API 可能隨版本更新而異。在正式環境執行任何批次處理前,請先以少量資料測試並確實備份。
自動化的三個原則
- 失敗要能被知道——靜默失敗是最糟的情況
- 可以重複執行——重跑一次不應造成問題
- 留下紀錄——出事時能追溯
第一項最常被忽略。排程腳本停止運作而沒人發現,比沒有這個腳本更危險——因為你以為它在跑。
用 logging 而不是 print
import logging
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
LOG_FILE = BASE_DIR / 'logs' / 'task.log'
LOG_FILE.parent.mkdir(exist_ok=True)
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
encoding='utf-8',
)
logging.info('開始執行')
logging.warning('略過 3 筆資料')
logging.error('連線失敗')
為什麼不用 print
- 排程執行時 print 的輸出可能不知去向
- 沒有時間戳記
- 無法分級
- 無法在不改程式的情況下調整詳細程度
錯誤處理與通知
import logging, sys, traceback
def main():
# 實際工作
...
if __name__ == '__main__':
try:
main()
logging.info('執行完成')
except Exception:
logging.error('執行失敗\n' + traceback.format_exc())
notify('備份腳本執行失敗,請查看日誌')
sys.exit(1)
通知的方式
- 寄送電子郵件
- 推送到通訊軟體
- 寫入監控系統
離開時要回傳非零的結束碼——排程系統與監控工具可以據此判斷失敗。
只監控失敗是不夠的
這一點很重要:如果排程根本沒執行,就不會有失敗通知。
建議的做法
- 成功時也記錄一筆,並定期確認有這筆紀錄
- 檢查產出的檔案——備份檔的時間戳是否為最新
- 設定「超過 N 小時未成功執行就通知」的監控
這與 API 串接的靜默失效是同樣的問題。
資料庫備份腳本
import subprocess, gzip, shutil, os
from pathlib import Path
from datetime import datetime, timedelta
BACKUP_DIR = Path('/backup/db')
KEEP_DAYS = 14
def backup(dbname):
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
sql_path = BACKUP_DIR / f'{dbname}_{ts}.sql'
cmd = [
'mysqldump',
'--defaults-extra-file=/etc/mysql/backup.cnf',
'--single-transaction', '--routines', '--triggers', '--events',
'--default-character-set=utf8mb4',
dbname,
]
with open(sql_path, 'wb') as out:
subprocess.run(cmd, stdout=out, check=True)
# 壓縮
gz_path = sql_path.with_suffix('.sql.gz')
with open(sql_path, 'rb') as f_in, gzip.open(gz_path, 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
sql_path.unlink()
# 驗證檔案不是空的
if gz_path.stat().st_size < 1024:
raise RuntimeError('備份檔案異常過小')
return gz_path
三個重點
- 密碼放在設定檔而非指令列——指令列參數會出現在行程列表
check=True——指令失敗時會拋出例外,不會靜默continue- 驗證產出檔案——大小異常就視為失敗
備份的完整要求見資料庫備份與還原的技術要點。
清理過期檔案
from datetime import datetime, timedelta
def cleanup(folder, keep_days=14):
cutoff = datetime.now() - timedelta(days=keep_days)
removed = 0
for f in Path(folder).glob('*.gz'):
if datetime.fromtimestamp(f.stat().st_mtime) < cutoff:
f.unlink()
removed += 1
logging.info(f'清除過期備份 {removed} 個')
務必先用「只列出不刪除」的版本確認,刪錯檔案是無法復原的。
日誌分析
import re
from collections import Counter
pattern = re.compile(r'^(\S+) .* "(\w+) (\S+).*" (\d{3})')
def analyze(log_path, top=20):
ips, paths, status = Counter(), Counter(), Counter()
with open(log_path, encoding='utf-8', errors='replace') as f:
for line in f:
m = pattern.match(line)
if not m:
continue
ip, method, path, code = m.groups()
ips[ip] += 1
status[code] += 1
if code == '404':
paths[path] += 1
return ips.most_common(top), status, paths.most_common(top)
實用的分析方向
- 請求量異常的來源——可能是掃描或攻擊
- 404 最多的路徑——改版後的遺漏轉址
- 狀態碼分布——5xx 突然增加代表有問題
日誌判讀見Nginx 與 Apache 的疑難排解。
排程設定
# crontab -e # 每日凌晨 3 點備份 0 3 * * * /path/to/.venv/bin/python /path/to/scripts/backup.py # 每小時檢查 0 * * * * /path/to/.venv/bin/python /path/to/scripts/check.py
四個常見的排程陷阱
- 沒用絕對路徑——排程的工作目錄與環境變數與登入時不同
- 沒指定虛擬環境的直譯器——會用到系統的 Python 而找不到套件
- 時區設定不同——確認伺服器時區
- 執行時間重疊——上一次還沒跑完就啟動下一次
防止重複執行
import fcntl, sys
lock_file = open('/tmp/mytask.lock', 'w')
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
logging.warning('前一次執行尚未結束,本次略過')
sys.exit(0)
可重複執行的設計
腳本應該重跑一次也不會造成問題:
- 寫入前先檢查是否已存在
- 用「更新或新增」而非單純新增
- 檔案輸出時包含時間戳,或先寫暫存檔再改名
# 先寫暫存檔,完成後才改名
tmp = target.with_suffix('.tmp')
write_data(tmp)
tmp.replace(target) # 原子性操作
這樣即使中途失敗,也不會留下半完成的檔案。
先做不會造成傷害的版本
任何會刪除、覆蓋、修改資料的腳本,建議都先做一個「只顯示會做什麼」的模式:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--dry-run', action='store_true',
help='只顯示會執行的動作,不實際執行')
args = parser.parse_args()
if args.dry_run:
print(f'[模擬] 將刪除 {f}')
else:
f.unlink()
這個習慣可以避免大部分的災難。
本文的範例以 Python 3 為例,實際的套件版本與 API 可能隨版本更新而異。在正式環境執行任何批次處理前,請先以少量資料測試並確實備份。
批次處理的價值
網站上線前常常需要處理數十到數百張圖片:縮到適當尺寸、壓縮、轉換格式、統一命名。
手動處理耗時且容易遺漏,寫一次腳本可以重複使用。
圖片規格的原則見圖片尺寸與壓縮。
基本的縮圖與壓縮
from PIL import Image
from pathlib import Path
def resize_image(src, dst, max_width=1200, quality=82):
with Image.open(src) as im:
# 依 EXIF 方向資訊自動轉正
from PIL import ImageOps
im = ImageOps.exif_transpose(im)
if im.width > max_width:
ratio = max_width / im.width
new_size = (max_width, int(im.height * ratio))
im = im.resize(new_size, Image.LANCZOS)
# JPEG 不支援透明,需先轉換
if im.mode in ('RGBA', 'P'):
im = im.convert('RGB')
im.save(dst, 'JPEG', quality=quality, optimize=True,
progressive=True)
四個重點
exif_transpose——手機拍的照片常帶有方向資訊,不處理會呈現橫倒LANCZOS——縮圖品質較好的演算法- 模式轉換——含透明度的圖存成 JPEG 會出錯
progressive=True——漸進式載入,大圖的體感較好
批次處理整個資料夾
def batch_resize(src_dir, dst_dir, max_width=1200):
src_dir, dst_dir = Path(src_dir), Path(dst_dir)
dst_dir.mkdir(parents=True, exist_ok=True)
exts = {'.jpg', '.jpeg', '.png', '.webp'}
count = 0
for f in src_dir.iterdir():
if f.suffix.lower() not in exts:
continue
try:
resize_image(f, dst_dir / f'{f.stem}.jpg', max_width)
count += 1
except Exception as e:
print(f'處理失敗 {f.name}: {e}')
print(f'完成 {count} 張')
輸出到不同的目錄,不要覆蓋原檔。 原始檔應保留,因為壓縮是不可逆的。
產生多種尺寸
網站常需要同一張圖的不同尺寸——列表縮圖、內容圖、主視覺。
SIZES = {
'thumb': 400,
'medium': 800,
'large': 1600,
}
def make_variants(src, dst_dir):
dst_dir = Path(dst_dir)
for name, width in SIZES.items():
out = dst_dir / f'{Path(src).stem}-{name}.jpg'
resize_image(src, out, max_width=width)
只縮小不放大——原圖比目標小時應保持原尺寸,放大只會變模糊又增加檔案大小。
轉換為較新的格式
def to_webp(src, dst, quality=80):
with Image.open(src) as im:
im.save(dst, 'WEBP', quality=quality, method=6)
method=6 是壓縮力度,數字越大檔案越小但處理越慢。批次處理時值得用。
關於 AVIF
壓縮率通常更好,但需要額外的套件支援,且處理速度較慢。建議先確認目標環境的支援情況。
實務建議
保留原始格式作為備援,同時產生新格式,讓網頁端依瀏覽器支援情況選擇。
檢查與報告
處理前先了解現況,往往能發現問題:
def audit(folder):
rows = []
for f in Path(folder).rglob('*'):
if f.suffix.lower() not in {'.jpg', '.jpeg', '.png', '.webp'}:
continue
try:
with Image.open(f) as im:
rows.append({
'path': str(f),
'width': im.width,
'height': im.height,
'kb': round(f.stat().st_size / 1024, 1),
})
except Exception:
rows.append({'path': str(f), 'error': '無法讀取'})
# 依檔案大小排序,找出最需要處理的
rows.sort(key=lambda r: r.get('kb', 0), reverse=True)
return rows
把結果輸出成 CSV,就能快速看出哪些圖片過大。 通常前二十名就佔了大部分的空間。
移除 EXIF 資訊
手機拍攝的照片可能包含拍攝地點的座標。放上網站前建議移除。
def strip_exif(src, dst):
with Image.open(src) as im:
from PIL import ImageOps
im = ImageOps.exif_transpose(im) # 先依方向轉正
data = list(im.getdata())
clean = Image.new(im.mode, im.size)
clean.putdata(data)
clean.save(dst)
注意順序:要先依方向資訊轉正,再移除,否則圖片會變成橫的。
為什麼重要
不動產、餐飲、居家服務等行業,若把含座標的照片上傳,等於公開了拍攝地點——可能涉及客戶的隱私。
加上浮水印
from PIL import Image
def add_watermark(src, dst, mark_path, opacity=128, margin=20):
with Image.open(src).convert('RGBA') as base:
with Image.open(mark_path).convert('RGBA') as mark:
# 浮水印寬度設為主圖的六分之一
w = base.width // 6
ratio = w / mark.width
mark = mark.resize((w, int(mark.height * ratio)))
alpha = mark.split()[3].point(lambda p: p * opacity // 255)
mark.putalpha(alpha)
pos = (base.width - mark.width - margin,
base.height - mark.height - margin)
base.alpha_composite(mark, pos)
base.convert('RGB').save(dst, 'JPEG', quality=85)
浮水印無法防止盜用,但能在被轉載時保留來源標示。
處理前的安全習慣
- 永遠輸出到新目錄,不要就地覆蓋
- 先用少量檔案測試,確認結果符合預期
- 處理前備份原始檔
- 記錄處理結果——成功幾張、失敗哪些
壓縮與縮圖都是不可逆的,原始檔一旦覆蓋就回不去了。
效能考量
大量圖片處理時,可用多行程加速:
from concurrent.futures import ProcessPoolExecutor
def batch_parallel(files, workers=4):
with ProcessPoolExecutor(max_workers=workers) as ex:
list(ex.map(process_one, files))
行程數不要超過 CPU 核心數太多——圖片處理是運算密集的工作,開太多反而互相競爭。
在正式主機上執行時要特別注意,避免影響網站的正常服務。建議在離峰時段或另一台機器處理。
本文的範例以 Python 3 為例,實際的套件版本與 API 可能隨版本更新而異。在正式環境執行任何批次處理前,請先以少量資料測試並確實備份。
先確認可不可以抓
技術上做得到,不代表可以做。抓取他人網站的資料前,應該先確認三件事:
- 對方的使用條款是否允許
- robots.txt 的規範
- 抓取的內容是否涉及著作權或個資
幾個明確的界線
- 不要抓取個人資料——姓名、聯絡方式、帳號等,即使是公開顯示的
- 不要整批複製他人的內容用於自己的網站——這是著作權問題
- 不要繞過登入或付費機制
- 不要造成對方伺服器的負擔
著作權的相關說明見別人的內容可以用嗎。
合理的使用情境
- 抓取自己網站的資料做檢查或搬遷
- 使用對方正式提供的 API
- 取得公開資料集或有明確授權的資料
- 經對方同意的資料交換
尊重 robots.txt
from urllib.robotparser import RobotFileParser
from urllib.parse import urljoin
def can_fetch(base_url, path, agent='MyBot'):
rp = RobotFileParser()
rp.set_url(urljoin(base_url, '/robots.txt'))
try:
rp.read()
except Exception:
return False # 讀不到就保守處理
return rp.can_fetch(agent, urljoin(base_url, path))
讀不到 robots.txt 時建議保守處理,而不是預設為允許。
基本的請求設定
import requests
session = requests.Session()
session.headers.update({
'User-Agent': 'MyCompanyBot/1.0 (+https://example.com/bot)',
})
resp = session.get(url, timeout=(5, 30))
resp.raise_for_status()
三個必要的設定
- 明確的 User-Agent——包含聯絡方式或說明頁,讓對方知道是誰在抓,有問題時能聯繫
- 逾時設定——
(連線逾時, 讀取逾時)。不設逾時是常見的錯誤,對方無回應時程式會永遠卡住 raise_for_status()——讓錯誤的狀態碼拋出例外,不要靜默繼續
控制頻率
import time, random
for url in urls:
fetch(url)
time.sleep(random.uniform(1.0, 2.5))
不要用固定的極短間隔連續請求。 這既是禮貌,也是自保——過度頻繁的請求可能被封鎖,甚至被視為攻擊行為。
建議的原則:把對方的伺服器當成你自己的網站來對待。
重試機制
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(
total=3,
backoff_factor=1.0,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=['GET', 'HEAD'],
)
session.mount('https://', HTTPAdapter(max_retries=retry))
重要的注意事項
- 429 代表請求過於頻繁——收到時應該放慢,而不是立刻重試
- backoff 讓每次重試的間隔逐次拉長,避免持續衝擊
- 只對讀取類的請求自動重試——寫入類的重試可能造成重複
解析 HTML
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, 'html.parser')
title = soup.select_one('h1')
title_text = title.get_text(strip=True) if title else ''
for a in soup.select('article a[href]'):
print(a['href'], a.get_text(strip=True))
兩個實務提醒
- 取值前先確認元素存在——網頁結構會變,直接取值容易拋出例外
- 編碼問題——若出現亂碼,可嘗試指定
resp.encoding或使用resp.apparent_encoding
內容由前端程式產生的情況
如果目標頁面的內容需要執行 JavaScript 才會出現,用一般的請求抓不到。
兩種處理方向
- 找出資料的實際來源——多數情況前端是呼叫某個 API 取得資料,直接取用該來源更穩定也更輕量
- 使用瀏覽器自動化工具——資源消耗大得多,是最後手段
優先找第一種。 用瀏覽器開發者工具的網路分頁,通常能看到實際的資料請求。
串接正式 API 的實務
def call_api(session, url, params=None):
resp = session.get(url, params=params, timeout=(5, 30))
if resp.status_code == 429:
wait = int(resp.headers.get('Retry-After', 60))
logging.warning(f'頻率限制,等待 {wait} 秒')
time.sleep(wait)
return call_api(session, url, params)
resp.raise_for_status()
return resp.json()
要注意的四件事
- 金鑰不要寫在程式碼中——用環境變數
- 注意頻率限制——查閱對方文件的規範
- 處理分頁——大量資料通常需要逐頁取得
- 記錄失敗的項目——之後可以只重試失敗的部分
串接的評估與維護見API 串接前要確認什麼。
自己網站的檢查腳本
這是最沒有爭議、也最實用的應用。
檢查連結是否失效
def check_links(urls, session):
broken = []
for u in urls:
try:
r = session.head(u, timeout=10, allow_redirects=True)
if r.status_code >= 400:
broken.append((u, r.status_code))
except Exception as e:
broken.append((u, str(e)))
time.sleep(0.5)
return broken
用 HEAD 而非 GET——只要狀態碼的話不必下載內容。但部分伺服器不支援 HEAD,失敗時可退回 GET。
其他實用的檢查
- 所有頁面是否都有標題與描述
- 圖片是否都有替代文字
- 是否有混合內容(頁面是 https 但資源是 http)
- 網站地圖中的網址是否都能正常開啟
技術檢查的項目見技術 SEO 的常見問題排查。
資料的儲存與去重
import json
from pathlib import Path
def save_progress(data, path):
tmp = Path(path).with_suffix('.tmp')
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
tmp.replace(path) # 原子性寫入
長時間執行的抓取要能中斷後續跑——定期存檔進度,並記錄已處理的項目,避免重複。
四個自我約束
- 只抓需要的資料,不要整站複製
- 控制頻率,不造成對方負擔
- 標明身分,讓對方能聯繫你
- 收到停止的要求就停止
優先使用對方提供的 API 或資料集——那才是對方希望你使用的方式,也最穩定。
本文的範例以 Python 3 為例,實際的套件版本與 API 可能隨版本更新而異。在正式環境執行任何批次處理前,請先以少量資料測試並確實備份。
準備好讓網站 開始幫你帶生意了嗎?
不論是要做新網站、救舊網站,還是只想先聊聊方向——先諮詢,不用先付錢,我們照實給你建議。


