專案定位
一個極簡的 PHP 單檔采集腳本,適用於靜態/偽靜態頁面、結構固定、無反爬、小規模一次性采集任務。核心流程:HTTP GET 抓取 HTML → 正則提取 <title> 與內容區塊 (指定開始/結束標記) → 簡易清洗 (去標籤、實體解碼) → 儲存靜態檔案。
完整程式碼 (重構增強版)
<?php
/**
* 簡易文章采集器
* 配置區塊請根據目標站點修改
*/
class SimpleArticleScraper {
// ===== 配置 =====
private $targetUrl = 'https://example.com/article/123.html';
private $startMarker = ''; // 內容區塊起始特徵
private $endMarker = '
'; // 內容區塊結束特徵
private $outputDir = __DIR__ . '/data/articles/';
private $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
private $timeout = 30;
public function run() {
// 1. 抓取
$html = $this->fetch($this->targetUrl);
if (!$html) return $this->log("抓取失敗: {$this->targetUrl}");
// 2. 提取標題
$title = $this->extractTitle($html);
if (!$title) return $this->log("標題提取失敗");
// 3. 提取內容
$content = $this->extractContent($html);
if (!$content) return $this->log("內容提取失敗 (檢查 Marker)");
// 4. 清洗
$cleanContent = $this->cleanHtml($content);
// 5. 儲存
$filename = $this->sanitizeFilename($title) . '.html';
$saved = $this->saveFile($filename, $cleanContent);
if ($saved) {
$this->log("✅ 采集成功: {$title} -> {$filename}");
} else {
$this->log("❌ 儲存失敗");
}
}
private function fetch($url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_USERAGENT => $this->userAgent,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_ENCODING => 'gzip,deflate',
]);
$html = curl_exec($ch);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno) {
$this->log("cURL Error [$errno]: " . curl_strerror($errno));
return false;
}
// 編碼統一
if (mb_detect_encoding($html, 'GBK,UTF-8,BIG5') !== 'UTF-8') {
$html = mb_convert_encoding($html, 'UTF-8', mb_detect_encoding($html, 'GBK,UTF-8,BIG5'));
}
return $html;
}
private function extractTitle($html) {
if (preg_match('/
]*>(.*?)<\/title>/is', $html, $m)) {
return trim(strip_tags($m[1]));
}
// 備用:h1
if (preg_match('/]*>(.*?)<\/h1>/is', $html, $m)) {
return trim(strip_tags($m[1]));
}
return false;
}
private function extractContent($html) {
// 策略:找到 startMarker 後的內容,直到 endMarker
$startPos = strpos($html, $this->startMarker);
if ($startPos === false) return false;
$startPos += strlen($this->startMarker);
$endPos = strpos($html, $this->endMarker, $startPos);
if ($endPos === false) {
// 無結束標記,取剩餘或固定長度
return substr($html, $startPos, 50000);
}
return substr($html, $startPos, $endPos - $startPos);
}
private function cleanHtml($html) {
// 1. 移除 script, style, iframe, noscript 等
$html = preg_replace('/<(script|style|iframe|noscript)[^>]*>.*?<\/\1>/is', '', $html);
// 2. 移除所有標籤,保留文字 (或保留 p, br, img, a 等)
// 此處示範「保留基本排版標籤」
$allowed = '
![]()
';
$html = strip_tags($html, $allowed);
// 3. 解碼實體
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// 4. 修正圖片連結為絕對路徑 (簡易)
$html = preg_replace_callback('/
]+src=["\']([^"\']+)["\']/i', function($m) {
$src = $m[1];
if (strpos($src, 'http') !== 0) {
$src = $this->resolveUrl($src);
}
return str_replace($m[1], $src, $m[0]);
}, $html);
// 5. 壓縮空白
$html = preg_replace('/\s+/', ' ', $html);
return trim($html);
}
private function resolveUrl($relative) {
$base = parse_url($this->targetUrl);
$baseUrl = $base['scheme'] . '://' . $base['host'] . (isset($base['port']) ? ':' . $base['port'] : '');
$path = dirname($base['path']);
if ($relative[0] === '/') return $baseUrl . $relative;
return $baseUrl . $path . '/' . $relative;
}
private function sanitizeFilename($title) {
$filename = preg_replace('/[\\\\\/:*?"<>|]/', '_', $title); // 非法字元
$filename = mb_substr($filename, 0, 100, 'UTF-8'); // 限制長度
return $filename ?: 'article_' . time();
}
private function saveFile($filename, $content) {
if (!is_dir($this->outputDir)) {
mkdir($this->outputDir, 0755, true);
}
$path = $this->outputDir . $filename;
// 避免覆蓋
$counter = 1;
$origPath = $path;
while (file_exists($path)) {
$info = pathinfo($origPath);
$path = $info['dirname'] . '/' . $info['filename'] . "_{$counter}." . $info['extension'];
$counter++;
}
return file_put_contents($path, $content, LOCK_EX) !== false;
}
private function log($msg) {
echo "[" . date('H:i:s') . "] {$msg}\n";
}
}
// ===== 執行 =====
// 可從 CLI 執行: php scraper.php
// 或 Web 觸發 (需注意超時)
$scraper = new SimpleArticleScraper();
// $scraper->targetUrl = $_GET['url'] ?? $scraper->targetUrl; // Web 模式需驗證 URL 安全性
$scraper->run();
?>
核心邏輯圖解
flowchart TD
A[開始] --> B{抓取 HTML}
B -- 失敗 --> C[記錄錯誤/重試] --> Z[結束]
B -- 成功 --> D[提取 Title]
D --> E[定位內容區塊
startMarker ~ endMarker]
E -- 失敗 --> C
E -- 成功 --> F[HTML 清洗
去標籤/實體/絕對化圖片]
F --> G[生成安全檔名]
G --> H[寫入檔案]
H --> I[成功/失敗日誌]
I --> Z
關鍵技術難點與對策
| 難點 | 簡易版缺陷 | 進階解決方案 |
| 內容區塊定位 | 靠固定字串 Marker,網站改版即失效 | XPath/CSS Selector (DOMDocument, Symfony DomCrawler) + 多備選選擇器 + 相似度比對 |
| 分頁/列表采集 | 僅支援單頁 | 列表頁提取連結 → 隊列逐個采集 → 入庫去重 |
| 反爬蟲 | 無應對,極易被封 | 代理 IP 池、請求指紋偽裝、Headless Chrome、驗證碼識別 API |
| 動態渲染 (JS) | file_get_contents 拿不到 JS 生成內容 | Puppeteer/Playwright (Node/Python) 或 PHP spatie/browsershot |
| 編碼混亂 | 簡易偵測 | 強制 mb_convert_encoding、Encoding::toUTF8() (voku/portable-utf8) |
| 大量采集效能 | 同步阻塞,極慢 | Swoole/OpenSwoole 協程並發、Guzzle cURL Multi、Gearman/RabbitMQ 隊列 |
生產級采集架構建議
- 調度層:Crontab / Laravel Scheduler / Celery Beat 定時觸發。
- 任務隊列:Redis Lists / RabbitMQ / Kafka 存放待采集 URL (優先級、重試次數)。
- Worker 消費者:Swoole 協程 / PHP-FPM + Supervisor / Go/Rust 高併發服務。
- 抓取引擎:Guzzle (靜態) + Browsershot (動態) + 代理中介軟體。
- 解析層:配置化選擇器 (YAML/JSON) + 版本管理 + 變更告警。
- 去重/存儲:Bloom Filter / Redis Set / DB Unique Key 去重 → MySQL/ES/MinIO 存儲。
- 監控告警:成功率、耗時、IP 存活率、目標站結構變更偵測 (DOM 雜湊比對)。
法律合規紅線 (必讀)
⚖️ 采集 ≠ 合法
- 遵守
robots.txt (User-agent, Disallow, Crawl-delay)。
- 檢查網站
Terms of Service,明文禁止采集者法律風險極高。
- 勿采集個人隱私 (姓名、電話、身分證、醫療、金融數據) — 違反 GDPR/個資法/網安法。
- 勿大規模高頻請求導致目標服務癱瘓 — 可能構成破壞計算機信息系統罪。
- 采集內容僅供個人研究/搜尋引擎索引,嚴禁商業轉載、訓練 AI 模型未授權數據。
結語
本簡易采集器適合理解「抓取 → 解析 → 清洗 → 存儲」基礎流程。實務中,請勿直接用於生產,應採用 Scrapy (Python)、Colly (Go)、Crawlee (Node.js) 等成熟框架,或商業級采集平台 (Bright Data, Apify, Octoparse),並在法律顧問指導下合規操作。
- 上一篇文章: PHP MySQL 資料庫操作類 (mysql_* 版) 完整原始碼與現代化遷移指南
- 下一篇文章: PHP 簡易模板引擎實現:基於 file_get_contents 與標籤替換的入門教程