Files
playground/extract_archives.py
T

231 lines
6.8 KiB
Python
Executable File

#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.13"
# ///
import argparse
import logging
import re
import subprocess
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
KNOWN_PASSWORDS = """
torgo
Yuzuki
oron.com
MonikGonPs
koth
drdoom-psuzy
psuzy
suzy
idle
dan4260
""".strip().splitlines(keepends=False)
class CorruptArchiveError(Exception):
pass
def find_archive_files(root_dir: Path) -> list[Path]:
extractables = []
globs = ["*.rar", "*.zip*", "*.7z*"]
for glob in globs:
for f in root_dir.rglob(glob):
extractables.append(f)
extractables = [f for f in extractables if is_extractable(f)]
return sorted(extractables, key=str)
def is_extractable(archive: Path) -> bool:
if ".corrupt" in archive.name:
return False
if re.search(r"\.zip|\.7z", archive.name):
zip_part = re.search(r"(\d+)$", archive.suffix)
if zip_part and int(zip_part.group(1)) == 1:
return True
if not zip_part:
return True
if archive.name.endswith(".rar"):
rar_part = re.search(r"\.part(\d+)", archive.name)
if not rar_part:
return True
if rar_part and int(rar_part.group(1)) == 1:
if archive.with_name(archive.name.replace("part1", "part2")).is_file():
return True
return False
def extract_archive_rar(archive: Path, output_dir: Path, password: str | None = None) -> None:
args = [
"rar",
"e",
*([f"-p{password}"] if password else []),
f"{archive.name}",
f"{output_dir}",
]
try:
subprocess.run(args, cwd=archive.parent, capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
output: str = e.stderr or e.stdout
if "corrupt file or wrong password" in output.lower():
raise LookupError("corrupt or invalid password")
elif e.returncode == 3:
raise FileNotFoundError("missing parts")
elif e.returncode == 10:
raise ValueError("not a rar file")
elif e.returncode == 11:
raise LookupError("invalid password")
raise
def extract_archive_7z(archive: Path, output_dir: Path, password: str | None = None):
args = [
"7z",
"e",
*(["-p", password] if password else []),
f"-o{output_dir}",
"-y",
f"{archive}",
]
try:
subprocess.run(args, capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
output: str = e.stderr or e.stdout
if "missing volume" in output.lower():
raise CorruptArchiveError("missing parts")
if "wrong password" in output.lower():
raise LookupError("invalid password")
raise
def extract_archive_zip(archive: Path, output_dir: Path, password: str | None = None):
args = [
"unzip",
"-o", # overwrite files without prompting
*(["-P", password] if password else []),
f"{archive}",
"-d",
f"{output_dir}",
]
try:
subprocess.run(args, capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
output: str = e.stderr or e.stdout
if "zipfile corrupt" in output.lower():
raise CorruptArchiveError("corrupt zip file")
if "cannot find zipfile" in output.lower():
raise FileNotFoundError("zip file not found")
if "incorrect password" in output.lower() or "password required" in output.lower():
raise LookupError("invalid password")
raise
def extract_archive(archive_path: Path, output_dir: Path, passwords: list[str]) -> None:
for p in ["", *passwords]:
try:
fn: callable
if ".rar" in archive_path.name:
fn = extract_archive_rar
elif ".zip" == archive_path.suffix:
fn = extract_archive_zip
else:
fn = extract_archive_7z
fn(archive_path, output_dir, p)
except subprocess.CalledProcessError as e:
logger.error(f"error extracting {archive_path}: {e.stderr}")
raise
except LookupError as e:
logger.info(f"invalid password: {p!r}. {e}")
continue
except Exception as e:
logger.error(f"failed to extract: {e}")
raise
def delete_archive(archive: Path):
logger.info(f"deleting: {archive.name}")
archive.unlink()
stem = archive.stem.rsplit(".", maxsplit=1)[0]
for f in archive.parent.rglob(f"*{archive.suffix}"):
if archive.stem.startswith(stem):
logger.info(f"deleting: {f.name}")
f.unlink()
def unlink_if_empty(dir_path: Path) -> None:
"""Unlink the directory if it is empty."""
try:
if not any(dir_path.iterdir()):
dir_path.rmdir()
logger.info(f"deleted empty directory: {dir_path}")
except OSError as e:
logger.error(f"error deleting directory: {e}")
raise
def parse_args():
arger = argparse.ArgumentParser()
arger.add_argument("archive_paths", type=lambda v: Path(v.strip()), nargs="+", help="List of archive paths")
arger.add_argument("--cwd", type=Path, default=Path.cwd(), help="working directory")
arger.add_argument("--find", default=False, help="find archives and print to stdout", action="store_true")
arger.add_argument("--clean", default=False, help="delete file(s) after successful extraction", action="store_true")
if len(sys.argv) < 2:
arger.print_help()
exit(1)
return arger.parse_args()
def main():
logging.basicConfig(level=logging.INFO)
args = parse_args()
cwd: Path = args.cwd
if args.find:
archives = find_archive_files(cwd)
for f in archives:
print(str(f.resolve()))
return
archives = args.archive_paths
failed = []
for i, f in enumerate(archives, start=1):
f: Path
progress = f"{i:02}/{len(archives):02}"
logger.info(f"extracting {progress}: {f.relative_to(cwd)}")
output_dir = f.parent / f.stem.strip()
try:
output_dir.mkdir(exist_ok=True, parents=True)
extract_archive(f, output_dir=output_dir, passwords=KNOWN_PASSWORDS)
if args.clean:
delete_archive(f)
except CorruptArchiveError as e:
if ".corrupt" not in f.name:
f.rename(f.with_stem(f"{f.stem}.corrupt"))
except (ValueError, FileNotFoundError) as e:
logger.error(str(e))
failed.append(f)
finally:
unlink_if_empty(output_dir)
if failed:
logger.info(f"failed to extract {len(failed)} archives")
for f in failed:
logger.info(f"{f.relative_to(cwd)}")
if __name__ == "__main__":
main()