Files
2026-09-22 18:48:00 +08:00

139 lines
4.5 KiB
Python

"""Download a release archive and extract it. A complete local file is extracted without downloading again."""
import tarfile
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
UA = "LLLL-tools"
def _archive_name(path: Path) -> str:
name = path.name.lower()
if name.endswith(".part"):
name = name[: -len(".part")]
return name
def archive_complete(path: Path) -> bool:
if not path.is_file() or path.stat().st_size == 0:
return False
name = _archive_name(path)
try:
if name.endswith(".zip") or name.endswith(".ipa"):
with zipfile.ZipFile(path) as archive:
return bool(archive.infolist())
if ".tar." in name or name.endswith(".tgz"):
mode = "r:xz" if name.endswith(".tar.xz") else "r:gz"
with tarfile.open(path, mode) as archive:
for _ in archive:
pass
return True
except (OSError, tarfile.TarError, zipfile.BadZipFile):
return False
return False
def _total_size(resp, have: int, code: int) -> int | None:
if code == 206:
content_range = resp.headers.get("Content-Range")
if content_range and "/" in content_range:
total = content_range.rsplit("/", 1)[-1]
if total.isdigit():
return int(total)
length = resp.headers.get("Content-Length")
if length and length.isdigit():
return have + int(length)
return None
length = resp.headers.get("Content-Length")
if length and length.isdigit():
return int(length)
return None
def ensure_archive(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
part = dest.with_name(dest.name + ".part")
if archive_complete(dest):
return
if archive_complete(part):
part.replace(dest)
return
if dest.exists() and not part.exists():
dest.replace(part)
elif dest.exists():
dest.unlink()
have = part.stat().st_size if part.exists() else 0
headers = {"User-Agent": UA}
if have:
headers["Range"] = f"bytes={have}-"
request = urllib.request.Request(url, headers=headers)
try:
response = urllib.request.urlopen(request, timeout=120)
except urllib.error.HTTPError as exc:
if exc.code == 416 and archive_complete(part):
part.replace(dest)
return
print(f"下载失败:{exc.code}")
raise SystemExit(2)
except (urllib.error.URLError, TimeoutError, OSError):
print("下载未完成")
raise SystemExit(2)
try:
with response:
code = getattr(response, "status", 200)
if code == 200:
have = 0
elif code != 206:
print(f"下载失败:{code}")
raise SystemExit(2)
total = _total_size(response, have, code)
shown = -1
with part.open("wb" if code == 200 else "ab") as output:
done = have if code == 206 else 0
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
output.write(chunk)
done += len(chunk)
if not total:
continue
pct = min(100, done * 100 // total)
if pct // 10 != shown:
shown = pct // 10
print(f"\r{pct}%", end="", flush=True)
if shown >= 0:
print()
except (urllib.error.URLError, TimeoutError, OSError):
print("下载未完成")
raise SystemExit(2)
if archive_complete(part):
part.replace(dest)
return
if total and part.exists() and part.stat().st_size == total:
part.unlink(missing_ok=True)
print("下载内容无法解压")
raise SystemExit(2)
print("下载未完成")
raise SystemExit(2)
def extract_archive(archive: Path, dest: Path) -> None:
dest.mkdir(parents=True, exist_ok=True)
root = dest.resolve()
name = archive.name.lower()
if name.endswith(".zip"):
with zipfile.ZipFile(archive) as zipped:
for info in zipped.infolist():
target = (dest / info.filename).resolve()
if not target.is_relative_to(root):
raise SystemExit("压缩包路径不安全")
zipped.extractall(dest)
return
with tarfile.open(archive) as packed:
packed.extractall(dest, filter="data")