<title>看似簡單的「抓標題」,實際面臨:編碼偵測 (Big5/UTF-8/GBK)、HTML 解析容錯、重導向、超時控制、User-Agent 偽裝、大檔案截斷等挑戰。本文提供生產級可用函式。
fetchPageTitle(string $url): ?string<?php
/**
* 取得網頁標題
* @param string $url 目標網址
* @param int $timeout 總超時秒數
* @param int $maxSize 最大讀取位元組 (防止下載巨大檔案)
* @return string|null 成功回傳標題,失敗回傳 null
*/
function fetchPageTitle(string $url, int $timeout = 10, int $maxSize = 2_000_000): ?string {
// 1. 基本驗證
if (!filter_var($url, FILTER_VALIDATE_URL)) return null;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; TitleBot/1.0; +https://example.com/bot)',
CURLOPT_ENCODING => 'gzip, deflate', // 自動解壓
CURLOPT_SSL_VERIFYPEER => true, // 生產環境建議開啟
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($maxSize, &$buffer) {
$buffer .= $data;
if (strlen($buffer) > $maxSize) return 0; // 停止下載
return strlen($data);
},
CURLOPT_HEADER => true, // 需要 header 解析 Content-Type charset
]);
$buffer = '';
$response = curl_exec($ch);
$errno = curl_errno($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($errno !== CURLE_OK) {
error_log("CURL error {$errno} for {$url}: " . curl_strerror($errno));
return null;
}
// 2. 分離 header / body
$headerSize = $info['header_size'];
$headers = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
// 3. 從 HTTP Header 取得 charset
$charset = 'UTF-8'; // 預設
if (preg_match('/charset\s*=\s*([^\s;]+)/i', $headers, $m)) {
$charset = strtoupper(trim($m[1]));
}
// 4. 從 HTML meta 補充偵測 (若 header 無 charset)
if ($charset === 'UTF-8') {
if (preg_match('/]+charset=["\']?([^"\'>\s]+)/i', $body, $m)) {
$charset = strtoupper(trim($m[1]));
}
}
// 5. 正規化 charset 別名
$charsetMap = [
'UTF8' => 'UTF-8', 'UTF-8' => 'UTF-8',
'BIG5' => 'BIG-5', 'BIG-5' => 'BIG-5',
'GBK' => 'GBK', 'GB2312' => 'GBK', 'GB18030' => 'GBK',
'ISO-8859-1' => 'ISO-8859-1', 'LATIN1' => 'ISO-8859-1',
];
$charset = $charsetMap[$charset] ?? 'UTF-8';
// 6. 轉碼至 UTF-8
if ($charset !== 'UTF-8') {
$body = @iconv($charset, 'UTF-8//IGNORE', $body);
if ($body === false) $body = ''; // iconv 失敗
}
// 7. 提取 title (支援跨行、屬性、大小寫)
if (preg_match('/]*>(.*?)<\/title>/is', $body, $m)) {
$title = trim($m[1]);
// 移除多餘空白、換行
$title = preg_replace('/\s+/', ' ', $title);
return $title !== '' ? $title : null;
}
return null;
}
<?php
$urls = [
'https://www.google.com',
'https://www.ptt.cc/bbs/Gossiping/index.html', // Big5
'https://www.sohu.com', // GBK
];
foreach ($urls as $url) {
$title = fetchPageTitle($url);
echo "{$url} => " . ($title ?? 'FAILED') . "\n";
}
大量 URL 時,使用 curl_multi_* 並行可大幅縮短總耗時:
function fetchTitlesMulti(array $urls, int $concurrency = 10): array {
$mh = curl_multi_init();
$handles = [];
$results = [];
foreach ($urls as $url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_USERAGENT => 'TitleBot/1.0',
CURLOPT_WRITEFUNCTION => function($ch, $data) use (&$buffers, $url) {
$buffers[$url] ??= '';
$buffers[$url] .= $data;
return strlen($data);
},
]);
curl_multi_add_handle($mh, $ch);
$handles[$url] = $ch;
}
$running = null;
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh, 0.5);
} while ($running > 0);
foreach ($handles as $url => $ch) {
$body = $buffers[$url] ?? '';
$results[$url] = extractTitle($body); // 使用上述提取邏輯
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
return $results;
}
| 現象 | 原因 | 解決 |
|---|---|---|
| 抓到亂碼 | 編碼偵測失敗 | 優先信任 HTTP Header,次信任 meta,最後 fallback UTF-8;使用 //IGNORE 容錯 |
| 超時/無回應 | 目標站回應慢或封鎖 | 調整 CURLOPT_CONNECTTIMEOUT/CURLOPT_TIMEOUT;更換 User-Agent;使用代理池 |
| 抓到 JS 渲染後的標題 | SPA 網站 title 由 JS 設定 | 需改用 Headless Chrome (Puppeteer/Playwright) 或檢查 JSON-LD/og:title |
| 記憶體爆增 | 下載巨大頁面/圖片 | 設定 CURLOPT_WRITEFUNCTION 截斷;或先 HEAD 檢查 Content-Length 與 Content-Type |
| SSL 憑證驗證失敗 | 目標站憑證過期/自簽 | 生產環境不要關閉驗證;改用 CURLOPT_CAINFO 指定信任 CA |
DOMDocument 解析正則對付 HTML 雖快但脆弱,嚴謹專案建議:
$dom = new DOMDocument();
libxml_use_internal_errors(true);
$dom->loadHTML($body, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$titleNode = $dom->getElementsByTagName('title')->item(0);
$title = $titleNode ? trim($titleNode->textContent) : null;
完整 Library 化版本 (含 PSR-4、單元測試、Guzzle 介面) 見 GitHub。