採集圖片時保持原站目錄結構,便於本地鏡像、CDN 回源、離線瀏覽。本文提供生產級實現:目錄遞歸創建、流式下載避免內存溢出、文件名衝突去重、斷點續傳支持。
https://cdn.example.com/images/2024/01/photo.jpg)./images/2024/01/photo.jpg (保持相對路徑)function saveImageBasic(string $url, string $baseDir = '.'): ?string {
// 解析路徑
$parsed = parse_url($url);
$path = $parsed['path'] ?? ''; // /images/2024/01/photo.jpg
$path = ltrim($path, '/');
$savePath = $baseDir . '/' . $path;
$dir = dirname($savePath);
// 遞歸創建目錄 (0755 權限)
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true)) {
return null; // 創建失敗
}
// 可選:防止目錄遍歷
file_put_contents($dir . '/index.html', '');
}
// 下載並保存
$content = @file_get_contents($url);
if ($content === false) {
return null;
}
file_put_contents($savePath, $content);
return $savePath;
}
function saveImageStream(string $url, string $baseDir = '.', array $options = []): ?string {
$parsed = parse_url($url);
$path = ltrim($parsed['path'] ?? '', '/');
$savePath = $baseDir . '/' . $path;
$dir = dirname($savePath);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
return null;
}
// 文件名衝突處理:同名添加序號
$finalPath = $savePath;
$counter = 1;
$info = pathinfo($savePath);
while (file_exists($finalPath)) {
$finalPath = $info['dirname'] . '/' . $info['filename'] . "_$counter." . $info['extension'];
$counter++;
}
$fp = fopen($finalPath, 'wb');
if (!$fp) return null;
$ch = curl_init($url);
$defaultOpts = [
CURLOPT_FILE => $fp, // 直接寫入文件句柄
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; ImageCollector/1.0)',
CURLOPT_REFERER => $parsed['scheme'] . '://' . $parsed['host'] . '/', // 簡單防盜鏈
CURLOPT_LOW_SPEED_LIMIT => 1024, // 1 KB/s
CURLOPT_LOW_SPEED_TIME => 30, // 30 秒低速則中斷
];
curl_setopt_array($ch, array_merge($defaultOpts, $options));
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
fclose($fp);
if ($result === false || $httpCode >= 400) {
@unlink($finalPath); // 刪除失敗文件
error_log("Image download failed: $url - HTTP $httpCode - $error");
return null;
}
return $finalPath;
}
class ImageCollector {
private $baseDir;
private $maxConcurrency;
private $tempSuffix = '.downloading';
public function __construct(string $baseDir, int $maxConcurrency = 5) {
$this->baseDir = rtrim($baseDir, '/');
$this->maxConcurrency = $maxConcurrency;
}
public function collect(array $urls): array {
$results = [];
$pool = new \CurlMultiPool($this->maxConcurrency);
foreach ($urls as $url) {
$pool->addTask($url, [$this, 'downloadTask']);
}
while ($pool->hasPending()) {
$completed = $pool->wait();
foreach ($completed as $result) {
$results[] = $result;
}
}
return $results;
}
private function downloadTask(string $url): array {
$parsed = parse_url($url);
$path = ltrim($parsed['path'] ?? '', '/');
$savePath = $this->baseDir . '/' . $path;
$tempPath = $savePath . $this->tempSuffix;
$dir = dirname($savePath);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
return ['url' => $url, 'status' => 'dir_failed'];
}
// 斷點續傳:檢查臨時文件大小,設置 Range 請求頭
$resumeHeader = [];
if (file_exists($tempPath)) {
$size = filesize($tempPath);
if ($size > 0) {
$resumeHeader[] = "Range: bytes=$size-";
}
}
$fp = fopen($tempPath, 'ab'); // 追加模式
if (!$fp) return ['url' => $url, 'status' => 'file_open_failed'];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FILE => $fp,
CURLOPT_HTTPHEADER => $resumeHeader,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'ImageCollector/2.0',
CURLOPT_REFERER => $parsed['scheme'] . '://' . $parsed['host'] . '/',
CURLOPT_WRITEFUNCTION => function($ch, $data) use ($fp) {
return fwrite($fp, $data); // 手動寫入可獲取進度
},
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
fclose($fp);
if ($result === false) {
return ['url' => $url, 'status' => 'curl_failed', 'error' => $error];
}
if ($httpCode === 200 || $httpCode === 206) { // 206 Partial Content
// 可選:校驗 Content-Length / MD5
rename($tempPath, $savePath);
return ['url' => $url, 'status' => 'success', 'path' => $savePath];
}
return ['url' => $url, 'status' => 'http_error', 'code' => $httpCode];
}
}
// 簡易 CurlMultiPool 實現(生產建議用 amphp/http-client 或 guzzle/guzzle)
class CurlMultiPool { /* ... 省略 ... */ }
| 特性 | 基礎版 | 流式版 | 企業級 |
|---|---|---|---|
| 內存佔用 | 全文件載入內存 | 流式寫入,常數內存 | 流式 + 並發池控制 |
| 大文件支持 | ❌ 易 OOM | ✅ | ✅ + 斷點續傳 |
| 網絡異常處理 | 簡單失敗 | 超時/低速中斷 | 重試/斷點續傳/熔斷 |
| 文件名衝突 | 覆蓋 | 序號遞增 | 序號/哈希/時間戳 |
| 完整性校驗 | 無 | 可選 | Content-Length/MD5/CRC32 |
| 防盜鏈 | 無 | Referer 模擬 | 動態 Referer/Cookie/Token |
| 並發控制 | 串行 | 串行 | 連接池限流 |
location ~ \.php$ { deny all; }url_hash (md5) -> local_path,避免重複下載從簡單的 file_get_contents 到企業級採集系統,核心演進路徑:**流式下載 → 斷點續傳 → 並發控制 → 合規治理**。