"""Archive every site image generation, prompt and published output to Yandex Disk. Run with --plan first, then --copy. Existing files are verified, never replaced. """ from __future__ import annotations import argparse import hashlib import json import os from pathlib import Path import shutil import uuid ROOT = Path(__file__).resolve().parent YANDEX_SITE = Path(r'D:\YandexDisk\servaki.online\лендинги\servaki.online site') GROUPS = ('artwork', 'assets', 'content', 'public') MEDIA_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.webm'} def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open('rb') as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b''): digest.update(chunk) return digest.hexdigest() def planned_files(source: Path, raw: Path): files = [] for dirname in GROUPS: directory = source / dirname if not directory.is_dir(): raise RuntimeError(f'Missing source directory: {directory}') files.extend((path, path.relative_to(source)) for path in directory.rglob('*') if path.is_file() and not path.is_symlink()) if not raw.is_dir(): raise RuntimeError(f'Missing image-generation directory: {raw}') files.extend((path, Path('imagegen-raw') / path.name) for path in raw.iterdir() if path.is_file() and not path.is_symlink() and path.suffix.lower() in MEDIA_EXTENSIONS) files.append((source / 'README.md', Path('SITE-BUILD.md'))) if len({relative.as_posix() for _, relative in files}) != len(files): raise RuntimeError('Duplicate archive destination') return sorted(files, key=lambda item: item[1].as_posix()) def main(): parser = argparse.ArgumentParser() parser.add_argument('--copy', action='store_true', help='copy and verify files') parser.add_argument('--source', type=Path, default=ROOT) parser.add_argument('--raw-dir', type=Path, required=True, help='directory containing raw generated media for this release') parser.add_argument('--target', type=Path, required=True, help='new release folder under the Yandex Disk site directory') args = parser.parse_args() base = YANDEX_SITE.resolve() target = args.target.resolve() if not base.is_dir() or not target.is_relative_to(base) or target == base: raise RuntimeError('Target must be a subfolder of the existing Yandex Disk site folder') files = planned_files(args.source.resolve(), args.raw_dir.resolve()) total = sum(source.stat().st_size for source, _ in files) counts = {group: sum(relative.parts[0] == group for _, relative in files) for group in (*GROUPS, 'imagegen-raw')} print(json.dumps({'target': str(target), 'files': len(files), 'bytes': total, 'groups': counts, 'mode': 'copy' if args.copy else 'plan'}, ensure_ascii=False)) if not args.copy: return target.mkdir(parents=True, exist_ok=True) entries = [] artwork_hashes = {} for source, relative in files: expected = sha256(source) if relative.parts[0] == 'artwork': artwork_hashes.setdefault(expected, []).append(relative.as_posix()) destination = target / relative destination.parent.mkdir(parents=True, exist_ok=True) if destination.exists(): if sha256(destination) != expected: raise RuntimeError(f'Archive conflict: {destination}') else: temporary = destination.with_name(destination.name + '.part-' + uuid.uuid4().hex[:8]) try: shutil.copy2(source, temporary) if sha256(temporary) != expected: raise RuntimeError(f'Copy checksum mismatch: {relative}') os.replace(temporary, destination) finally: if temporary.exists(): temporary.unlink() entries.append({'path': relative.as_posix(), 'bytes': source.stat().st_size, 'sha256': expected}) raw_matches = {entry['path']: artwork_hashes.get(entry['sha256'], []) for entry in entries if entry['path'].startswith('imagegen-raw/')} manifest = {'site': 'servaki.online', 'release_date': '2026-09-28', 'files': entries, 'raw_matches_in_artwork': raw_matches} destination = target / 'MANIFEST.json' temporary = target / ('MANIFEST.json.part-' + uuid.uuid4().hex[:8]) temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') os.replace(temporary, destination) for entry in entries: if sha256(target / entry['path']) != entry['sha256']: raise RuntimeError(f'Final checksum mismatch: {entry["path"]}') print(json.dumps({'verified_files': len(entries), 'verified_bytes': total, 'raw_matched_to_artwork': sum(bool(paths) for paths in raw_matches.values())}, ensure_ascii=False)) if __name__ == '__main__': main()