<?php
/**
* RemoteDownloader - 遠程文件下載器
*/
class RemoteDownloader {
private string $url = '';
private string $savePath = './downloads/';
private string $fileName = '';
private bool $forceRedownload = false;
private string $error = '';
private ?callable $progressCallback = null;
/** 設置下載 URL */
public function setUrl(string $url): self {
$this->url = $url;
return $this;
}
/** 設置保存目錄 */
public function setSavePath(string $path): self {
$this->savePath = rtrim($path, '/') . '/';
return $this;
}
/** 設置保存文件名(留空則自動從 URL 推斷) */
public function setFileName(string $name): self {
$this->fileName = $name;
return $this;
}
/** 是否強制重新下載 */
public function setForceRedownload(bool $force): self {
$this->forceRedownload = $force;
return $this;
}
/** 設置進度回調 function(int $downloaded, int $total): void */
public function setProgressCallback(callable $cb): self {
$this->progressCallback = $cb;
return $this;
}
/** 執行下載 */
public function download(): bool {
if (!$this->validateUrl()) return false;
if (!$this->prepareSavePath()) return false;
$localFile = $this->savePath . $this->getFinalFileName();
// 檢查文件是否已存在
if (file_exists($localFile) && !$this->forceRedownload) {
$this->error = "文件已存在:{$localFile},啟用 forceRedownload 可覆蓋";
return false;
}
// 打開遠程流
$remote = @fopen($this->url, 'rb');
if (!$remote) {
$this->error = "無法打開遠程文件:{$this->url}";
return false;
}
// 獲取文件總大小(用於進度)
$meta = stream_get_meta_data($remote);
$totalSize = 0;
foreach ($meta['wrapper_data'] as $header) {
if (stripos($header, 'Content-Length:') === 0) {
$totalSize = (int)trim(substr($header, 15));
break;
}
}
// 打開本地文件
$local = @fopen($localFile, 'wb');
if (!$local) {
fclose($remote);
$this->error = "無法創建本地文件:{$localFile}";
return false;
}
// 分塊下載
$downloaded = 0;
$bufferSize = 8192;
while (!feof($remote)) {
$chunk = fread($remote, $bufferSize);
if ($chunk === false) break;
if (fwrite($local, $chunk) === false) {
$this->error = '寫入本地文件失敗';
fclose($remote);
fclose($local);
@unlink($localFile);
return false;
}
$downloaded += strlen($chunk);
if ($this->progressCallback) {
$this->progressCallback($downloaded, $totalSize);
}
}
fclose($remote);
fclose($local);
if ($downloaded === 0) {
@unlink($localFile);
$this->error = '下載內容為空';
return false;
}
return true;
}
/** 獲取最後錯誤 */
public function getError(): string {
return $this->error;
}
/** 獲取最終文件名 */
public function getFinalFileName(): string {
if ($this->fileName) return $this->fileName;
$parsed = parse_url($this->url);
$basename = basename($parsed['path'] ?? '');
return $basename ?: 'download_' . time() . '.bin';
}
/** URL 合法性檢查 */
private function validateUrl(): bool {
if ($this->url === '') {
$this->error = 'URL 不能為空';
return false;
}
if (!filter_var($this->url, FILTER_VALIDATE_URL)) {
$this->error = 'URL 格式非法:' . $this->url;
return false;
}
$scheme = parse_url($this->url, PHP_URL_SCHEME);
if (!in_array($scheme, ['http', 'https', 'ftp'])) {
$this->error = '不支持的協議:' . $scheme;
return false;
}
return true;
}
/** 創建保存目錄 */
private function prepareSavePath(): bool {
if (!is_dir($this->savePath)) {
if (!mkdir($this->savePath, 0755, true)) {
$this->error = '無法創建保存目錄:' . $this->savePath;
return false;
}
}
return true;
}
}
?>
$downloader = new RemoteDownloader();
$downloader->setUrl('https://example.com/large-file.zip')
->setSavePath('/var/www/files/')
->setFileName('my-file.zip');
if ($downloader->download()) {
echo "下載成功:{$downloader->getFinalFileName()}";
} else {
echo "失敗:{$downloader->getError()}";
}
$downloader = new RemoteDownloader();
$downloader->setUrl('https://speed.hetzner.de/100MB.bin')
->setSavePath('/tmp/')
->setProgressCallback(function($done, $total) {
static $lastPct = -1;
if ($total > 0) {
$pct = (int)($done / $total * 100);
if ($pct !== $lastPct) {
echo "\r進度:{$pct}% (" . formatBytes($done) . " / " . formatBytes($total) . ")";
$lastPct = $pct;
}
} else {
echo "\r已下載:" . formatBytes($done);
}
});
if ($downloader->download()) {
echo "\n完成!\n";
} else {
echo "\n失敗:{$downloader->getError()}\n";
}
function formatBytes($bytes) {
$units = ['B','KB','MB','GB'];
$i = 0;
while ($bytes >= 1024 && $i < 3) { $bytes /= 1024; $i++; }
return round($bytes, 2) . ' ' . $units[$i];
}
<!-- 前端 -->
<button id="startBtn">開始下載</button>
<div id="progress" style="display:none">
<progress id="bar" max="100" value="0"></progress>
<span id="pct">0%</span>
</div>
<script>
document.getElementById('startBtn').onclick = async () => {
const btn = document.getElementById('startBtn');
btn.disabled = true;
const res = await fetch('download_async.php', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({url: 'https://example.com/big.zip'})
});
const {task_id} = await res.json();
document.getElementById('progress').style.display = 'block';
pollProgress(task_id);
};
function pollProgress(taskId) {
fetch('download_status.php?task_id=' + taskId)
.then(r => r.json())
.then(data => {
document.getElementById('bar').value = data.pct;
document.getElementById('pct').textContent = data.pct + '%';
if (data.status === 'running') setTimeout(() => pollProgress(taskId), 1000);
else if (data.status === 'done') alert('下載完成:' + data.file);
else alert('失敗:' + data.error);
});
}
</script>
HTTP 範圍請求 (Range: bytes=START-) 實現續傳:
// 在 download() 中,若本地文件已存在部分內容
$localSize = file_exists($localFile) ? filesize($localFile) : 0;
if ($localSize > 0 && !$this->forceRedownload) {
// 請求剩餘部分
$context = stream_context_create([
'http' => ['header' => "Range: bytes={$localSize}-\r\n"]
]);
$remote = fopen($this->url, 'rb', false, $context);
// 打開本地文件追加模式 'ab'
$local = fopen($localFile, 'ab');
// ... 後續邏輯同樣
}
stream_context_create(['http'=>['timeout'=>30]]) 避免掛起。Content-Length,超過閾值拒絕或需確認。.tmp 文件,成功後 rename() 覆蓋目標,防止部分文件被誤用。